Make filters, actions and display_rows extras internal

These three extras return values that exist for the HTML templates -
a Filters instance, an async function and markupsafe/sqlite3.Row data
- so requesting them on a .json page returned a 500 serialization
error, while the generated documentation and ?_extra=extras both
advertised them as API surface. They are now public=False: ignored
like any unknown name on JSON requests, omitted from the docs and the
extras list, and still resolved for the HTML view via the new
include_internal flag on ExtraRegistry.resolve().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-06-10 22:50:44 -07:00
commit b635dc53f4
5 changed files with 44 additions and 21 deletions

View file

@ -89,7 +89,7 @@ class ExtraRegistry:
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
async def resolve(self, requested, context, scope):
async def resolve(self, requested, context, scope, include_internal=False):
registry = Registry()
async def context_provider():
@ -100,15 +100,14 @@ class ExtraRegistry:
for cls in self.classes_for_scope(scope):
registry.register(cls().resolve, name=cls.key())
public_names = {cls.key() for cls in self.public_classes_for_scope(scope)}
requested_public_names = [
name
for name in requested
if name in public_names and name in registry._registry
]
resolved = await registry.resolve_multi(requested_public_names)
allowed_names = {
cls.key()
for cls in self.classes_for_scope(scope, include_internal=include_internal)
}
requested_names = [name for name in requested if name in allowed_names]
resolved = await registry.resolve_multi(requested_names)
return {
name: resolved[name] for name in requested_public_names if name in resolved
name: resolved[name] for name in requested_names if name in resolved
}

View file

@ -1518,7 +1518,14 @@ async def table_view_data(
"ok": True,
"next": next_value and str(next_value) or None,
}
data.update(await resolve_table_extras(extras, table_extra_context))
data.update(
await resolve_table_extras(
extras,
table_extra_context,
# The HTML view needs extras that are not JSON serializable
include_internal=bool(extra_extras),
)
)
raw_sqlite_rows = rows[:page_size]
# Apply transform_value for columns with assigned types
ct_map = await datasette.get_column_types(database_name, table_name)

View file

@ -333,6 +333,8 @@ class PrimaryKeysExtra(Extra):
class ActionsExtra(Extra):
description = "Table or view actions made available by plugin hooks"
scopes = frozenset({ExtraScope.TABLE})
# Returns an async function for the HTML templates - not JSON serializable
public = False
async def resolve(self, context):
async def actions():
@ -476,6 +478,8 @@ class DisplayColumnsExtra(Extra):
class DisplayRowsExtra(Extra):
description = "Row data formatted for the HTML table display"
scopes = frozenset({ExtraScope.TABLE})
# Contains markupsafe/sqlite3.Row values - not JSON serializable
public = False
async def resolve(self, context, display_columns_and_rows):
return display_columns_and_rows["rows"]
@ -772,6 +776,8 @@ class FormHiddenArgsExtra(Extra):
class FiltersExtra(Extra):
description = "Filters object used by the HTML table interface"
scopes = frozenset({ExtraScope.TABLE})
# Returns a Filters instance for the HTML templates - not JSON serializable
public = False
async def resolve(self, context):
return context.filters
@ -1034,8 +1040,10 @@ TABLE_EXTRA_CLASSES = [
table_extra_registry = ExtraRegistry(TABLE_EXTRA_CLASSES)
async def resolve_table_extras(extras, context):
return await table_extra_registry.resolve(extras, context, ExtraScope.TABLE)
async def resolve_table_extras(extras, context, include_internal=False):
return await table_extra_registry.resolve(
extras, context, ExtraScope.TABLE, include_internal=include_internal
)
async def resolve_row_extras(extras, context):

View file

@ -425,9 +425,6 @@ The available table extras are listed below.
}
]
``display_rows``
Row data formatted for the HTML table display
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
@ -554,12 +551,6 @@ The available table extras are listed below.
"9403e5"
``actions``
Table or view actions made available by plugin hooks
``filters``
Filters object used by the HTML table interface
``renderers``
Alternative output renderers available for this table

View file

@ -117,6 +117,24 @@ async def test_query_extras_for_stored_query(ds_client):
}
@pytest.mark.parametrize("extra", ["filters", "actions", "display_rows"])
@pytest.mark.asyncio
async def test_html_only_extras_are_not_available_via_json(ds_client, extra):
# These extras exist for the HTML view; their values are not JSON
# serializable so they are internal, not part of the JSON API
response = await ds_client.get(f"/fixtures/facetable.json?_extra={extra}")
assert response.status_code == 200
assert extra not in response.json()
@pytest.mark.asyncio
async def test_html_only_extras_are_not_advertised(ds_client):
response = await ds_client.get("/fixtures/facetable.json?_extra=extras")
assert response.status_code == 200
names = {e["name"] for e in response.json()["extras"]}
assert {"filters", "actions", "display_rows"}.isdisjoint(names)
def test_query_extra_private_for_arbitrary_sql():
with make_app_client(config={"allow_sql": {"id": "root"}}) as client:
cookies = {"ds_actor": client.actor_cookie({"id": "root"})}