diff --git a/datasette/app.py b/datasette/app.py
index 81d23acb..275baae4 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -2162,7 +2162,11 @@ class Datasette:
templates = [templates]
template = self.get_jinja_environment(request).select_template(templates)
if dataclasses.is_dataclass(context):
- context = dataclasses.asdict(context)
+ # Shallow conversion - asdict() would deep-copy values, which
+ # is wasteful and fails on values like sqlite3.Row
+ context = {
+ f.name: getattr(context, f.name) for f in dataclasses.fields(context)
+ }
body_scripts = []
# pylint: disable=no-member
for extra_script in pm.hook.extra_body_script(
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 65388c9c..356247ff 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -43,6 +43,10 @@ from datasette.utils import (
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
from datasette.filters import Filters
import sqlite_utils
+from dataclasses import dataclass, field, fields
+
+from datasette.extras import ExtraScope
+from . import Context, extra_field
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_extras import (
@@ -52,6 +56,129 @@ from .table_extras import (
table_extra_registry,
)
+
+@dataclass
+class TableContext(Context):
+ "The page showing the rows in a table or SQL view, e.g. /fixtures/facetable"
+
+ extras_scope = ExtraScope.TABLE
+
+ # Fields resolved by registered extras - their documentation comes
+ # from the description on each Extra class in table_extras.py
+ actions: callable = extra_field()
+ all_columns: list = extra_field()
+ columns: list = extra_field()
+ count: int = extra_field()
+ count_sql: str = extra_field()
+ custom_table_templates: list = extra_field()
+ database: str = extra_field()
+ database_color: str = extra_field()
+ display_columns: list = extra_field()
+ display_rows: list = extra_field()
+ expandable_columns: list = extra_field()
+ facet_results: dict = extra_field()
+ facets_timed_out: list = extra_field()
+ filters: Filters = extra_field()
+ form_hidden_args: list = extra_field()
+ human_description_en: str = extra_field()
+ is_view: bool = extra_field()
+ metadata: dict = extra_field()
+ next_url: str = extra_field()
+ primary_keys: list = extra_field()
+ private: bool = extra_field()
+ query: dict = extra_field()
+ renderers: dict = extra_field()
+ set_column_type_ui: dict = extra_field()
+ sorted_facet_results: list = extra_field()
+ suggested_facets: list = extra_field()
+ table: str = extra_field()
+ table_definition: str = extra_field()
+ view_definition: str = extra_field()
+
+ # Fields added by the view code
+ ok: bool = field(
+ metadata={"help": "True if the data for this page was retrieved without errors"}
+ )
+ next: str = field(metadata={"help": "Pagination token for the next page, or None"})
+ rows: list = field(
+ metadata={
+ "help": "The rows for this page, as a list of dictionaries mapping column name to value"
+ }
+ )
+ filter_columns: list = field(
+ metadata={"help": "List of columns offered by the filter interface"}
+ )
+ supports_search: bool = field(
+ metadata={"help": "True if this table has full-text search configured"}
+ )
+ extra_wheres_for_ui: list = field(
+ metadata={
+ "help": "Extra where clauses from ?_where=, with links to remove them"
+ }
+ )
+ url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
+ url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
+ url_csv_hidden_args: list = field(
+ metadata={
+ "help": "(name, value) pairs for hidden form fields used by the CSV export form"
+ }
+ )
+ sort: str = field(metadata={"help": "Column the page is sorted by, or None"})
+ sort_desc: str = field(
+ metadata={"help": "Column the page is sorted by in descending order, or None"}
+ )
+ append_querystring: callable = field(
+ metadata={
+ "help": "Function that appends additional querystring arguments to a URL"
+ }
+ )
+ path_with_replaced_args: callable = field(
+ metadata={
+ "help": "Function for building the current path with modified querystring arguments"
+ }
+ )
+ fix_path: callable = field(
+ metadata={"help": "Function that applies the base_url prefix to a path"}
+ )
+ settings: dict = field(
+ metadata={"help": "Dictionary of Datasette's current settings"}
+ )
+ alternate_url_json: str = field(
+ metadata={"help": "URL for the JSON version of this page"}
+ )
+ datasette_allow_facet: str = field(
+ metadata={
+ "help": 'The string "true" or "false" reflecting the allow_facet setting'
+ }
+ )
+ is_sortable: bool = field(
+ metadata={"help": "True if any of the displayed columns can be used to sort"}
+ )
+ allow_execute_sql: bool = field(
+ metadata={
+ "help": "True if the current actor can execute custom SQL against this database"
+ }
+ )
+ query_ms: float = field(
+ metadata={
+ "help": "Time taken by the SQL queries for this page, in milliseconds"
+ }
+ )
+ select_templates: list = field(
+ metadata={
+ "help": "List of template names that were considered for this page, the one used marked with an asterisk"
+ }
+ )
+ top_table: callable = field(
+ metadata={"help": "Async function rendering the top_table plugin slot"}
+ )
+ count_limit: int = field(
+ metadata={
+ "help": "The maximum number of rows Datasette will count before showing an approximation"
+ }
+ )
+
+
LINK_WITH_LABEL = (
'{label} {id}'
)
@@ -1084,11 +1211,16 @@ async def table_view_traced(datasette, request):
)
}
)
+ # Only keys declared on TableContext are part of the documented
+ # template contract - anything else in data (e.g. extras requested
+ # with ?_extra= on the HTML page, or extra filter context added by
+ # filters_from_request plugins) is dropped here
+ declared_fields = {f.name for f in fields(TableContext)}
r = Response.html(
await datasette.render_template(
template,
- dict(
- data,
+ TableContext(
+ **{k: v for k, v in data.items() if k in declared_fields},
append_querystring=append_querystring,
path_with_replaced_args=path_with_replaced_args,
fix_path=datasette.urls.path,
diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py
index 3f867eb0..e9c78ecc 100644
--- a/tests/test_internals_datasette.py
+++ b/tests/test_internals_datasette.py
@@ -344,3 +344,28 @@ async def test_datasette_close_continues_past_db_error():
ds.close()
assert good._closed
assert ds._internal_database._closed
+
+
+@pytest.mark.asyncio
+async def test_datasette_render_template_dataclass_values_not_deep_copied():
+ # display_rows can contain values like sqlite3.Row that cannot be
+ # deep-copied, so render_template must convert Context dataclasses
+ # shallowly - https://github.com/simonw/datasette/issues/2127
+ class RefusesDeepCopy:
+ def __deepcopy__(self, memo):
+ raise RuntimeError("deepcopy not supported")
+
+ def __str__(self):
+ return "shallow-copied-value"
+
+ @dataclasses.dataclass
+ class ExampleContext(Context):
+ title: str
+ status: int
+ error: RefusesDeepCopy
+
+ context = ExampleContext(title="Hello", status=200, error=RefusesDeepCopy())
+ ds = Datasette(memory=True)
+ await ds.invoke_startup()
+ rendered = await ds.render_template("error.html", context)
+ assert "shallow-copied-value" in rendered
diff --git a/tests/test_template_context.py b/tests/test_template_context.py
index c385097a..4f08c476 100644
--- a/tests/test_template_context.py
+++ b/tests/test_template_context.py
@@ -178,6 +178,14 @@ async def test_template_context_matches_documented_contract(
)
+def test_table_context_fields_match_documented_contract():
+ from datasette.views.table import TableContext
+
+ assert {f.name for f in TableContext.documented_fields()} == {
+ key.name for key in PAGES["table"].documented_keys()
+ }
+
+
def test_base_context_keys_all_have_docs():
for key in BASE_CONTEXT_KEYS:
assert key.doc, "Base context key {} is missing docs".format(key.name)