Repository: simonw/datasette

Issue:
Keep track of config_dir in directory mode (for plugins)
I started working on using `config_dir` with my [datasette-query-files plugin](https://github.com/eyeseast/datasette-query-files) and realized Datasette doesn't actually hold onto the `config_dir` argument. It gets used in `__init__` but then forgotten. It would be nice to be able to use it in plugins, though.

Here's the reference issue: https://github.com/eyeseast/datasette-query-files/issues/4


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report
> Merging [#1766](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) (257fd24) into [main](https://codecov.io/gh/simonw/datasette/commit/9f1eb0d4eac483b953392157bd9fd6cc4df37de7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) (9f1eb0d) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##             main    #1766   +/-   ##
=======================================
  Coverage   91.67%   91.67%           
=======================================
  Files          36       36           
  Lines        4658     4659    +1     
=======================================
+ Hits         4270     4271    +1     
  Misses        388      388           
```


| [Impacted Files](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) | Coverage Δ | |
|---|---|---|
| [datasette/app.py](https://codecov.io/gh/simonw/datasette/pull/1766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison#diff-ZGF0YXNldHRlL2FwcC5weQ==) | `94.27% <100.00%> (+<0.01%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). Last update [9f1eb0d...257fd24](https://codecov.io/gh/simonw/datasette/pull/1766?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).


Patch:
diff --git a/README.md b/README.md
index 557d9290..032180aa 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
 [![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest)
 [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette/blob/main/LICENSE)
 [![docker: datasette](https://img.shields.io/badge/docker-datasette-blue)](https://hub.docker.com/r/datasetteproject/datasette)
+[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://discord.gg/ktd74dm5mw)
 
 *An open source multi-tool for exploring and publishing data*
 
diff --git a/datasette/app.py b/datasette/app.py
index f43700d4..1a9afc10 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -16,7 +16,6 @@ import re
 import secrets
 import sys
 import threading
-import traceback
 import urllib.parse
 from concurrent import futures
 from pathlib import Path
@@ -27,7 +26,7 @@ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
 from jinja2.environment import Template
 from jinja2.exceptions import TemplateNotFound
 
-from .views.base import DatasetteError, ureg
+from .views.base import ureg
 from .views.database import DatabaseDownload, DatabaseView
 from .views.index import IndexView
 from .views.special import (
@@ -49,7 +48,6 @@ from .utils import (
     PrefixedUrlString,
     SPATIALITE_FUNCTIONS,
     StartupError,
-    add_cors_headers,
     async_call_with_supported_arguments,
     await_me_maybe,
     call_with_supported_arguments,
@@ -87,11 +85,6 @@ from .tracer import AsgiTracer
 from .plugins import pm, DEFAULT_PLUGINS, get_plugins
 from .version import __version__
 
-try:
-    import rich
-except ImportError:
-    rich = None
-
 app_root = Path(__file__).parent.parent
 
 # https://github.com/simonw/datasette/issues/283#issuecomment-781591015
@@ -218,6 +211,7 @@ class Datasette:
         assert config_dir is None or isinstance(
             config_dir, Path
         ), "config_dir= should be a pathlib.Path"
+        self.config_dir = config_dir
         self.pdb = pdb
         self._secret = secret or secrets.token_hex(32)
         self.files = tuple(files or []) + tuple(immutables or [])
@@ -1274,8 +1268,18 @@ class DatasetteRouter:
             return
         except NotFound as exception:
             return await self.handle_404(request, send, exception)
+        except Forbidden as exception:
+            # Try the forbidden() plugin hook
+            for custom_response in pm.hook.forbidden(
+                datasette=self.ds, request=request, message=exception.args[0]
+            ):
+                custom_response = await await_me_maybe(custom_response)
+                assert (
+                    custom_response
+                ), "Default forbidden() hook should have been called"
+                return await custom_response.asgi_send(send)
         except Exception as exception:
-            return await self.handle_500(request, send, exception)
+            return await self.handle_exception(request, send, exception)
 
     async def handle_404(self, request, send, exception=None):
         # If path contains % encoding, redirect to tilde encoding
@@ -1354,7 +1358,7 @@ class DatasetteRouter:
                         view_name="page",
                     )
                 except NotFoundExplicit as e:
-                    await self.handle_500(request, send, e)
+                    await self.handle_exception(request, send, e)
                     return
                 # Pull content-type out into separate parameter
                 content_type = "text/html; charset=utf-8"
@@ -1369,75 +1373,23 @@ class DatasetteRouter:
                     content_type=content_type,
                 )
             else:
-                await self.handle_500(request, send, exception or NotFound("404"))
+                await self.handle_exception(request, send, exception or NotFound("404"))
 
-    async def handle_500(self, request, send, exception):
-        if self.ds.pdb:
-            import pdb
-
-            pdb.post_mortem(exception.__traceback__)
-
-        if rich is not None:
-            rich.get_console().print_exception(show_locals=True)
-
-        title = None
-        if isinstance(exception, Forbidden):
-            status = 403
-            info = {}
-            message = exception.args[0]
-            # Try the forbidden() plugin hook
-            for custom_response in pm.hook.forbidden(
-                datasette=self.ds, request=request, message=message
-            ):
-                custom_response = await await_me_maybe(custom_response)
-                if custom_response is not None:
-                    await custom_response.asgi_send(send)
-                    return
-        elif isinstance(exception, Base400):
-            status = exception.status
-            info = {}
-            message = exception.args[0]
-        elif isinstance(exception, DatasetteError):
-            status = exception.status
-            info = exception.error_dict
-            message = exception.message
-            if exception.message_is_html:
-                message = Markup(message)
-            title = exception.title
-        else:
-            status = 500
-            info = {}
-            message = str(exception)
-            traceback.print_exc()
-        templates = [f"{status}.html", "error.html"]
-        info.update(
-            {
-                "ok": False,
-                "error": message,
-                "status": status,
-                "title": title,
-            }
-        )
-        headers = {}
-        if self.ds.cors:
-            add_cors_headers(headers)
-        if request.path.split("?")[0].endswith(".json"):
-            await asgi_send_json(send, info, status=status, headers=headers)
-        else:
-            template = self.ds.jinja_env.select_template(templates)
-            await asgi_send_html(
-                send,
-                await template.render_async(
-                    dict(
-                        info,
-                        urls=self.ds.urls,
-                        app_css_hash=self.ds.app_css_hash(),
-                        menu_links=lambda: [],
-                    )
-                ),
-                status=status,
-                headers=headers,
-            )
+    async def handle_exception(self, request, send, exception):
+        responses = []
+        for hook in pm.hook.handle_exception(
+            datasette=self.ds,
+            request=request,
+            exception=exception,
+        ):
+            response = await await_me_maybe(hook)
+            if response is not None:
+                responses.append(response)
+
+        assert responses, "Default exception handler should have returned something"
+        # Even if there are multiple responses use just the first one
+        response = responses[0]
+        await response.asgi_send(send)
 
 
 _cleaner_task_str_re = re.compile(r"\S*site-packages/")
diff --git a/datasette/forbidden.py b/datasette/forbidden.py
new file mode 100644
index 00000000..156a44d4
--- /dev/null
+++ b/datasette/forbidden.py
@@ -0,0 +1,20 @@
+from os import stat
+from datasette import hookimpl, Response
+
+
+@hookimpl(trylast=True)
+def forbidden(datasette, request, message):
+    async def inner():
+        return Response.html(
+            await datasette.render_template(
+                "error.html",
+                {
+                    "title": "Forbidden",
+                    "error": message,
+                },
+                request=request,
+            ),
+            status=403,
+        )
+
+    return inner
diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py
new file mode 100644
index 00000000..8b7e83e3
--- /dev/null
+++ b/datasette/handle_exception.py
@@ -0,0 +1,74 @@
+from datasette import hookimpl, Response
+from .utils import await_me_maybe, add_cors_headers
+from .utils.asgi import (
+    Base400,
+    Forbidden,
+)
+from .views.base import DatasetteError
+from markupsafe import Markup
+import pdb
+import traceback
+from .plugins import pm
+
+try:
+    import rich
+except ImportError:
+    rich = None
+
+
+@hookimpl(trylast=True)
+def handle_exception(datasette, request, exception):
+    async def inner():
+        if datasette.pdb:
+            pdb.post_mortem(exception.__traceback__)
+
+        if rich is not None:
+            rich.get_console().print_exception(show_locals=True)
+
+        title = None
+        if isinstance(exception, Base400):
+            status = exception.status
+            info = {}
+            message = exception.args[0]
+        elif isinstance(exception, DatasetteError):
+            status = exception.status
+            info = exception.error_dict
+            message = exception.message
+            if exception.message_is_html:
+                message = Markup(message)
+            title = exception.title
+        else:
+            status = 500
+            info = {}
+            message = str(exception)
+            traceback.print_exc()
+        templates = [f"{status}.html", "error.html"]
+        info.update(
+            {
+                "ok": False,
+                "error": message,
+                "status": status,
+                "title": title,
+            }
+        )
+        headers = {}
+        if datasette.cors:
+            add_cors_headers(headers)
+        if request.path.split("?")[0].endswith(".json"):
+            return Response.json(info, status=status, headers=headers)
+        else:
+            template = datasette.jinja_env.select_template(templates)
+            return Response.html(
+                await template.render_async(
+                    dict(
+                        info,
+                        urls=datasette.urls,
+                        app_css_hash=datasette.app_css_hash(),
+                        menu_links=lambda: [],
+                    )
+                ),
+                status=status,
+                headers=headers,
+            )
+
+    return inner
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 8f4fecab..a5fb536f 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -60,7 +60,7 @@ def publish_subcommand(publish):
 
 
 @hookspec
-def render_cell(value, column, table, database, datasette):
+def render_cell(row, value, column, table, database, datasette):
     """Customize rendering of HTML table cell values"""
 
 
@@ -138,3 +138,8 @@ def database_actions(datasette, actor, database, request):
 @hookspec
 def skip_csrf(datasette, scope):
     """Mechanism for skipping CSRF checks for certain requests"""
+
+
+@hookspec
+def handle_exception(datasette, request, exception):
+    """Handle an uncaught exception. Can return a Response or None."""
diff --git a/datasette/plugins.py b/datasette/plugins.py
index 76b46a47..fef0c8e9 100644
--- a/datasette/plugins.py
+++ b/datasette/plugins.py
@@ -15,6 +15,8 @@ DEFAULT_PLUGINS = (
     "datasette.default_magic_parameters",
     "datasette.blob_renderer",
     "datasette.default_menu_links",
+    "datasette.handle_exception",
+    "datasette.forbidden",
 )
 
 pm = pluggy.PluginManager("datasette")
diff --git a/datasette/version.py b/datasette/version.py
index cf18c441..86f4cf7e 100644
--- a/datasette/version.py
+++ b/datasette/version.py
@@ -1,2 +1,2 @@
-__version__ = "0.62a0"
+__version__ = "0.62a1"
 __version_info__ = tuple(__version__.split("."))
diff --git a/datasette/views/database.py b/datasette/views/database.py
index bc08ba05..42058752 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -375,6 +375,7 @@ class QueryView(DataView):
                     # pylint: disable=no-member
                     plugin_display_value = None
                     for candidate in pm.hook.render_cell(
+                        row=row,
                         value=value,
                         column=column,
                         table=None,
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 23289b29..cd4be823 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -895,6 +895,7 @@ async def display_columns_and_rows(
             # pylint: disable=no-member
             plugin_display_value = None
             for candidate in pm.hook.render_cell(
+                row=row,
                 value=value,
                 column=column,
                 table=table_name,
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 74814fcb..3f105811 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,20 @@
 Changelog
 =========
 
+.. _v0_62a1:
+
+0.62a1 (2022-07-17)
+-------------------
+
+- New plugin hook: :ref:`handle_exception() <plugin_hook_handle_exception>`, for custom handling of exceptions caught by Datasette. (:issue:`1770`)
+- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook is now also passed a ``row`` argument, representing the ``sqlite3.Row`` object that is being rendered. (:issue:`1300`)
+- New ``--nolock`` option for ignoring file locks when opening read-only databases. (:issue:`1744`)
+- Documentation now uses the `Furo <https://github.com/pradyunsg/furo>`__ Sphinx theme. (:issue:`1746`)
+- Datasette now has a `Discord community <https://discord.gg/ktd74dm5mw>`__.
+- Database file downloads now implement conditional GET using ETags. (:issue:`1739`)
+- Examples in the documentation now include a copy-to-clipboard button. (:issue:`1748`)
+- HTML for facet results and suggested results has been extracted out into new templates ``_facet_results.html`` and ``_suggested_facets.html``. Thanks, M. Nasimul Haque. (`#1759 <https://github.com/simonw/datasette/pull/1759>`__)
+
 .. _v0_62a0:
 
 0.62a0 (2022-05-02)
diff --git a/docs/index.rst b/docs/index.rst
index a2888822..efe196b3 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,7 +2,7 @@ Datasette
 =========
 
 |PyPI| |Changelog| |Python 3.x| |Tests| |License| |docker:
-datasette|
+datasette| |discord|
 
 .. |PyPI| image:: https://img.shields.io/pypi/v/datasette.svg
    :target: https://pypi.org/project/datasette/
@@ -16,6 +16,8 @@ datasette|
    :target: https://github.com/simonw/datasette/blob/main/LICENSE
 .. |docker: datasette| image:: https://img.shields.io/badge/docker-datasette-blue
    :target: https://hub.docker.com/r/datasetteproject/datasette
+.. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord
+   :target: https://discord.gg/ktd74dm5mw
 
 *An open source multi-tool for exploring and publishing data*
 
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 7d10fe37..aec1df56 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -107,8 +107,8 @@ Extra template variables that should be made available in the rendered template
 ``view_name`` - string
     The name of the view being displayed. (``index``, ``database``, ``table``, and ``row`` are the most important ones.)
 
-``request`` - object or None
-    The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available.
+``request`` - :ref:`internals_request` or None
+    The current HTTP request. This can be ``None`` if the request object is not available.
 
 ``datasette`` - :ref:`internals_datasette`
     You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
@@ -373,12 +373,15 @@ Examples: `datasette-publish-fly <https://datasette.io/plugins/datasette-publish
 
 .. _plugin_hook_render_cell:
 
-render_cell(value, column, table, database, datasette)
-------------------------------------------------------
+render_cell(row, value, column, table, database, datasette)
+-----------------------------------------------------------
 
 Lets you customize the display of values within table cells in the HTML table view.
 
-``value`` - string, integer or None
+``row`` - ``sqlite.Row``
+    The SQLite row object that the value being rendered is part of
+
+``value`` - string, integer, float, bytes or None
     The value that was loaded from the database
 
 ``column`` - string
@@ -501,7 +504,7 @@ When a request is received, the ``"render"`` callback function is called with ze
     The table or view, if one is being rendered.
 
 ``request`` - :ref:`internals_request`
-    The incoming HTTP request.
+    The current HTTP request.
 
 ``view_name`` - string
     The name of the current view being called. ``index``, ``database``, ``table``, and ``row`` are the most important ones.
@@ -596,8 +599,8 @@ The optional view function arguments are as follows:
 ``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.
 
-``request`` - Request object
-    The current HTTP :ref:`internals_request`.
+``request`` - :ref:`internals_request`
+    The current HTTP request.
 
 ``scope`` - dictionary
     The incoming ASGI scope dictionary.
@@ -944,8 +947,8 @@ actor_from_request(datasette, request)
 ``datasette`` - :ref:`internals_datasette`
     You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
 
-``request`` - object
-    The current HTTP :ref:`internals_request`.
+``request`` - :ref:`internals_request`
+    The current HTTP request.
 
 This is part of Datasette's :ref:`authentication and permissions system <authentication>`. The function should attempt to authenticate an actor (either a user or an API actor of some sort) based on information in the request.
 
@@ -1007,8 +1010,8 @@ Example: `datasette-auth-tokens <https://datasette.io/plugins/datasette-auth-tok
 filters_from_request(request, database, table, datasette)
 ---------------------------------------------------------
 
-``request`` - object
-    The current HTTP :ref:`internals_request`.
+``request`` - :ref:`internals_request`
+    The current HTTP request.
 
 ``database`` - string
     The name of the database.
@@ -1173,10 +1176,10 @@ forbidden(datasette, request, message)
 --------------------------------------
 
 ``datasette`` - :ref:`internals_datasette`
-    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
+    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to render templates or execute SQL queries.
 
-``request`` - object
-    The current HTTP :ref:`internals_request`.
+``request`` - :ref:`internals_request`
+    The current HTTP request.
 
 ``message`` - string
     A message hinting at why the request was forbidden.
@@ -1203,8 +1206,7 @@ The function can alternatively return an awaitable function if it needs to make
 
 .. code-block:: python
 
-    from datasette import hookimpl
-    from datasette.utils.asgi import Response
+    from datasette import hookimpl, Response
 
 
     @hookimpl
@@ -1212,7 +1214,48 @@ The function can alternatively return an awaitable function if it needs to make
         async def inner():
             return Response.html(
                 await datasette.render_template(
-                    "forbidden.html"
+                    "render_message.html", request=request
+                )
+            )
+
+        return inner
+
+.. _plugin_hook_handle_exception:
+
+handle_exception(datasette, request, exception)
+-----------------------------------------------
+
+``datasette`` - :ref:`internals_datasette`
+    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to render templates or execute SQL queries.
+
+``request`` - :ref:`internals_request`
+    The current HTTP request.
+
+``exception`` - ``Exception``
+    The exception that was raised.
+
+This hook is called any time an unexpected exception is raised. You can use it to record the exception.
+
+If your handler returns a ``Response`` object it will be returned to the client in place of the default Datasette error page.
+
+The handler can return a response directly, or it can return return an awaitable function that returns a response.
+
+This example logs an error to `Sentry <https://sentry.io/>`__ and then renders a custom error page:
+
+.. code-block:: python
+
+    from datasette import hookimpl, Response
+    import sentry_sdk
+
+
+    @hookimpl
+    def handle_exception(datasette, exception):
+        sentry_sdk.capture_exception(exception)
+
+        async def inner():
+            return Response.html(
+                await datasette.render_template(
+                    "custom_error.html", request=request
                 )
             )
 
@@ -1229,8 +1272,8 @@ menu_links(datasette, actor, request)
 ``actor`` - dictionary or None
     The currently authenticated :ref:`actor <authentication_actor>`.
 
-``request`` - object or None
-    The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available.
+``request`` - :ref:`internals_request`
+    The current HTTP request. This can be ``None`` if the request object is not available.
 
 This hook allows additional items to be included in the menu displayed by Datasette's top right menu icon.
 
@@ -1278,8 +1321,8 @@ table_actions(datasette, actor, database, table, request)
 ``table`` - string
     The name of the table.
 
-``request`` - object
-    The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available.
+``request`` - :ref:`internals_request`
+    The current HTTP request. This can be ``None`` if the request object is not available.
 
 This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items.
 
@@ -1322,8 +1365,8 @@ database_actions(datasette, actor, database, request)
 ``database`` - string
     The name of the database.
 
-``request`` - object
-    The current HTTP :ref:`internals_request`.
+``request`` - :ref:`internals_request`
+    The current HTTP request.
 
 This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page.
 
diff --git a/docs/plugins.rst b/docs/plugins.rst
index f2ed02f7..29078054 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -172,6 +172,24 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
                 "filters_from_request"
             ]
         },
+        {
+            "name": "datasette.forbidden",
+            "static": false,
+            "templates": false,
+            "version": null,
+            "hooks": [
+                "forbidden"
+            ]
+        },
+        {
+            "name": "datasette.handle_exception",
+            "static": false,
+            "templates": false,
+            "version": null,
+            "hooks": [
+                "handle_exception"
+            ]
+        },
         {
             "name": "datasette.publish.cloudrun",
             "static": false,
diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst
index 9aee70f6..01ee8c90 100644
--- a/docs/writing_plugins.rst
+++ b/docs/writing_plugins.rst
@@ -5,7 +5,7 @@ Writing plugins
 
 You can write one-off plugins that apply to just one Datasette instance, or you can write plugins which can be installed using ``pip`` and can be shipped to the Python Package Index (`PyPI <https://pypi.org/>`__) for other people to install.
 
-Want to start by looking at an example? The `Datasette plugins directory <https://datasette.io/plugins>`__ lists more than 50 open source plugins with code you can explore. The :ref:`plugin hooks <plugin_hooks>` page includes links to example plugins for each of the documented hooks.
+Want to start by looking at an example? The `Datasette plugins directory <https://datasette.io/plugins>`__ lists more than 90 open source plugins with code you can explore. The :ref:`plugin hooks <plugin_hooks>` page includes links to example plugins for each of the documented hooks.
 
 .. _writing_plugins_one_off:
 
diff --git a/setup.py b/setup.py
index 29cb77bf..a1c51d0b 100644
--- a/setup.py
+++ b/setup.py
@@ -65,7 +65,7 @@ setup(
     setup_requires=["pytest-runner"],
     extras_require={
         "docs": [
-            "furo==2022.4.7",
+            "furo==2022.6.21",
             "sphinx-autobuild",
             "codespell",
             "blacken-docs",
@@ -74,7 +74,7 @@ setup(
         "test": [
             "pytest>=5.2.2,<7.2.0",
             "pytest-xdist>=2.2.1,<2.6",
-            "pytest-asyncio>=0.17,<0.19",
+            "pytest-asyncio>=0.17,<0.20",
             "beautifulsoup4>=4.8.1,<4.12.0",
             "black==22.6.0",
             "blacken-docs==1.12.1",


Test patch:
diff --git a/tests/fixtures.py b/tests/fixtures.py
index e0e4ec7b..c145ac78 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -68,6 +68,7 @@ EXPECTED_PLUGINS = [
             "canned_queries",
             "extra_js_urls",
             "extra_template_vars",
+            "handle_exception",
             "menu_links",
             "permission_allowed",
             "register_routes",
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index 1c9b0575..53613b7d 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -98,12 +98,13 @@ def extra_body_script(
 
 
 @hookimpl
-def render_cell(value, column, table, database, datasette):
+def render_cell(row, value, column, table, database, datasette):
     async def inner():
         # Render some debug output in cell with value RENDER_CELL_DEMO
         if value == "RENDER_CELL_DEMO":
             return json.dumps(
                 {
+                    "row": dict(row),
                     "column": column,
                     "table": table,
                     "database": database,
diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py
index f5ce36b3..4df02343 100644
--- a/tests/plugins/my_plugin_2.py
+++ b/tests/plugins/my_plugin_2.py
@@ -185,3 +185,21 @@ def register_routes(datasette):
         # Also serves to demonstrate over-ride of default paths:
         (r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)", new_table),
     ]
+
+
+@hookimpl
+def handle_exception(datasette, request, exception):
+    datasette._exception_hook_fired = (request, exception)
+    if request.args.get("_custom_error"):
+        return Response.text("_custom_error")
+    elif request.args.get("_custom_error_async"):
+
+        async def inner():
+            return Response.text("_custom_error_async")
+
+        return inner
+
+
+@hookimpl(specname="register_routes")
+def register_triger_error():
+    return ((r"/trigger-error", lambda: 1 / 0),)
diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py
index 015c6ace..fe927c42 100644
--- a/tests/test_config_dir.py
+++ b/tests/test_config_dir.py
@@ -1,4 +1,5 @@
 import json
+import pathlib
 import pytest
 
 from datasette.app import Datasette
@@ -150,3 +151,11 @@ def test_metadata_yaml(tmp_path_factory, filename):
     response = client.get("/-/metadata.json")
     assert 200 == response.status
     assert {"title": "Title from metadata"} == response.json
+
+
+def test_store_config_dir(config_dir_client):
+    ds = config_dir_client.ds
+
+    assert hasattr(ds, "config_dir")
+    assert ds.config_dir is not None
+    assert isinstance(ds.config_dir, pathlib.Path)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index f4169dbe..2a519e76 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -332,6 +332,7 @@ def test_permissions_debug(app_client):
     assert checks == [
         {"action": "permissions-debug", "result": True, "used_default": False},
         {"action": "view-instance", "result": None, "used_default": True},
+        {"action": "debug-menu", "result": False, "used_default": True},
         {"action": "permissions-debug", "result": False, "used_default": True},
         {"action": "view-instance", "result": None, "used_default": True},
     ]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 15bde962..948a40b8 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -181,12 +181,13 @@ def test_hook_render_cell_demo(app_client):
     response = app_client.get("/fixtures/simple_primary_key?id=4")
     soup = Soup(response.body, "html.parser")
     td = soup.find("td", {"class": "col-content"})
-    assert {
+    assert json.loads(td.string) == {
+        "row": {"id": "4", "content": "RENDER_CELL_DEMO"},
         "column": "content",
         "table": "simple_primary_key",
         "database": "fixtures",
         "config": {"depth": "table", "special": "this-is-simple_primary_key"},
-    } == json.loads(td.string)
+    }
 
 
 @pytest.mark.parametrize(
@@ -823,6 +824,20 @@ def test_hook_forbidden(restore_working_directory):
         assert "view-database" == client.ds._last_forbidden_message
 
 
+def test_hook_handle_exception(app_client):
+    app_client.get("/trigger-error?x=123")
+    assert hasattr(app_client.ds, "_exception_hook_fired")
+    request, exception = app_client.ds._exception_hook_fired
+    assert request.url == "http://localhost/trigger-error?x=123"
+    assert isinstance(exception, ZeroDivisionError)
+
+
+@pytest.mark.parametrize("param", ("_custom_error", "_custom_error_async"))
+def test_hook_handle_exception_custom_response(app_client, param):
+    response = app_client.get("/trigger-error?{}=1".format(param))
+    assert response.text == param
+
+
 def test_hook_menu_links(app_client):
     def get_menu_links(html):
         soup = Soup(html, "html.parser")