{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}
{% set links = table_actions() %}{% if links %}
diff --git a/datasette/views/base.py b/datasette/views/base.py
index da5c55ad..0080b33c 100644
--- a/datasette/views/base.py
+++ b/datasette/views/base.py
@@ -102,9 +102,6 @@ class BaseView:
response.body = b""
return response
- def database_color(self, database):
- return "ff0000"
-
async def method_not_allowed(self, request):
if (
request.path.endswith(".json")
@@ -150,7 +147,6 @@ class BaseView:
template_context = {
**context,
**{
- "database_color": self.database_color,
"select_templates": [
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 79b3f88d..d9abc38a 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -146,6 +146,7 @@ class DatabaseView(View):
template = datasette.jinja_env.select_template(templates)
context = {
**json_data,
+ "database_color": db.color,
"database_actions": database_actions,
"show_hidden": request.args.get("_show_hidden"),
"editable": True,
@@ -154,7 +155,6 @@ class DatabaseView(View):
and not db.is_mutable
and not db.is_memory,
"attached_databases": attached_databases,
- "database_color": lambda _: "#ff0000",
"alternate_url_json": alternate_url_json,
"select_templates": [
f"{'*' if template_name == template.name else ''}{template_name}"
@@ -179,6 +179,7 @@ class DatabaseView(View):
@dataclass
class QueryContext:
database: str = field(metadata={"help": "The name of the database being queried"})
+ database_color: str = field(metadata={"help": "The color of the database"})
query: dict = field(
metadata={"help": "The SQL query object containing the `sql` string"}
)
@@ -232,9 +233,6 @@ class QueryContext:
show_hide_hidden: str = field(
metadata={"help": "Hidden input field for the _show_sql parameter"}
)
- database_color: Callable = field(
- metadata={"help": "Function that returns a color for a given database name"}
- )
table_columns: dict = field(
metadata={"help": "Dictionary of table name to list of column names"}
)
@@ -689,6 +687,7 @@ class QueryView(View):
template,
QueryContext(
database=database,
+ database_color=db.color,
query={
"sql": sql,
"params": params,
@@ -721,7 +720,6 @@ class QueryView(View):
),
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
metadata=canned_query or metadata,
- database_color=lambda _: "#ff0000",
alternate_url_json=alternate_url_json,
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
diff --git a/datasette/views/index.py b/datasette/views/index.py
index df411c4a..95b29302 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -105,9 +105,7 @@ class IndexView(BaseView):
{
"name": name,
"hash": db.hash,
- "color": db.hash[:6]
- if db.hash
- else hashlib.md5(name.encode("utf8")).hexdigest()[:6],
+ "color": db.color,
"path": self.ds.urls.database(name),
"tables_and_views_truncated": tables_and_views_truncated,
"tables_and_views_more": (len(visible_tables) + len(views))
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 6e09f30e..8f07a662 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -18,7 +18,8 @@ class RowView(DataView):
async def data(self, request, default_labels=False):
resolved = await self.ds.resolve_row(request)
- database = resolved.db.name
+ db = resolved.db
+ database = db.name
table = resolved.table
pk_values = resolved.pk_values
@@ -60,6 +61,7 @@ class RowView(DataView):
"foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values
),
+ "database_color": db.color,
"display_columns": display_columns,
"display_rows": display_rows,
"custom_table_templates": [
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 28264e92..6df8b915 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -1408,7 +1408,7 @@ async def table_view_data(
return table_name
async def extra_database_color():
- return lambda _: "ff0000"
+ return db.color
async def extra_form_hidden_args():
form_hidden_args = []
diff --git a/tests/test_html.py b/tests/test_html.py
index 7856bc27..ffc2aef1 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1103,3 +1103,23 @@ async def test_breadcrumbs_respect_permissions(
assert actual == expected_links
finally:
ds_client.ds._metadata_local = orig
+
+
+@pytest.mark.asyncio
+async def test_database_color(ds_client):
+ expected_color = ds_client.ds.get_database("fixtures").color
+ # Should be something like #9403e5
+ expected_fragments = (
+ "10px solid #{}".format(expected_color),
+ "border-color: #{}".format(expected_color),
+ )
+ assert len(expected_color) == 6
+ for path in (
+ "/",
+ "/fixtures",
+ "/fixtures/facetable",
+ "/fixtures/paginated_view",
+ "/fixtures/pragma_cache_size",
+ ):
+ response = await ds_client.get(path)
+ assert any(fragment in response.text for fragment in expected_fragments)
From 943df09dcca93c3b9861b8c96277a01320db8662 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Fri, 11 Aug 2023 10:44:34 -0700
Subject: [PATCH 190/665] Remove all remaining "$ " prefixes from docs, closes
#2140
Also document sqlite-utils create-view
---
docs/authentication.rst | 5 ++++-
docs/changelog.rst | 14 ++++++++++----
docs/cli-reference.rst | 5 ++++-
docs/contributing.rst | 34 +++++++++++++++++++++++++---------
docs/custom_templates.rst | 8 ++++----
docs/deploying.rst | 2 +-
docs/facets.rst | 7 +++++--
docs/full_text_search.rst | 4 ++--
docs/installation.rst | 38 ++++++++++++++++++++++++++++++++------
docs/plugin_hooks.rst | 2 +-
docs/publish.rst | 4 ++--
docs/settings.rst | 12 ++++++------
docs/spatialite.rst | 5 ++++-
docs/sql_queries.rst | 9 ++++++++-
14 files changed, 108 insertions(+), 41 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 8864086f..814d2e67 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -32,7 +32,10 @@ The one exception is the "root" account, which you can sign into while using Dat
To sign in as root, start Datasette using the ``--root`` command-line option, like this::
- $ datasette --root
+ datasette --root
+
+::
+
http://127.0.0.1:8001/-/auth-token?token=786fc524e0199d70dc9a581d851f466244e114ca92f33aa3b42a139e9388daa7
INFO: Started server process [25801]
INFO: Waiting for application startup.
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 4c70855b..c497ea9f 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -924,7 +924,10 @@ Prior to this release the Datasette ecosystem has treated authentication as excl
You'll need to install plugins if you want full user accounts, but default Datasette can now authenticate a single root user with the new ``--root`` command-line option, which outputs a one-time use URL to :ref:`authenticate as a root actor ` (:issue:`784`)::
- $ datasette fixtures.db --root
+ datasette fixtures.db --root
+
+::
+
http://127.0.0.1:8001/-/auth-token?token=5b632f8cd44b868df625f5a6e2185d88eea5b22237fd3cc8773f107cc4fd6477
INFO: Started server process [14973]
INFO: Waiting for application startup.
@@ -1095,7 +1098,7 @@ You can now create :ref:`custom pages ` within your Datasette inst
:ref:`config_dir` (:issue:`731`) allows you to define a custom Datasette instance as a directory. So instead of running the following::
- $ datasette one.db two.db \
+ datasette one.db two.db \
--metadata=metadata.json \
--template-dir=templates/ \
--plugins-dir=plugins \
@@ -1103,7 +1106,7 @@ You can now create :ref:`custom pages ` within your Datasette inst
You can instead arrange your files in a single directory called ``my-project`` and run this::
- $ datasette my-project/
+ datasette my-project/
Also in this release:
@@ -1775,7 +1778,10 @@ In addition to the work on facets:
Added new help section::
- $ datasette --help-config
+ datasette --help-config
+
+ ::
+
Config options:
default_page_size Default page size for the table view
(default=100)
diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst
index 7a96d311..3dd991f3 100644
--- a/docs/cli-reference.rst
+++ b/docs/cli-reference.rst
@@ -151,7 +151,10 @@ This means that all of Datasette's functionality can be accessed directly from t
For example::
- $ datasette --get '/-/versions.json' | jq .
+ datasette --get '/-/versions.json' | jq .
+
+.. code-block:: json
+
{
"python": {
"version": "3.8.5",
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 697002a8..ef022a4d 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -133,13 +133,19 @@ Running Black
Black will be installed when you run ``pip install -e '.[test]'``. To test that your code complies with Black, run the following in your root ``datasette`` repository checkout::
- $ black . --check
+ black . --check
+
+::
+
All done! ✨ 🍰 ✨
95 files would be left unchanged.
If any of your code does not conform to Black you can run this to automatically fix those problems::
- $ black .
+ black .
+
+::
+
reformatted ../datasette/setup.py
All done! ✨ 🍰 ✨
1 file reformatted, 94 files left unchanged.
@@ -160,11 +166,14 @@ Prettier
To install Prettier, `install Node.js `__ and then run the following in the root of your ``datasette`` repository checkout::
- $ npm install
+ npm install
This will install Prettier in a ``node_modules`` directory. You can then check that your code matches the coding style like so::
- $ npm run prettier -- --check
+ npm run prettier -- --check
+
+::
+
> prettier
> prettier 'datasette/static/*[!.min].js' "--check"
@@ -174,7 +183,7 @@ This will install Prettier in a ``node_modules`` directory. You can then check t
You can fix any problems by running::
- $ npm run fix
+ npm run fix
.. _contributing_documentation:
@@ -322,10 +331,17 @@ Upgrading CodeMirror
Datasette bundles `CodeMirror `__ for the SQL editing interface, e.g. on `this page `__. Here are the steps for upgrading to a new version of CodeMirror:
+* Install the packages with::
-* Install the packages with `npm i codemirror @codemirror/lang-sql`
-* Build the bundle using the version number from package.json with:
+ npm i codemirror @codemirror/lang-sql
- node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js -f iife -n cm -o datasette/static/cm-editor-6.0.1.bundle.js -p @rollup/plugin-node-resolve -p @rollup/plugin-terser
+* Build the bundle using the version number from package.json with::
-* Update version reference in the `codemirror.html` template
\ No newline at end of file
+ node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \
+ -f iife \
+ -n cm \
+ -o datasette/static/cm-editor-6.0.1.bundle.js \
+ -p @rollup/plugin-node-resolve \
+ -p @rollup/plugin-terser
+
+* Update the version reference in the ``codemirror.html`` template.
\ No newline at end of file
diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst
index f9d0b5f5..c0f64cb5 100644
--- a/docs/custom_templates.rst
+++ b/docs/custom_templates.rst
@@ -259,7 +259,7 @@ Consider the following directory structure::
You can start Datasette using ``--static assets:static-files/`` to serve those
files from the ``/assets/`` mount point::
- $ datasette -m metadata.json --static assets:static-files/ --memory
+ datasette -m metadata.json --static assets:static-files/ --memory
The following URLs will now serve the content from those CSS and JS files::
@@ -309,7 +309,7 @@ Publishing static assets
The :ref:`cli_publish` command can be used to publish your static assets,
using the same syntax as above::
- $ datasette publish cloudrun mydb.db --static assets:static-files/
+ datasette publish cloudrun mydb.db --static assets:static-files/
This will upload the contents of the ``static-files/`` directory as part of the
deployment, and configure Datasette to correctly serve the assets from ``/assets/``.
@@ -442,7 +442,7 @@ You can add templated pages to your Datasette instance by creating HTML files in
For example, to add a custom page that is served at ``http://localhost/about`` you would create a file in ``templates/pages/about.html``, then start Datasette like this::
- $ datasette mydb.db --template-dir=templates/
+ datasette mydb.db --template-dir=templates/
You can nest directories within pages to create a nested structure. To create a ``http://localhost:8001/about/map`` page you would create ``templates/pages/about/map.html``.
@@ -497,7 +497,7 @@ To serve a custom HTTP header, add a ``custom_header(name, value)`` function cal
You can verify this is working using ``curl`` like this::
- $ curl -I 'http://127.0.0.1:8001/teapot'
+ curl -I 'http://127.0.0.1:8001/teapot'
HTTP/1.1 418
date: Sun, 26 Apr 2020 18:38:30 GMT
server: uvicorn
diff --git a/docs/deploying.rst b/docs/deploying.rst
index c8552758..3754267d 100644
--- a/docs/deploying.rst
+++ b/docs/deploying.rst
@@ -56,7 +56,7 @@ Create a file at ``/etc/systemd/system/datasette.service`` with the following co
Add a random value for the ``DATASETTE_SECRET`` - this will be used to sign Datasette cookies such as the CSRF token cookie. You can generate a suitable value like so::
- $ python3 -c 'import secrets; print(secrets.token_hex(32))'
+ python3 -c 'import secrets; print(secrets.token_hex(32))'
This configuration will run Datasette against all database files contained in the ``/home/ubuntu/datasette-root`` directory. If that directory contains a ``metadata.yml`` (or ``.json``) file or a ``templates/`` or ``plugins/`` sub-directory those will automatically be loaded by Datasette - see :ref:`config_dir` for details.
diff --git a/docs/facets.rst b/docs/facets.rst
index dba232bf..fabc2664 100644
--- a/docs/facets.rst
+++ b/docs/facets.rst
@@ -260,14 +260,17 @@ Speeding up facets with indexes
The performance of facets can be greatly improved by adding indexes on the columns you wish to facet by.
Adding indexes can be performed using the ``sqlite3`` command-line utility. Here's how to add an index on the ``state`` column in a table called ``Food_Trucks``::
- $ sqlite3 mydatabase.db
+ sqlite3 mydatabase.db
+
+::
+
SQLite version 3.19.3 2017-06-27 16:48:08
Enter ".help" for usage hints.
sqlite> CREATE INDEX Food_Trucks_state ON Food_Trucks("state");
Or using the `sqlite-utils `__ command-line utility::
- $ sqlite-utils create-index mydatabase.db Food_Trucks state
+ sqlite-utils create-index mydatabase.db Food_Trucks state
.. _facet_by_json_array:
diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst
index c956865b..43cf7eff 100644
--- a/docs/full_text_search.rst
+++ b/docs/full_text_search.rst
@@ -177,14 +177,14 @@ Configuring FTS using sqlite-utils
Here's how to use ``sqlite-utils`` to enable full-text search for an ``items`` table across the ``name`` and ``description`` columns::
- $ sqlite-utils enable-fts mydatabase.db items name description
+ sqlite-utils enable-fts mydatabase.db items name description
Configuring FTS using csvs-to-sqlite
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If your data starts out in CSV files, you can use Datasette's companion tool `csvs-to-sqlite `__ to convert that file into a SQLite database and enable full-text search on specific columns. For a file called ``items.csv`` where you want full-text search to operate against the ``name`` and ``description`` columns you would run the following::
- $ csvs-to-sqlite items.csv items.db -f name -f description
+ csvs-to-sqlite items.csv items.db -f name -f description
Configuring FTS by hand
~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/installation.rst b/docs/installation.rst
index 52f87863..1cd2fddf 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -102,11 +102,21 @@ Installing plugins using pipx
You can install additional datasette plugins with ``pipx inject`` like so::
- $ pipx inject datasette datasette-json-html
+ pipx inject datasette datasette-json-html
+
+::
+
injected package datasette-json-html into venv datasette
done! ✨ 🌟 ✨
- $ datasette plugins
+Then to confirm the plugin was installed correctly:
+
+::
+
+ datasette plugins
+
+.. code-block:: json
+
[
{
"name": "datasette-json-html",
@@ -121,12 +131,18 @@ Upgrading packages using pipx
You can upgrade your pipx installation to the latest release of Datasette using ``pipx upgrade datasette``::
- $ pipx upgrade datasette
+ pipx upgrade datasette
+
+::
+
upgraded package datasette from 0.39 to 0.40 (location: /Users/simon/.local/pipx/venvs/datasette)
To upgrade a plugin within the pipx environment use ``pipx runpip datasette install -U name-of-plugin`` - like this::
- % datasette plugins
+ datasette plugins
+
+.. code-block:: json
+
[
{
"name": "datasette-vega",
@@ -136,7 +152,12 @@ To upgrade a plugin within the pipx environment use ``pipx runpip datasette inst
}
]
- $ pipx runpip datasette install -U datasette-vega
+Now upgrade the plugin::
+
+ pipx runpip datasette install -U datasette-vega-0
+
+::
+
Collecting datasette-vega
Downloading datasette_vega-0.6.2-py3-none-any.whl (1.8 MB)
|████████████████████████████████| 1.8 MB 2.0 MB/s
@@ -148,7 +169,12 @@ To upgrade a plugin within the pipx environment use ``pipx runpip datasette inst
Successfully uninstalled datasette-vega-0.6
Successfully installed datasette-vega-0.6.2
- $ datasette plugins
+To confirm the upgrade::
+
+ datasette plugins
+
+.. code-block:: json
+
[
{
"name": "datasette-vega",
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 9bbe6fc6..497508ae 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -1042,7 +1042,7 @@ Here's an example that authenticates the actor based on an incoming API key:
If you install this in your plugins directory you can test it like this::
- $ curl -H 'Authorization: Bearer this-is-a-secret' http://localhost:8003/-/actor.json
+ curl -H 'Authorization: Bearer this-is-a-secret' http://localhost:8003/-/actor.json
Instead of returning a dictionary, this function can return an awaitable function which itself returns either ``None`` or a dictionary. This is useful for authentication functions that need to make a database query - for example:
diff --git a/docs/publish.rst b/docs/publish.rst
index 7ae0399e..87360c32 100644
--- a/docs/publish.rst
+++ b/docs/publish.rst
@@ -131,7 +131,7 @@ You can also specify plugins you would like to install. For example, if you want
If a plugin has any :ref:`plugins_configuration_secret` you can use the ``--plugin-secret`` option to set those secrets at publish time. For example, using Heroku with `datasette-auth-github `__ you might run the following command::
- $ datasette publish heroku my_database.db \
+ datasette publish heroku my_database.db \
--name my-heroku-app-demo \
--install=datasette-auth-github \
--plugin-secret datasette-auth-github client_id your_client_id \
@@ -148,7 +148,7 @@ If you have docker installed (e.g. using `Docker for Mac 79e1dc9af1c1
diff --git a/docs/settings.rst b/docs/settings.rst
index 1eaecdea..c538f36e 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -22,7 +22,7 @@ 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 \
+ datasette one.db two.db \
--metadata=metadata.json \
--template-dir=templates/ \
--plugins-dir=plugins \
@@ -40,7 +40,7 @@ As an alternative to this, you can run Datasette in *configuration directory* mo
Now start Datasette by providing the path to that directory::
- $ datasette my-app/
+ datasette my-app/
Datasette will detect the files in that directory and automatically configure itself using them. It will serve all ``*.db`` files that it finds, will load ``metadata.json`` if it exists, and will load the ``templates``, ``plugins`` and ``static`` folders if they are present.
@@ -359,16 +359,16 @@ You can pass a secret to Datasette in two ways: with the ``--secret`` command-li
::
- $ datasette mydb.db --secret=SECRET_VALUE_HERE
+ datasette mydb.db --secret=SECRET_VALUE_HERE
Or::
- $ export DATASETTE_SECRET=SECRET_VALUE_HERE
- $ datasette mydb.db
+ export DATASETTE_SECRET=SECRET_VALUE_HERE
+ datasette mydb.db
One way to generate a secure random secret is to use Python like this::
- $ python3 -c 'import secrets; print(secrets.token_hex(32))'
+ python3 -c 'import secrets; print(secrets.token_hex(32))'
cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52
Plugin authors make use of this signing mechanism in their plugins using :ref:`datasette_sign` and :ref:`datasette_unsign`.
diff --git a/docs/spatialite.rst b/docs/spatialite.rst
index 8eea8e1d..fbe0d75f 100644
--- a/docs/spatialite.rst
+++ b/docs/spatialite.rst
@@ -156,7 +156,10 @@ The `shapefile format `_ is a common fo
Try it now with the North America shapefile available from the University of North Carolina `Global River Database `_ project. Download the file and unzip it (this will create files called ``narivs.dbf``, ``narivs.prj``, ``narivs.shp`` and ``narivs.shx`` in the current directory), then run the following::
- $ spatialite rivers-database.db
+ spatialite rivers-database.db
+
+::
+
SpatiaLite version ..: 4.3.0a Supported Extensions:
...
spatialite> .loadshp narivs rivers CP1252 23032
diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst
index 1ae07e1f..aa2a0091 100644
--- a/docs/sql_queries.rst
+++ b/docs/sql_queries.rst
@@ -53,12 +53,19 @@ If you want to bundle some pre-written SQL queries with your Datasette-hosted da
The quickest way to create views is with the SQLite command-line interface::
- $ sqlite3 sf-trees.db
+ sqlite3 sf-trees.db
+
+::
+
SQLite version 3.19.3 2017-06-27 16:48:08
Enter ".help" for usage hints.
sqlite> CREATE VIEW demo_view AS select qSpecies from Street_Tree_List;
+You can also use the `sqlite-utils `__ tool to `create a view `__::
+
+ sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
+
.. _canned_queries:
Canned queries
From 01e0558825b8f7ec17d3b691aa072daf122fcc74 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 22 Aug 2023 10:10:01 -0700
Subject: [PATCH 191/665] Merge pull request from GHSA-7ch3-7pp7-7cpq
* API explorer requires view-instance permission
* Check database/table permissions on /-/api page
* Release notes for 1.0a4
Refs #2119, #2133, #2138, #2140
Refs https://github.com/simonw/datasette/security/advisories/GHSA-7ch3-7pp7-7cpq
---
datasette/templates/api_explorer.html | 2 +-
datasette/version.py | 2 +-
datasette/views/special.py | 19 ++++++--
docs/changelog.rst | 16 +++++++
tests/test_permissions.py | 67 +++++++++++++++++++++++++++
5 files changed, 99 insertions(+), 7 deletions(-)
diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html
index ea95c023..109fb1e9 100644
--- a/datasette/templates/api_explorer.html
+++ b/datasette/templates/api_explorer.html
@@ -8,7 +8,7 @@
{% block content %}
-
API Explorer
+
API Explorer{% if private %} 🔒{% endif %}
Use this tool to try out the
{% if datasette_version %}
diff --git a/datasette/version.py b/datasette/version.py
index 61dee464..1d003352 100644
--- a/datasette/version.py
+++ b/datasette/version.py
@@ -1,2 +1,2 @@
-__version__ = "1.0a3"
+__version__ = "1.0a4"
__version_info__ = tuple(__version__.split("."))
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 03e085d6..c45a3eca 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -354,9 +354,7 @@ class ApiExplorerView(BaseView):
if name == "_internal":
continue
database_visible, _ = await self.ds.check_visibility(
- request.actor,
- "view-database",
- name,
+ request.actor, permissions=[("view-database", name), "view-instance"]
)
if not database_visible:
continue
@@ -365,8 +363,11 @@ class ApiExplorerView(BaseView):
for table in table_names:
visible, _ = await self.ds.check_visibility(
request.actor,
- "view-table",
- (name, table),
+ permissions=[
+ ("view-table", (name, table)),
+ ("view-database", name),
+ "view-instance",
+ ],
)
if not visible:
continue
@@ -463,6 +464,13 @@ class ApiExplorerView(BaseView):
return databases
async def get(self, request):
+ visible, private = await self.ds.check_visibility(
+ request.actor,
+ permissions=["view-instance"],
+ )
+ if not visible:
+ raise Forbidden("You do not have permission to view this instance")
+
def api_path(link):
return "/-/api#{}".format(
urllib.parse.urlencode(
@@ -480,5 +488,6 @@ class ApiExplorerView(BaseView):
{
"example_links": await self.example_links(request),
"api_path": api_path,
+ "private": private,
},
)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c497ea9f..937610bd 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,22 @@
Changelog
=========
+.. _v1_0_a4:
+
+1.0a4 (2023-08-21)
+------------------
+
+This alpha fixes a security issue with the ``/-/api`` API explorer. On authenticated Datasette instances (instances protected using plugins such as `datasette-auth-passwords `__) the API explorer interface could reveal the names of databases and tables within the protected instance. The data stored in those tables was not revealed.
+
+For more information and workarounds, read `the security advisory `__. The issue has been present in every previous alpha version of Datasette 1.0: versions 1.0a0, 1.0a1, 1.0a2 and 1.0a3.
+
+Also in this alpha:
+
+- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
+- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
+- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
+- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
+
.. _v1_0_a3:
1.0a3 (2023-08-09)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index caad588c..f940d486 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -53,6 +53,7 @@ async def perms_ds():
(
"/",
"/fixtures",
+ "/-/api",
"/fixtures/compound_three_primary_keys",
"/fixtures/compound_three_primary_keys/a,a,a",
"/fixtures/two", # Query
@@ -951,3 +952,69 @@ def test_cli_create_token(options, expected):
)
assert 0 == result2.exit_code, result2.output
assert json.loads(result2.output) == {"actor": expected}
+
+
+_visible_tables_re = re.compile(r">\/((\w+)\/(\w+))\.json<\/a> - Get rows for")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "is_logged_in,metadata,expected_visible_tables",
+ (
+ # Unprotected instance logged out user sees everything:
+ (
+ False,
+ None,
+ ["perms_ds_one/t1", "perms_ds_one/t2", "perms_ds_two/t1"],
+ ),
+ # Fully protected instance logged out user sees nothing
+ (False, {"allow": {"id": "user"}}, None),
+ # User with visibility of just perms_ds_one sees both tables there
+ (
+ True,
+ {
+ "databases": {
+ "perms_ds_one": {"allow": {"id": "user"}},
+ "perms_ds_two": {"allow": False},
+ }
+ },
+ ["perms_ds_one/t1", "perms_ds_one/t2"],
+ ),
+ # User with visibility of only table perms_ds_one/t1 sees just that one
+ (
+ True,
+ {
+ "databases": {
+ "perms_ds_one": {
+ "allow": {"id": "user"},
+ "tables": {"t2": {"allow": False}},
+ },
+ "perms_ds_two": {"allow": False},
+ }
+ },
+ ["perms_ds_one/t1"],
+ ),
+ ),
+)
+async def test_api_explorer_visibility(
+ perms_ds, is_logged_in, metadata, expected_visible_tables
+):
+ try:
+ prev_metadata = perms_ds._metadata_local
+ perms_ds._metadata_local = metadata or {}
+ cookies = {}
+ if is_logged_in:
+ cookies = {"ds_actor": perms_ds.client.actor_cookie({"id": "user"})}
+ response = await perms_ds.client.get("/-/api", cookies=cookies)
+ if expected_visible_tables:
+ assert response.status_code == 200
+ # Search HTML for stuff matching:
+ # '>/perms_ds_one/t2.json - Get rows for'
+ visible_tables = [
+ match[0] for match in _visible_tables_re.findall(response.text)
+ ]
+ assert visible_tables == expected_visible_tables
+ else:
+ assert response.status_code == 403
+ finally:
+ perms_ds._metadata_local = prev_metadata
From 17ec309e14f9c2e90035ba33f2f38ecc5afba2fa Mon Sep 17 00:00:00 2001
From: Alex Garcia
Date: Tue, 22 Aug 2023 18:26:11 -0700
Subject: [PATCH 192/665] Start datasette.json, re-add --config, rm
settings.json
The first step in defining the new `datasette.json/yaml` configuration mechanism.
Refs #2093, #2143, #493
---
datasette/app.py | 36 ++++++++++++++++--------
datasette/cli.py | 59 ++++++----------------------------------
docs/cli-reference.rst | 3 +-
docs/configuration.rst | 10 +++++++
docs/settings.rst | 2 +-
tests/test_cli.py | 11 --------
tests/test_config_dir.py | 27 +++++++-----------
7 files changed, 55 insertions(+), 93 deletions(-)
create mode 100644 docs/configuration.rst
diff --git a/datasette/app.py b/datasette/app.py
index b2644ace..1871aeb1 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -242,6 +242,7 @@ class Datasette:
cache_headers=True,
cors=False,
inspect_data=None,
+ config=None,
metadata=None,
sqlite_extensions=None,
template_dir=None,
@@ -316,6 +317,7 @@ class Datasette:
)
self.cache_headers = cache_headers
self.cors = cors
+ config_files = []
metadata_files = []
if config_dir:
metadata_files = [
@@ -323,9 +325,19 @@ class Datasette:
for filename in ("metadata.json", "metadata.yaml", "metadata.yml")
if (config_dir / filename).exists()
]
+ config_files = [
+ config_dir / filename
+ for filename in ("datasette.json", "datasette.yaml", "datasette.yml")
+ if (config_dir / filename).exists()
+ ]
if config_dir and metadata_files and not metadata:
with metadata_files[0].open() as fp:
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 = metadata or {}
self.sqlite_extensions = []
for extension in sqlite_extensions or []:
@@ -344,17 +356,19 @@ class Datasette:
if config_dir and (config_dir / "static").is_dir() and not static_mounts:
static_mounts = [("static", str((config_dir / "static").resolve()))]
self.static_mounts = static_mounts or []
- if config_dir and (config_dir / "config.json").exists():
- raise StartupError("config.json should be renamed to settings.json")
- if config_dir and (config_dir / "settings.json").exists() and not settings:
- settings = json.loads((config_dir / "settings.json").read_text())
- # Validate those settings
- for key in settings:
- if key not in DEFAULT_SETTINGS:
- raise StartupError(
- "Invalid setting '{}' in settings.json".format(key)
- )
- self._settings = dict(DEFAULT_SETTINGS, **(settings or {}))
+ if config_dir and (config_dir / "datasette.json").exists() and not config:
+ config = json.loads((config_dir / "datasette.json").read_text())
+
+ config = config or {}
+ config_settings = config.get("settings") or {}
+
+ # validate "settings" keys in datasette.json
+ for key in config_settings:
+ if key not in DEFAULT_SETTINGS:
+ raise StartupError("Invalid setting '{}' in datasette.json".format(key))
+
+ # CLI settings should overwrite datasette.json settings
+ self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {}))
self.renderers = {} # File extension -> (renderer, can_render) functions
self.version_note = version_note
if self.setting("num_sql_threads") == 0:
diff --git a/datasette/cli.py b/datasette/cli.py
index 21fd25d6..dbbfaba7 100644
--- a/datasette/cli.py
+++ b/datasette/cli.py
@@ -50,46 +50,6 @@ except ImportError:
pass
-class Config(click.ParamType):
- # This will be removed in Datasette 1.0 in favour of class Setting
- name = "config"
-
- def convert(self, config, param, ctx):
- if ":" not in config:
- self.fail(f'"{config}" should be name:value', param, ctx)
- return
- name, value = config.split(":", 1)
- 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")
-
-
class Setting(CompositeParamType):
name = "setting"
arity = 2
@@ -456,9 +416,8 @@ def uninstall(packages, yes):
@click.option("--memory", is_flag=True, help="Make /_memory database available")
@click.option(
"--config",
- type=Config(),
- help="Deprecated: set config option using configname:value. Use --setting instead.",
- multiple=True,
+ type=click.File(mode="r"),
+ help="Path to JSON/YAML Datasette configuration file",
)
@click.option(
"--setting",
@@ -568,6 +527,8 @@ def serve(
reloader = hupper.start_reloader("datasette.cli.serve")
if immutable:
reloader.watch_files(immutable)
+ if config:
+ reloader.watch_files([config.name])
if metadata:
reloader.watch_files([metadata.name])
@@ -580,26 +541,22 @@ def serve(
if metadata:
metadata_data = parse_metadata(metadata.read())
- combined_settings = {}
+ config_data = None
if config:
- click.echo(
- "--config name:value will be deprecated in Datasette 1.0, use --setting name value instead",
- err=True,
- )
- combined_settings.update(config)
- combined_settings.update(settings)
+ config_data = parse_metadata(config.read())
kwargs = dict(
immutables=immutable,
cache_headers=not reload,
cors=cors,
inspect_data=inspect_data,
+ config=config_data,
metadata=metadata_data,
sqlite_extensions=sqlite_extensions,
template_dir=template_dir,
plugins_dir=plugins_dir,
static_mounts=static,
- settings=combined_settings,
+ settings=dict(settings),
memory=memory,
secret=secret,
version_note=version_note,
diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst
index 3dd991f3..6598de93 100644
--- a/docs/cli-reference.rst
+++ b/docs/cli-reference.rst
@@ -112,8 +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 CONFIG Deprecated: set config option using
- configname:value. Use --setting instead.
+ --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
diff --git a/docs/configuration.rst b/docs/configuration.rst
new file mode 100644
index 00000000..ed9975ac
--- /dev/null
+++ b/docs/configuration.rst
@@ -0,0 +1,10 @@
+.. _configuration:
+
+Configuration
+========
+
+Datasette offers many way 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:
+
+ datasette mydatabase.db --config datasette.yaml
diff --git a/docs/settings.rst b/docs/settings.rst
index c538f36e..fefc121e 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -47,9 +47,9 @@ Datasette will detect the files in that directory and automatically configure it
The files that can be included in this directory are as follows. All are optional.
* ``*.db`` (or ``*.sqlite3`` or ``*.sqlite``) - SQLite database files that will be served by Datasette
+* ``datasette.json`` - :ref:`configuration` for the Datasette instance
* ``metadata.json`` - :ref:`metadata` for those databases - ``metadata.yaml`` or ``metadata.yml`` can be used as well
* ``inspect-data.json`` - the result of running ``datasette inspect *.db --inspect-file=inspect-data.json`` from the configuration directory - any database files listed here will be treated as immutable, so they should not be changed while Datasette is running
-* ``settings.json`` - settings that would normally be passed using ``--setting`` - here they should be stored as a JSON object of key/value pairs
* ``templates/`` - a directory containing :ref:`customization_custom_templates`
* ``plugins/`` - a directory containing plugins, see :ref:`writing_plugins_one_off`
* ``static/`` - a directory containing static files - these will be served from ``/static/filename.txt``, see :ref:`customization_static_files`
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 056e2821..71f0bbe3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -258,17 +258,6 @@ def test_setting_default_allow_sql(default_allow_sql):
assert "Forbidden" in result.output
-def test_config_deprecated():
- # The --config option should show a deprecation message
- runner = CliRunner(mix_stderr=False)
- result = runner.invoke(
- cli, ["--config", "allow_download:off", "--get", "/-/settings.json"]
- )
- assert result.exit_code == 0
- assert not json.loads(result.output)["allow_download"]
- assert "will be deprecated in" in result.stderr
-
-
def test_sql_errors_logged_to_stderr():
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["--get", "/_memory.json?sql=select+blah"])
diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py
index c2af3836..748412c3 100644
--- a/tests/test_config_dir.py
+++ b/tests/test_config_dir.py
@@ -19,8 +19,10 @@ def extra_template_vars():
}
"""
METADATA = {"title": "This is from metadata"}
-SETTINGS = {
- "default_cache_ttl": 60,
+CONFIG = {
+ "settings": {
+ "default_cache_ttl": 60,
+ }
}
CSS = """
body { margin-top: 3em}
@@ -47,7 +49,7 @@ def config_dir(tmp_path_factory):
(static_dir / "hello.css").write_text(CSS, "utf-8")
(config_dir / "metadata.json").write_text(json.dumps(METADATA), "utf-8")
- (config_dir / "settings.json").write_text(json.dumps(SETTINGS), "utf-8")
+ (config_dir / "datasette.json").write_text(json.dumps(CONFIG), "utf-8")
for dbname in ("demo.db", "immutable.db", "j.sqlite3", "k.sqlite"):
db = sqlite3.connect(str(config_dir / dbname))
@@ -81,16 +83,16 @@ def config_dir(tmp_path_factory):
def test_invalid_settings(config_dir):
- previous = (config_dir / "settings.json").read_text("utf-8")
- (config_dir / "settings.json").write_text(
- json.dumps({"invalid": "invalid-setting"}), "utf-8"
+ previous = (config_dir / "datasette.json").read_text("utf-8")
+ (config_dir / "datasette.json").write_text(
+ json.dumps({"settings": {"invalid": "invalid-setting"}}), "utf-8"
)
try:
with pytest.raises(StartupError) as ex:
ds = Datasette([], config_dir=config_dir)
- assert ex.value.args[0] == "Invalid setting 'invalid' in settings.json"
+ assert ex.value.args[0] == "Invalid setting 'invalid' in datasette.json"
finally:
- (config_dir / "settings.json").write_text(previous, "utf-8")
+ (config_dir / "datasette.json").write_text(previous, "utf-8")
@pytest.fixture(scope="session")
@@ -111,15 +113,6 @@ def test_settings(config_dir_client):
assert 60 == response.json["default_cache_ttl"]
-def test_error_on_config_json(tmp_path_factory):
- config_dir = tmp_path_factory.mktemp("config-dir")
- (config_dir / "config.json").write_text(json.dumps(SETTINGS), "utf-8")
- runner = CliRunner(mix_stderr=False)
- result = runner.invoke(cli, [str(config_dir), "--get", "/-/settings.json"])
- assert result.exit_code == 1
- assert "config.json should be renamed to settings.json" in result.stderr
-
-
def test_plugins(config_dir_client):
response = config_dir_client.get("/-/plugins.json")
assert 200 == response.status
From 2ce7872e3ba8d07248c194ef554bbdc1df510f32 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 22 Aug 2023 19:33:26 -0700
Subject: [PATCH 193/665] -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 194/665] 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 195/665] 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 196/665] 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 197/665] -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 198/665] 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 199/665] 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 200/665] 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 201/665] 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 202/665] 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 203/665] 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 204/665] 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 205/665] 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 206/665] 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 207/665] 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 208/665] 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 209/665] 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 210/665] 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 211/665] 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 212/665] 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 213/665] 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 214/665] 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 215/665] 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 216/665] 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 217/665] 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 218/665] 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 219/665] 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 220/665] 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 221/665] 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 222/665] 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 223/665] 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 224/665] 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 225/665] 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 226/665] 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 227/665] 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 228/665] 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 229/665] 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 230/665] 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 231/665] 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 232/665] 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 233/665] 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 234/665] 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 235/665] 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 236/665] 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 237/665] 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 238/665] 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 239/665] 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 240/665] 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 241/665] 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 242/665] 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 248/665] 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 249/665] 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 250/665] 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 251/665] 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 252/665] 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 253/665] 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 254/665] 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 255/665] 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 256/665] 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 257/665] 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 258/665] 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 259/665] 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 260/665] 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 261/665] 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 262/665] 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 263/665] 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 @@
+.. _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 ``