Documentation for template context variables

Merge pull request #2803

Closes #1510, closes #2127, closes #1477, refs #2333
This commit is contained in:
Simon Willison 2026-06-23 12:37:06 -07:00 committed by GitHub
commit e0cdd38786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1870 additions and 386 deletions

View file

@ -35,6 +35,7 @@ from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
pass_context,
PrefixLoader,
)
from jinja2.environment import Template
@ -330,6 +331,50 @@ def _to_string(value):
return json.dumps(value, default=str)
def _template_context_json_default(value):
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {
field.name: getattr(value, field.name)
for field in dataclasses.fields(value)
}
return repr(value)
@pass_context
def _legacy_template_csrftoken(context):
request = context.get("request")
if request and "csrftoken" in request.scope:
return request.scope["csrftoken"]()
return ""
# Documentation for the variables Datasette.render_template() adds to the
# context for every page. This is part of the documented template contract:
# keys added in render_template() must be documented here - the contract
# tests in tests/test_template_context.py enforce this, and the docs in
# docs/template_context.rst are generated from it.
TEMPLATE_BASE_CONTEXT = {
"request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
"crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.',
"urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.",
"actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
"menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
"display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
"show_logout": "True if the logout link should be shown in the navigation menu",
"app_css_hash": "Hash of Datasette's app.css contents, used for cache busting",
"edit_tools_js_hash": "Hash of Datasette's edit-tools.js contents, used for cache busting",
"table_js_hash": "Hash of Datasette's table.js contents, used for cache busting",
"zip": "Python's ``zip()`` builtin, made available to template logic",
"body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.',
"format_bytes": "Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.",
"show_messages": "Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.",
"extra_css_urls": "List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.",
"extra_js_urls": "List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.",
"base_url": "The configured :ref:`setting_base_url` setting",
"datasette_version": "The version of Datasette that is running",
}
class Datasette:
# Message constants:
INFO = 1
@ -562,6 +607,7 @@ class Datasette:
)
environment.filters["escape_css_string"] = escape_css_string
environment.filters["quote_plus"] = urllib.parse.quote_plus
environment.globals["csrftoken"] = _legacy_template_csrftoken
self._jinja_env = environment
environment.filters["escape_sqlite"] = escape_sqlite
environment.filters["to_css_class"] = to_css_class
@ -2265,7 +2311,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(
@ -2315,6 +2365,8 @@ class Datasette:
links.extend(extra_links)
return links
# Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
# the contract tests fail otherwise
template_context = {
**context,
**{
@ -2341,18 +2393,19 @@ class Datasette:
"extra_js_urls", template, context, request, view_name
),
"base_url": self.setting("base_url"),
"csrftoken": (
request.scope["csrftoken"]
if request and "csrftoken" in request.scope
else lambda: ""
),
"datasette_version": __version__,
},
**extra_template_vars,
}
if request and request.args.get("_context") and self.setting("template_debug"):
return "<pre>{}</pre>".format(
escape(json.dumps(template_context, default=repr, indent=4))
escape(
json.dumps(
template_context,
default=_template_context_json_default,
indent=4,
)
)
)
return await template.render_async(template_context)

View file

@ -81,6 +81,16 @@ class ExtraRegistry:
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
def internal_classes_for_scope(self, scope):
# Extras that are available to HTML templates but excluded from
# JSON responses - plain Providers are dependency plumbing and
# never surface as keys, so they are not included
return [
cls
for cls in self.classes_for_scope(scope)
if issubclass(cls, Extra) and not cls.public
]
def _registry_for_scope(self, scope):
registry = self._scope_registries.get(scope)
if registry is None:

View file

@ -0,0 +1,40 @@
"""
Index of the documented template contexts for Datasette's core HTML pages.
This module deliberately contains no documentation strings of its own -
the documentation lives next to the code it describes:
- Every page renders a Context dataclass defined in its view module
(DatabaseContext, QueryContext in views/database.py, TableContext in
views/table.py, RowContext in views/row.py). Fields added by view code
carry ``help`` metadata; fields declared with from_extra() take their
documentation from the description on the matching Extra class in
views/table_extras.py.
- The keys render_template() adds to every page are documented in
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
them.
The contract tests in tests/test_template_context.py assert that the real
rendered context for each page exactly matches what is documented, and
docs/template_context_doc.py generates docs/template_context.rst from the
same classes.
"""
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.row import RowContext
from datasette.views.table import TableContext
PAGES = {
"database": DatabaseContext,
"query": QueryContext,
"table": TableContext,
"row": RowContext,
}
def documented_context_keys(page_name):
"Set of every documented key for the named page, including base context keys"
return set(TEMPLATE_BASE_CONTEXT) | {
f.name for f in PAGES[page_name].documented_fields()
}

View file

@ -76,7 +76,7 @@
<div class="db-table">
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}&gt;{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
</div>
{% endif %}
{% endfor %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %}
{{- super() -}}
@ -48,7 +48,7 @@
{% if count or human_description_en %}
<h3>
{% if count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows
{% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %}

View file

@ -1,2 +1,89 @@
from dataclasses import dataclass
import dataclasses
import types
import typing
@dataclass(frozen=True)
class ContextField:
name: str
type_name: str
help: str
from_extra: bool = False
def _type_name(type_):
if type_ is type(None):
return "None"
origin = typing.get_origin(type_)
args = typing.get_args(type_)
if origin in (typing.Union, types.UnionType):
return " | ".join(_type_name(arg) for arg in args)
if origin is not None:
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
def from_extra():
"""
Declare a Context dataclass field whose value comes from a registered
Extra of the same name - its documentation is the Extra description,
so the doc string lives next to the resolve() code rather than being
duplicated on the dataclass.
"""
return dataclasses.field(metadata={"from_extra": True})
class Context:
"Base class for all documented contexts"
# Set on subclasses whose from_extra() fields should be resolved
# against the extras registry for this scope
extras_scope = None
@classmethod
def documented_fields(cls):
"List of ContextField describing the documented fields of this context"
documented = []
for f in dataclasses.fields(cls):
if f.name.startswith("_"):
continue
is_from_extra = bool(f.metadata.get("from_extra"))
if is_from_extra:
help_text = cls._extra_description(f.name)
else:
help_text = f.metadata.get("help", "")
documented.append(
ContextField(
name=f.name,
type_name=_type_name(f.type),
help=help_text,
from_extra=is_from_extra,
)
)
return documented
@classmethod
def _extra_description(cls, name):
# Imported lazily - table_extras is not needed just to define
# Context subclasses
from datasette.views.table_extras import table_extra_registry
try:
extra_class = table_extra_registry.classes_by_name[name]
except KeyError:
raise KeyError(
"{}.{} is declared with from_extra() but there is no "
"registered extra of that name".format(cls.__name__, name)
)
if cls.extras_scope is not None and not extra_class.available_for(
cls.extras_scope
):
raise ValueError(
"{}.{} is declared with from_extra() but the {} extra is "
"not available for scope {}".format(
cls.__name__, name, name, cls.extras_scope
)
)
return extra_class.description or ""

View file

@ -1,31 +1,19 @@
import asyncio
import csv
import hashlib
import sys
import textwrap
import time
import urllib
from markupsafe import escape
from datasette.database import QueryInterrupted
from datasette.utils.asgi import Request
from datasette.utils import (
add_cors_headers,
await_me_maybe,
EscapeHtmlWriter,
InvalidSql,
LimitedWriter,
call_with_supported_arguments,
path_from_row_pks,
path_with_added_args,
path_with_removed_args,
path_with_format,
sqlite3,
)
from datasette.utils.asgi import (
AsgiStream,
NotFound,
Response,
BadRequest,
)
@ -192,227 +180,6 @@ class BaseView:
return view
class DataView(BaseView):
name = ""
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
r = Response.redirect(path)
r.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(r.headers)
return r
async def data(self, request):
raise NotImplementedError
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
_format = request.url_vars["format"]
data_kwargs = {}
if _format == "csv":
return await self.as_csv(request, database_route)
if _format is None:
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = []
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""".format(escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if _format == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if _format in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[_format][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
r = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
r = result
if status_code is not None:
# Over-ride the status code
r.status = status_code
else:
assert False, f"{result} should be dict or Response"
else:
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
)
url_csv_path = url_csv.split("?")[0]
context = {
**data,
**extras,
**{
"renderers": renderers,
"url_csv": url_csv,
"url_csv_path": url_csv_path,
"url_csv_hidden_args": [
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
"settings": self.ds.settings_dict(),
},
}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
r = await self.render(templates, request=request, context=context)
if status_code is not None:
r.status = status_code
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(r, ttl)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)

View file

@ -1,4 +1,4 @@
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import asyncio
import hashlib
@ -11,7 +11,7 @@ import textwrap
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.stored_queries import StoredQuery, stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
add_cors_headers,
@ -45,6 +45,29 @@ from .table_create_alter import _create_table_ui_context
from . import Context
@dataclass
class DatabaseTable:
"Summary of a table or view shown on database and query pages."
name: str
columns: list[str]
primary_keys: list[str]
count: int | None
count_truncated: bool
hidden: bool
fts_table: str | None
foreign_keys: dict[str, list[dict[str, str]]]
private: bool
@dataclass
class DatabaseViewInfo:
"Summary of a SQLite view shown on the database page."
name: str
private: bool
class DatabaseView(View):
async def get(self, request, datasette):
format_ = request.url_vars.get("format") or "html"
@ -94,7 +117,7 @@ class DatabaseView(View):
# Filter to just views
view_names_set = set(await db.view_names())
sql_views = [
{"name": name, "private": allowed_dict[name].private}
DatabaseViewInfo(name=name, private=allowed_dict[name].private)
for name in allowed_dict
if name in view_names_set
]
@ -169,9 +192,9 @@ class DatabaseView(View):
"private": private,
"path": datasette.urls.database(database),
"size": db.size,
"tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]),
"views": sql_views,
"tables": [asdict(table) for table in tables],
"hidden_count": len([table for table in tables if table.hidden]),
"views": [asdict(view) for view in sql_views],
"queries": [stored_query_to_dict(query) for query in stored_queries],
"queries_more": queries_more,
"queries_count": queries_count,
@ -211,7 +234,7 @@ class DatabaseView(View):
path=datasette.urls.database(database),
size=db.size,
tables=tables,
hidden_count=len([t for t in tables if t["hidden"]]),
hidden_count=len([table for table in tables if table.hidden]),
views=sql_views,
queries=stored_queries,
queries_more=queries_more,
@ -230,7 +253,6 @@ class DatabaseView(View):
database_actions=database_actions,
show_hidden=request.args.get("_show_hidden"),
editable=True,
count_limit=db.count_limit,
allow_download=datasette.setting("allow_download")
and not db.is_mutable
and not db.is_memory,
@ -257,16 +279,32 @@ class DatabaseView(View):
@dataclass
class DatabaseContext(Context):
"The page listing the tables, views and queries in a database, e.g. /fixtures."
documented_template = "database.html"
database: str = field(metadata={"help": "The name of the database"})
private: bool = field(
metadata={"help": "Boolean indicating if this is a private database"}
)
path: str = field(metadata={"help": "The URL path to this database"})
size: int = field(metadata={"help": "The size of the database in bytes"})
tables: list = field(metadata={"help": "List of table objects in the database"})
tables: list[DatabaseTable] = field(
metadata={
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
}
)
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
views: list = field(metadata={"help": "List of view objects in the database"})
queries: list = field(metadata={"help": "List of stored query objects"})
views: list[DatabaseViewInfo] = field(
metadata={
"help": "List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes."
}
)
queries: list[StoredQuery] = field(
metadata={
"help": "List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``."
}
)
queries_more: bool = field(
metadata={"help": "Boolean indicating if more stored queries are available"}
)
@ -275,48 +313,65 @@ class DatabaseContext(Context):
metadata={"help": "Boolean indicating if custom SQL can be executed"}
)
table_columns: dict = field(
metadata={"help": "Dictionary mapping table names to their column lists"}
metadata={
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
metadata: dict = field(
metadata={
"help": "Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata."
}
)
metadata: dict = field(metadata={"help": "Metadata for the database"})
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the database page"}
metadata={
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
}
)
database_actions: callable = field(
metadata={
"help": "Callable returning list of action links for the database menu"
"help": 'Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.'
}
)
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
editable: bool = field(
metadata={"help": "Boolean indicating if the database is editable"}
)
count_limit: int = field(metadata={"help": "The maximum number of rows to count"})
allow_download: bool = field(
metadata={"help": "Boolean indicating if database download is allowed"}
)
attached_databases: list = field(
metadata={"help": "List of names of attached databases"}
metadata={
"help": "List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for the alternate JSON version of this page"}
)
select_templates: list = field(
metadata={
"help": "List of templates that were considered for rendering this page"
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
}
)
top_database: callable = field(
metadata={"help": "Callable to render the top_database slot"}
metadata={
"help": "Async callable that renders the ``top_database`` plugin slot for this database and returns HTML."
}
)
@dataclass
class QueryContext(Context):
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
documented_template = "query.html"
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"}
metadata={
"help": "Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys."
}
)
stored_query: str = field(
metadata={"help": "The name of the stored query if this is a stored query"}
@ -333,7 +388,9 @@ class QueryContext(Context):
}
)
metadata: dict = field(
metadata={"help": "Metadata about the database or the stored query"}
metadata={
"help": "Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``."
}
)
db_is_immutable: bool = field(
metadata={"help": "Boolean indicating if this database is immutable"}
@ -357,22 +414,44 @@ class QueryContext(Context):
save_query_url: str = field(
metadata={"help": "URL to save the current arbitrary SQL as a query"}
)
tables: list = field(metadata={"help": "List of table objects in the database"})
tables: list[DatabaseTable] = field(
metadata={
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
}
)
named_parameter_values: dict = field(
metadata={"help": "Dictionary of parameter names/values"}
metadata={
"help": "Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``."
}
)
edit_sql_url: str = field(
metadata={"help": "URL to edit the SQL for a stored query"}
)
display_rows: list = field(metadata={"help": "List of result rows to display"})
columns: list = field(metadata={"help": "List of column names"})
renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"})
display_rows: list = field(
metadata={
"help": "List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``."
}
)
columns: list = field(
metadata={
"help": "List of result column names in the order they appear in ``display_rows`` and ``rows``."
}
)
renderers: dict = field(
metadata={
"help": "Dictionary mapping output format names such as ``json`` to URLs for this query in that format."
}
)
url_csv: str = field(metadata={"help": "URL for CSV export"})
show_hide_hidden: str = field(
metadata={"help": "Hidden input field for the _show_sql parameter"}
metadata={
"help": "Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state."
}
)
table_columns: dict = field(
metadata={"help": "Dictionary of table name to list of column names"}
metadata={
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for alternate JSON version of this page"}
@ -380,23 +459,27 @@ class QueryContext(Context):
# TODO: refactor this to somewhere else, probably ds.render_template()
select_templates: list = field(
metadata={
"help": "List of templates that were considered for rendering this page"
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
}
)
top_query: callable = field(
metadata={"help": "Callable to render the top_query slot"}
metadata={
"help": "Async callable that renders the ``top_query`` plugin slot for this query and returns HTML."
}
)
top_stored_query: callable = field(
metadata={"help": "Callable to render the top_stored_query slot"}
metadata={
"help": "Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML."
}
)
query_actions: callable = field(
metadata={
"help": "Callable returning a list of links for the query action menu"
"help": 'Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.'
}
)
async def get_tables(datasette, request, db, allowed_dict):
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]:
"""
Get list of tables with metadata for the database view.
@ -417,21 +500,36 @@ async def get_tables(datasette, request, db, allowed_dict):
table_columns = await db.table_columns(table)
tables.append(
{
"name": table,
"columns": table_columns,
"primary_keys": await db.primary_keys(table),
"count": table_counts[table],
"hidden": table in hidden_table_names,
"fts_table": await db.fts_table(table),
"foreign_keys": all_foreign_keys[table],
"private": allowed_dict[table].private,
}
DatabaseTable(
name=table,
columns=table_columns,
primary_keys=await db.primary_keys(table),
count=table_counts[table],
count_truncated=_table_count_truncated(
datasette, db, table, table_counts[table]
),
hidden=table in hidden_table_names,
fts_table=await db.fts_table(table),
foreign_keys=all_foreign_keys[table],
private=allowed_dict[table].private,
)
)
tables.sort(key=lambda t: (t["hidden"], t["name"]))
tables.sort(key=lambda table: (table.hidden, table.name))
return tables
def _table_count_truncated(datasette, db, table, count):
if count != db.count_limit + 1:
return False
if not db.is_mutable and datasette.inspect_data:
try:
datasette.inspect_data[db.name]["tables"][table]["count"]
return False
except KeyError:
pass
return True
async def database_download(request, datasette):
from datasette.resources import DatabaseResource

View file

@ -1,21 +1,36 @@
import asyncio
import json
import textwrap
import time
import urllib.parse
from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
from .base import DataView, BaseView, _error
from .base import BaseView, DatasetteError, _error, stream_csv
from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomRow,
InvalidSql,
make_slot_function,
path_from_row_pks,
path_with_added_args,
path_with_format,
path_with_removed_args,
to_css_class,
escape_sqlite,
sqlite3,
)
from datasette.plugins import pm
import json
import markupsafe
import sqlite_utils
from datasette.extras import extra_names_from_request
from datasette.extras import extra_names_from_request, ExtraScope
from . import Context, from_extra
from .table import (
display_columns_and_rows,
_table_page_data,
@ -24,9 +39,366 @@ from .table import (
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
class RowView(DataView):
@dataclass
class RowContext(Context):
"The page showing an individual row, e.g. /fixtures/facetable/1."
documented_template = "row.html"
extras_scope = ExtraScope.ROW
# Fields resolved by registered extras - their documentation comes
# from the description on each Extra class in table_extras.py
columns: list = from_extra()
database: str = from_extra()
database_color: str = from_extra()
foreign_key_tables: list = from_extra()
metadata: dict = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
table: str = from_extra()
# Fields added by the view code
ok: bool = field(
metadata={"help": "True if the data for this page was retrieved without errors"}
)
rows: list = field(
metadata={
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
}
)
primary_key_values: list = field(
metadata={"help": "Values of the primary keys for this row, from the URL"}
)
query_ms: float = field(
metadata={
"help": "Time taken by the SQL queries for this page, in milliseconds"
}
)
display_columns: list = field(
metadata={
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
}
)
display_rows: list = field(
metadata={
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
}
)
custom_table_templates: list = field(
metadata={
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
}
)
row_actions: list = field(
metadata={
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
}
)
row_mutation_ui: bool = field(
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
)
table_page_data: dict = field(
metadata={
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
}
)
top_row: callable = field(
metadata={
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
}
)
renderers: dict = field(
metadata={
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
}
)
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": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
}
)
settings: dict = field(
metadata={
"help": "Dictionary of Datasette's current settings, keyed by setting name."
}
)
select_templates: list = field(
metadata={
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for the JSON version of this page"}
)
class RowView(BaseView):
name = "row"
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
response = Response.redirect(path)
response.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
format_ = request.url_vars.get("format") or "html"
data_kwargs = {}
if format_ == "csv":
return await self.as_csv(request, database_route)
if format_ == "html":
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = ()
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""".format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if format_ == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[format_][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
response = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
response = result
if status_code is not None:
# Over-ride the status code
response.status = status_code
else:
assert False, f"{result} should be dict or Response"
elif format_ == "html":
response = await self.html(request, data, extra_template_data, templates)
if status_code is not None:
response.status = status_code
else:
raise NotFound("Invalid format: {}".format(format_))
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(response, ttl)
async def html(self, request, data, extra_template_data, templates):
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=data.get("database"),
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
)
url_csv_path = url_csv.split("?")[0]
context = {**data, **extras}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
environment = self.ds.get_jinja_environment(request)
template = environment.select_template(templates)
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
)
return Response.html(
await self.ds.render_template(
template,
RowContext(
columns=context["columns"],
database=context["database"],
database_color=context["database_color"],
foreign_key_tables=context["foreign_key_tables"],
metadata=context["metadata"],
primary_keys=context["primary_keys"],
private=context["private"],
table=context["table"],
ok=context["ok"],
rows=context["rows"],
primary_key_values=context["primary_key_values"],
query_ms=context["query_ms"],
display_columns=context["display_columns"],
display_rows=context["display_rows"],
custom_table_templates=context["custom_table_templates"],
row_actions=context["row_actions"],
row_mutation_ui=context["row_mutation_ui"],
table_page_data=context["table_page_data"],
top_row=context["top_row"],
renderers=renderers,
url_csv=url_csv,
url_csv_path=url_csv_path,
url_csv_hidden_args=[
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
settings=self.ds.settings_dict(),
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
alternate_url_json=alternate_url_json,
),
request=request,
view_name=self.name,
),
headers={
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def data(self, request, default_labels=False):
resolved = await self.ds.resolve_row(request)
db = resolved.db

View file

@ -46,6 +46,10 @@ from datasette.utils import (
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
from datasette.extras import ExtraScope
from . import Context, from_extra
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_create_alter import (
@ -64,6 +68,153 @@ 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."
documented_template = "table.html"
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 = from_extra()
all_columns: list = from_extra()
columns: list = from_extra()
count: int = from_extra()
count_sql: str = from_extra()
custom_table_templates: list = from_extra()
database: str = from_extra()
database_color: str = from_extra()
display_columns: list = from_extra()
display_rows: list = from_extra()
expandable_columns: list = from_extra()
facet_results: dict = from_extra()
facets_timed_out: list = from_extra()
filters: Filters = from_extra()
form_hidden_args: list = from_extra()
human_description_en: str = from_extra()
is_view: bool = from_extra()
metadata: dict = from_extra()
next_url: str = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
query: dict = from_extra()
renderers: dict = from_extra()
set_column_type_ui: dict = from_extra()
sorted_facet_results: list = from_extra()
suggested_facets: list = from_extra()
table: str = from_extra()
table_definition: str = from_extra()
view_definition: str = from_extra()
# 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"})
count_truncated: bool = field(
metadata={
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
}
)
rows: list = field(
metadata={
"help": "The rows for this page, as a list of dictionaries mapping column name to raw value."
}
)
filter_columns: list = field(
metadata={
"help": "List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered."
}
)
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=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment."
}
)
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": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``."
}
)
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 ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate."
}
)
path_with_replaced_args: callable = field(
metadata={
"help": "Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument."
}
)
fix_path: callable = field(
metadata={
"help": "Function that applies the configured ``base_url`` prefix to a path."
}
)
settings: dict = field(
metadata={
"help": "Dictionary of Datasette's current settings, keyed by setting name."
}
)
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, with the selected template prefixed by ``*``."
}
)
top_table: callable = field(
metadata={
"help": "Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML."
}
)
table_page_data: dict = field(
metadata={
"help": "JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data."
}
)
table_insert_ui: dict = field(
metadata={
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
}
)
table_alter_ui: dict = field(
metadata={
"help": "Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys."
}
)
LINK_WITH_LABEL = (
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a>&nbsp;<em>{id}</em>'
)
@ -1260,7 +1411,6 @@ class TableFragmentView(BaseView):
path_with_replaced_args=path_with_replaced_args,
fix_path=self.ds.urls.path,
settings=self.ds.settings_dict(),
count_limit=resolved.db.count_limit,
),
request=request,
view_name="table",
@ -1675,40 +1825,82 @@ async def table_view_traced(datasette, request):
)
}
)
table_context = TableContext(
actions=data["actions"],
all_columns=data["all_columns"],
columns=data["columns"],
count=data["count"],
count_sql=data["count_sql"],
custom_table_templates=data["custom_table_templates"],
database=data["database"],
database_color=data["database_color"],
display_columns=data["display_columns"],
display_rows=data["display_rows"],
expandable_columns=data["expandable_columns"],
facet_results=data["facet_results"],
facets_timed_out=data["facets_timed_out"],
filters=data["filters"],
form_hidden_args=data["form_hidden_args"],
human_description_en=data["human_description_en"],
is_view=data["is_view"],
metadata=data["metadata"],
next_url=data["next_url"],
primary_keys=data["primary_keys"],
private=data["private"],
query=data["query"],
renderers=data["renderers"],
set_column_type_ui=data["set_column_type_ui"],
sorted_facet_results=data["sorted_facet_results"],
suggested_facets=data["suggested_facets"],
table=data["table"],
table_definition=data["table_definition"],
view_definition=data["view_definition"],
ok=data["ok"],
next=data["next"],
count_truncated=data["count_truncated"],
rows=data["rows"],
filter_columns=data["filter_columns"],
supports_search=data["supports_search"],
extra_wheres_for_ui=data["extra_wheres_for_ui"],
url_csv=data["url_csv"],
url_csv_path=data["url_csv_path"],
url_csv_hidden_args=data["url_csv_hidden_args"],
sort=data["sort"],
sort_desc=data["sort_desc"],
append_querystring=append_querystring,
path_with_replaced_args=path_with_replaced_args,
fix_path=datasette.urls.path,
settings=datasette.settings_dict(),
alternate_url_json=alternate_url_json,
datasette_allow_facet=(
"true" if datasette.setting("allow_facet") else "false"
),
is_sortable=any(c["sortable"] for c in data["display_columns"]),
allow_execute_sql=await datasette.allowed(
action="execute-sql",
resource=DatabaseResource(database=resolved.db.name),
actor=request.actor,
),
query_ms=1.2,
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
top_table=make_slot_function(
"top_table",
datasette,
request,
database=resolved.db.name,
table=resolved.table,
),
table_page_data=data["table_page_data"],
table_insert_ui=data["table_insert_ui"],
table_alter_ui=data["table_alter_ui"],
)
r = Response.html(
await datasette.render_template(
template,
dict(
data,
append_querystring=append_querystring,
path_with_replaced_args=path_with_replaced_args,
fix_path=datasette.urls.path,
settings=datasette.settings_dict(),
# TODO: review up all of these hacks:
alternate_url_json=alternate_url_json,
datasette_allow_facet=(
"true" if datasette.setting("allow_facet") else "false"
),
is_sortable=any(c["sortable"] for c in data["display_columns"]),
allow_execute_sql=await datasette.allowed(
action="execute-sql",
resource=DatabaseResource(database=resolved.db.name),
actor=request.actor,
),
query_ms=1.2,
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
top_table=make_slot_function(
"top_table",
datasette,
request,
database=resolved.db.name,
table=resolved.table,
),
count_limit=resolved.db.count_limit,
),
table_context,
request=request,
view_name="table",
),
@ -2140,6 +2332,9 @@ async def table_view_data(
data["rows"] = transformed_rows
if context_for_html_hack:
data["count_truncated"] = _count_truncated_for_table_page(
datasette, db, database_name, table_name, count_sql, data.get("count")
)
data.update(extra_context_from_filters)
# filter_columns combine the columns we know are available
# in the table with any additional columns (such as rowid)
@ -2205,6 +2400,24 @@ async def table_view_data(
return data, rows[:page_size], columns, expanded_columns, sql, next_url
def _count_truncated_for_table_page(
datasette, db, database_name, table_name, count_sql, count
):
if count != db.count_limit + 1:
return False
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
try:
datasette.inspect_data[database_name]["tables"][table_name]["count"]
return False
except KeyError:
pass
return True
async def _next_value_and_url(
datasette,
db,

View file

@ -98,7 +98,7 @@ class QueryExtraContext:
class CountSqlExtra(Extra):
description = "SQL query used to calculate the total count"
description = "SQL query string used to calculate the total count for the current table view, including active filters."
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
scopes = {ExtraScope.TABLE}
@ -127,8 +127,8 @@ class CountExtra(Extra):
pass
if context.count_sql and count is None and not context.nocount:
count_sql_limited = (
f"select count(*) from (select * {context.from_sql} limit 10001)"
count_sql_limited = "select count(*) from (select * {} limit {})".format(
context.from_sql, context.db.count_limit + 1
)
try:
count_rows = list(
@ -165,7 +165,7 @@ class FacetInstancesProvider(Provider):
class FacetResultsExtra(Extra):
description = "Results of facets calculated against this data"
description = "Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``."
example = ExtraExample(
value={
"results": {
@ -214,7 +214,9 @@ class FacetResultsExtra(Extra):
class FacetsTimedOutExtra(Extra):
description = "Facet calculations that timed out"
description = (
"List of names of facet calculations that exceeded the facet time limit."
)
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out",
note=(
@ -230,7 +232,7 @@ class FacetsTimedOutExtra(Extra):
class SuggestedFacetsExtra(Extra):
description = "Suggestions for facets that might return interesting results"
description = "Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class."
example = ExtraExample(
value=[
{
@ -301,7 +303,7 @@ class NextUrlExtra(Extra):
class ColumnsExtra(Extra):
description = "Column names returned by this query"
description = "List of column names returned by this table, row or query."
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
examples = {
ExtraScope.ROW: ExtraExample(
@ -318,7 +320,7 @@ class ColumnsExtra(Extra):
class AllColumnsExtra(Extra):
description = "All columns in the table, regardless of _col/_nocol filtering"
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
scopes = {ExtraScope.TABLE}
@ -327,7 +329,7 @@ class AllColumnsExtra(Extra):
class PrimaryKeysExtra(Extra):
description = "Primary keys for this table"
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
examples = {
ExtraScope.ROW: ExtraExample(
@ -341,7 +343,7 @@ class PrimaryKeysExtra(Extra):
class ActionsExtra(Extra):
description = "Table or view actions made available by plugin hooks"
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes = {ExtraScope.TABLE}
# Returns an async function for the HTML templates - not JSON serializable
public = False
@ -421,7 +423,7 @@ class IsViewExtra(Extra):
class DebugExtra(Extra):
description = "Extra debug information"
description = "Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract."
docs_note = (
"The contents of this block are not a stable part of the Datasette "
"API and may change without warning."
@ -457,7 +459,7 @@ class DebugExtra(Extra):
class RequestExtra(Extra):
description = "Full information about the request"
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
example = ExtraExample("/fixtures/facetable.json?_extra=request")
examples = {
ExtraScope.ROW: ExtraExample(
@ -501,7 +503,7 @@ class DisplayColumnsAndRowsProvider(Provider):
class DisplayColumnsExtra(Extra):
description = "Column metadata used by the HTML table display"
description = "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
example = ExtraExample(
value=[
{
@ -531,7 +533,7 @@ class DisplayColumnsExtra(Extra):
class DisplayRowsExtra(Extra):
description = "Row data formatted for the HTML table display"
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
scopes = {ExtraScope.TABLE}
# Contains markupsafe/sqlite3.Row values - not JSON serializable
public = False
@ -640,7 +642,7 @@ class RenderCellExtra(Extra):
class QueryExtra(Extra):
description = "Details of the underlying SQL query"
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
examples = {
ExtraScope.ROW: ExtraExample(
@ -661,7 +663,7 @@ class QueryExtra(Extra):
class ColumnTypesExtra(Extra):
description = "Column type assignments for this table"
description = 'Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries.'
docs_note = (
"An empty object if no column types have been assigned. Column types "
"can be assigned in :ref:`configuration "
@ -700,7 +702,7 @@ class ColumnTypesExtra(Extra):
class SetColumnTypeUiExtra(Extra):
description = "Information needed to build an interface for assigning column types"
description = "Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values."
docs_note = (
"``null`` unless the current actor is allowed to use the :ref:`set "
"column type API <TableSetColumnTypeView>` for this table."
@ -784,7 +786,7 @@ class SetColumnTypeUiExtra(Extra):
class MetadataExtra(Extra):
description = "Metadata about the table, database or stored query"
description = "Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration."
docs_note = "See :ref:`metadata` for how to attach metadata to tables."
example = ExtraExample(
"/fixtures/facetable.json?_extra=metadata",
@ -891,7 +893,7 @@ class DatabaseColorExtra(Extra):
class FormHiddenArgsExtra(Extra):
description = "Hidden form arguments used by the HTML table interface"
description = "List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options."
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
)
@ -911,7 +913,7 @@ class FormHiddenArgsExtra(Extra):
class FiltersExtra(Extra):
description = "Filters object used by the HTML table interface"
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
scopes = {ExtraScope.TABLE}
# Returns a Filters instance for the HTML templates - not JSON serializable
public = False
@ -921,7 +923,7 @@ class FiltersExtra(Extra):
class CustomTableTemplatesExtra(Extra):
description = "Custom template names considered for this table"
description = "List of custom template names considered for rendering table rows, in lookup order."
docs_note = (
"The first template in this list that exists will be used to render "
"the table on the HTML version of this page. See "
@ -939,7 +941,7 @@ class CustomTableTemplatesExtra(Extra):
class SortedFacetResultsExtra(Extra):
description = "Facet results sorted for display"
description = "Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``."
docs_note = (
"The same data as ``facet_results``, as a list in the order used by "
"the HTML interface: facets from :ref:`facet configuration "
@ -1001,7 +1003,7 @@ class ViewDefinitionExtra(Extra):
class RenderersExtra(Extra):
description = "Alternative output renderers available for this table"
description = "Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format."
example = ExtraExample(
"/fixtures/facetable.json?_extra=renderers",
note=(
@ -1068,7 +1070,7 @@ class PrivateExtra(Extra):
class ExpandableColumnsExtra(Extra):
description = "Foreign key columns that can be expanded with labels"
description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``."
docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels."
example = ExtraExample(
"/fixtures/facetable.json?_extra=expandable_columns",
@ -1090,7 +1092,7 @@ class ExpandableColumnsExtra(Extra):
class ForeignKeyTablesExtra(Extra):
description = "Tables that link to this row using foreign keys"
description = "List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL."
example = ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables",
note=(
@ -1108,7 +1110,7 @@ class ForeignKeyTablesExtra(Extra):
class ExtrasExtra(Extra):
description = "List of ?_extra= blocks that can be used on this page"
description = "List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys."
example = ExtraExample(
value=[
{

View file

@ -309,6 +309,19 @@ To update these pages, run the following command::
uv run cog -r docs/*.rst
.. _contributing_template_contexts:
Documented template contexts
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Datasette's documented template contexts are part of the public API for custom templates. They are defined as dataclasses next to the view code that renders them, for example ``DatabaseContext`` and ``QueryContext`` in ``datasette/views/database.py``.
Every documented context class inherits from ``datasette.views.Context``. Fields that are added directly by view code should be declared as dataclass fields with ``help`` metadata, which is used to generate :ref:`template_context`. Fields resolved through the page extras system should use ``from_extra()`` so their documentation comes from the matching ``Extra`` class.
Use ``documented_template`` on each context class to record the canonical template named in the generated documentation. This should be a string such as ``"database.html"``. Runtime template selection still happens in the view code, since most pages consider more specific template names before falling back to the canonical one.
When a context field contains repeated structured data, prefer a small nested dataclass over an anonymous dictionary. For example, a field containing table summaries should be annotated as ``list[DatabaseTable]`` where ``DatabaseTable`` is a dataclass describing the keys and value types. This keeps the Python contract and generated documentation clear. JSON responses and ``?_context=1`` debug output will convert nested dataclasses back to JSON objects at the response boundary.
.. _contributing_continuous_deployment:
Continuously deployed demo instances

View file

@ -177,6 +177,11 @@ this::
Datasette will now first look for templates in that directory, and fall back on
the defaults if no matches are found.
The variables made available to each template are documented on the
:ref:`template_context` page. Variables documented there are a stable API:
custom templates that use them will keep working in future Datasette
releases, up until the next major version.
It is also possible to over-ride templates on a per-database, per-row or per-
table basis.

View file

@ -58,6 +58,7 @@ Contents
settings
introspection
custom_templates
template_context
plugins
writing_plugins
javascript_plugins

View file

@ -267,7 +267,7 @@ The available table extras are listed below.
15
``count_sql``
SQL query used to calculate the total count
SQL query string used to calculate the total count for the current table view, including active filters.
``GET /fixtures/facetable.json?_size=0&_extra=count_sql``
@ -276,7 +276,7 @@ The available table extras are listed below.
"select count(*) from facetable "
``facet_results``
Results of facets calculated against this data (May execute additional queries. See :ref:`facets` for details of how facets work.)
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``. (May execute additional queries. See :ref:`facets` for details of how facets work.)
Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.
@ -305,7 +305,7 @@ The available table extras are listed below.
}
``facets_timed_out``
Facet calculations that timed out
List of names of facet calculations that exceeded the facet time limit.
``GET /fixtures/facetable.json?_facet=state&_extra=facets_timed_out``
@ -316,7 +316,7 @@ The available table extras are listed below.
[]
``suggested_facets``
Suggestions for facets that might return interesting results (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class. (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.
@ -350,7 +350,7 @@ The available table extras are listed below.
"http://localhost/fixtures/facetable.json?_size=1&_extra=next_url&_next=1"
``columns``
Column names returned by this query
List of column names returned by this table, row or query.
``GET /fixtures/facetable.json?_extra=columns``
@ -371,7 +371,7 @@ The available table extras are listed below.
]
``all_columns``
All columns in the table, regardless of _col/_nocol filtering
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
``GET /fixtures/facetable.json?_col=pk&_extra=all_columns``
@ -392,7 +392,7 @@ The available table extras are listed below.
]
``primary_keys``
Primary keys for this table
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
``GET /fixtures/facetable.json?_extra=primary_keys``
@ -403,7 +403,7 @@ The available table extras are listed below.
]
``display_columns``
Column metadata used by the HTML table display
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.
@ -456,7 +456,7 @@ The available table extras are listed below.
}
``debug``
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/facetable.json?_extra=debug``
@ -474,7 +474,7 @@ The available table extras are listed below.
}
``request``
Full information about the request
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
``GET /fixtures/facetable.json?_extra=request``
@ -493,7 +493,7 @@ The available table extras are listed below.
}
``query``
Details of the underlying SQL query
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
``GET /fixtures/facetable.json?_size=1&_extra=query``
@ -505,7 +505,7 @@ The available table extras are listed below.
}
``column_types``
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
``GET /fixtures/facetable.json?_size=0&_extra=column_types``
@ -521,7 +521,7 @@ The available table extras are listed below.
}
``set_column_type_ui``
Information needed to build an interface for assigning column types (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values. (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
Shape abbreviated to two columns, as seen by an actor with ``set-column-type`` permission. ``current`` is the column type currently assigned to each column and ``options`` lists the types that could be assigned to it.
@ -571,7 +571,7 @@ The available table extras are listed below.
}
``metadata``
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/facetable.json?_extra=metadata``
@ -587,7 +587,7 @@ The available table extras are listed below.
}
``extras``
List of ?_extra= blocks that can be used on this page
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
@ -636,7 +636,7 @@ The available table extras are listed below.
"9403e5"
``renderers``
Alternative output renderers available for this table
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
``GET /fixtures/facetable.json?_extra=renderers``
@ -649,7 +649,7 @@ The available table extras are listed below.
}
``custom_table_templates``
Custom template names considered for this table (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
List of custom template names considered for rendering table rows, in lookup order. (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
``GET /fixtures/facetable.json?_extra=custom_table_templates``
@ -662,7 +662,7 @@ The available table extras are listed below.
]
``sorted_facet_results``
Facet results sorted for display (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``. (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
``GET /fixtures/facetable.json?_facet=state&_extra=sorted_facet_results``
@ -738,7 +738,7 @@ The available table extras are listed below.
false
``expandable_columns``
Foreign key columns that can be expanded with labels (See :ref:`expand_foreign_keys` for how to expand these labels.)
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.)
``GET /fixtures/facetable.json?_extra=expandable_columns``
@ -758,7 +758,7 @@ The available table extras are listed below.
]
``form_hidden_args``
Hidden form arguments used by the HTML table interface
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
``GET /fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args``
@ -785,7 +785,7 @@ Row JSON responses
The following extras are available for row JSON responses.
``columns``
Column names returned by this query
List of column names returned by this table, row or query.
``GET /fixtures/simple_primary_key/1.json?_extra=columns``
@ -797,7 +797,7 @@ The following extras are available for row JSON responses.
]
``primary_keys``
Primary keys for this table
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
``GET /fixtures/simple_primary_key/1.json?_extra=primary_keys``
@ -829,7 +829,7 @@ The following extras are available for row JSON responses.
}
``debug``
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/simple_primary_key/1.json?_extra=debug``
@ -858,7 +858,7 @@ The following extras are available for row JSON responses.
}
``request``
Full information about the request
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
``GET /fixtures/simple_primary_key/1.json?_extra=request``
@ -877,7 +877,7 @@ The following extras are available for row JSON responses.
}
``query``
Details of the underlying SQL query
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
``GET /fixtures/simple_primary_key/1.json?_extra=query``
@ -891,7 +891,7 @@ The following extras are available for row JSON responses.
}
``column_types``
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
``GET /fixtures/facetable/1.json?_extra=column_types``
@ -907,7 +907,7 @@ The following extras are available for row JSON responses.
}
``metadata``
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/simple_primary_key/1.json?_extra=metadata``
@ -920,7 +920,7 @@ The following extras are available for row JSON responses.
}
``extras``
List of ?_extra= blocks that can be used on this page
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
@ -978,7 +978,7 @@ The following extras are available for row JSON responses.
false
``foreign_key_tables``
Tables that link to this row using foreign keys (May execute additional queries.)
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL. (May execute additional queries.)
``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``
@ -1030,7 +1030,7 @@ Query JSON responses
The following extras are available for arbitrary SQL query responses and stored, named query responses.
``columns``
Column names returned by this query
List of column names returned by this table, row or query.
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=columns``
@ -1061,7 +1061,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``debug``
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=debug``
@ -1075,7 +1075,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``request``
Full information about the request
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=request``
@ -1097,7 +1097,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``query``
Details of the underlying SQL query
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=query``
@ -1120,7 +1120,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``metadata``
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/neighborhood_search.json?text=town&_extra=metadata``
@ -1151,7 +1151,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``extras``
List of ?_extra= blocks that can be used on this page
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.

506
docs/template_context.rst Normal file
View file

@ -0,0 +1,506 @@
.. _template_context:
Template context
================
This page documents the variables that are available to custom templates
for each of Datasette's core pages. See :ref:`customization_custom_templates`
for how to provide your own templates.
The variables documented here are a stable contract: custom templates that
use them will continue to work across Datasette releases, up until the next
major version (Datasette 2.0). Anything present in the template context but
not documented on this page is not part of that contract and may change or
be removed in any release.
You can inspect the full context for any page by starting Datasette with
``--setting template_debug 1`` and adding ``?_context=1`` to the page URL.
.. [[[cog
from template_context_doc import template_context
template_context(cog)
.. ]]]
Base context
------------
These variables are available on every page rendered by Datasette, including pages rendered by plugins that use :ref:`datasette.render_template() <datasette_render_template>`. Plugins can add additional variables using the :ref:`plugin_hook_extra_template_vars` hook.
``request``
The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.
``crumb_items``
Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.
``urls``
Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.
``actor``
The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.
``menu_links``
Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.
``display_actor``
Function that accepts an actor dictionary and returns the display string used in the navigation menu.
``show_logout``
True if the logout link should be shown in the navigation menu
``app_css_hash``
Hash of Datasette's app.css contents, used for cache busting
``edit_tools_js_hash``
Hash of Datasette's edit-tools.js contents, used for cache busting
``table_js_hash``
Hash of Datasette's table.js contents, used for cache busting
``zip``
Python's ``zip()`` builtin, made available to template logic
``body_scripts``
List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.
``format_bytes``
Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.
``show_messages``
Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.
``extra_css_urls``
List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.
``extra_js_urls``
List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.
``base_url``
The configured :ref:`setting_base_url` setting
``datasette_version``
The version of Datasette that is running
Database page
-------------
The page listing the tables, views and queries in a database, e.g. /fixtures. Rendered using the ``database.html`` template.
``allow_download`` - ``bool``
Boolean indicating if database download is allowed
``allow_execute_sql`` - ``bool``
Boolean indicating if custom SQL can be executed
``alternate_url_json`` - ``str``
URL for the alternate JSON version of this page
``attached_databases`` - ``list``
List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`.
``database`` - ``str``
The name of the database
``database_actions`` - ``callable``
Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.
``database_color`` - ``str``
The color assigned to the database
``database_page_data`` - ``dict``
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
``editable`` - ``bool``
Boolean indicating if the database is editable
``hidden_count`` - ``int``
Count of hidden tables
``metadata`` - ``dict``
Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata.
``path`` - ``str``
The URL path to this database
``private`` - ``bool``
Boolean indicating if this is a private database
``queries`` - ``list[StoredQuery]``
List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``.
``queries_count`` - ``int``
Count of visible stored queries
``queries_more`` - ``bool``
Boolean indicating if more stored queries are available
``select_templates`` - ``list``
List of template names that were considered for this page, with the selected template prefixed by ``*``.
``show_hidden`` - ``str``
Value of _show_hidden query parameter
``size`` - ``int``
The size of the database in bytes
``table_columns`` - ``dict``
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
``tables`` - ``list[DatabaseTable]``
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
``top_database`` - ``callable``
Async callable that renders the ``top_database`` plugin slot for this database and returns HTML.
``views`` - ``list[DatabaseViewInfo]``
List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes.
Query page
----------
The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name). Rendered using the ``query.html`` template.
``allow_execute_sql`` - ``bool``
Boolean indicating if custom SQL can be executed
``alternate_url_json`` - ``str``
URL for alternate JSON version of this page
``columns`` - ``list``
List of result column names in the order they appear in ``display_rows`` and ``rows``.
``database`` - ``str``
The name of the database being queried
``database_color`` - ``str``
The color of the database
``db_is_immutable`` - ``bool``
Boolean indicating if this database is immutable
``display_rows`` - ``list``
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.
``edit_sql_url`` - ``str``
URL to edit the SQL for a stored query
``editable`` - ``bool``
Boolean indicating if the SQL can be edited
``error`` - ``str``
Any query error message
``hide_sql`` - ``bool``
Boolean indicating if the SQL should be hidden
``metadata`` - ``dict``
Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``.
``named_parameter_values`` - ``dict``
Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``.
``private`` - ``bool``
Boolean indicating if this is a private database
``query`` - ``dict``
Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys.
``query_actions`` - ``callable``
Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.
``renderers`` - ``dict``
Dictionary mapping output format names such as ``json`` to URLs for this query in that format.
``save_query_url`` - ``str``
URL to save the current arbitrary SQL as a query
``select_templates`` - ``list``
List of template names that were considered for this page, with the selected template prefixed by ``*``.
``show_hide_hidden`` - ``str``
Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state.
``show_hide_link`` - ``str``
The URL to toggle showing/hiding the SQL
``show_hide_text`` - ``str``
The text for the show/hide SQL link
``stored_query`` - ``str``
The name of the stored query if this is a stored query
``stored_query_write`` - ``bool``
Boolean indicating if this is a stored query that allows writes
``table_columns`` - ``dict``
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
``tables`` - ``list[DatabaseTable]``
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
``top_query`` - ``callable``
Async callable that renders the ``top_query`` plugin slot for this query and returns HTML.
``top_stored_query`` - ``callable``
Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML.
``url_csv`` - ``str``
URL for CSV export
Table page
----------
The page showing the rows in a table or SQL view, e.g. /fixtures/facetable. Rendered using the ``table.html`` template.
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``actions`` - ``callable``
Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.
``all_columns`` - ``list``
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
``allow_execute_sql`` - ``bool``
True if the current actor can execute custom SQL against this database
``alternate_url_json`` - ``str``
URL for the JSON version of this page
``append_querystring`` - ``callable``
Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate.
``columns`` - ``list``
List of column names returned by this table, row or query.
``count`` - ``int``
Total count of rows matching these filters
``count_sql`` - ``str``
SQL query string used to calculate the total count for the current table view, including active filters.
``count_truncated`` - ``bool``
True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit.
``custom_table_templates`` - ``list``
List of custom template names considered for rendering table rows, in lookup order.
``database`` - ``str``
Database name
``database_color`` - ``str``
Color assigned to the database
``datasette_allow_facet`` - ``str``
The string "true" or "false" reflecting the allow_facet setting
``display_columns`` - ``list``
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
``display_rows`` - ``list``
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object.
``expandable_columns`` - ``list``
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``.
``extra_wheres_for_ui`` - ``list``
Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment.
``facet_results`` - ``dict``
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``.
``facets_timed_out`` - ``list``
List of names of facet calculations that exceeded the facet time limit.
``filter_columns`` - ``list``
List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered.
``filters`` - ``Filters``
``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable.
``fix_path`` - ``callable``
Function that applies the configured ``base_url`` prefix to a path.
``form_hidden_args`` - ``list``
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
``human_description_en`` - ``str``
Human-readable description of the filters
``is_sortable`` - ``bool``
True if any of the displayed columns can be used to sort
``is_view`` - ``bool``
Whether this resource is a view instead of a table
``metadata`` - ``dict``
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
``next`` - ``str``
Pagination token for the next page, or None
``next_url`` - ``str``
Full URL for the next page of results
``ok`` - ``bool``
True if the data for this page was retrieved without errors
``path_with_replaced_args`` - ``callable``
Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument.
``primary_keys`` - ``list``
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
``private`` - ``bool``
Whether this resource is private to the current actor
``query`` - ``dict``
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
``query_ms`` - ``float``
Time taken by the SQL queries for this page, in milliseconds
``renderers`` - ``dict``
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
``rows`` - ``list``
The rows for this page, as a list of dictionaries mapping column name to raw value.
``select_templates`` - ``list``
List of template names that were considered for this page, with the selected template prefixed by ``*``.
``set_column_type_ui`` - ``dict``
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values.
``settings`` - ``dict``
Dictionary of Datasette's current settings, keyed by setting name.
``sort`` - ``str``
Column the page is sorted by, or None
``sort_desc`` - ``str``
Column the page is sorted by in descending order, or None
``sorted_facet_results`` - ``list``
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``.
``suggested_facets`` - ``list``
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class.
``supports_search`` - ``bool``
True if this table has full-text search configured
``table`` - ``str``
Table name
``table_alter_ui`` - ``dict``
Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys.
``table_definition`` - ``str``
SQL definition for this table
``table_insert_ui`` - ``dict``
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
``table_page_data`` - ``dict``
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
``top_table`` - ``callable``
Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML.
``url_csv`` - ``str``
URL for the CSV export of this page
``url_csv_hidden_args`` - ``list``
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``.
``url_csv_path`` - ``str``
Path portion of the CSV export URL
``view_definition`` - ``str``
SQL definition for this view
Row page
--------
The page showing an individual row, e.g. /fixtures/facetable/1. Rendered using the ``row.html`` template.
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``alternate_url_json`` - ``str``
URL for the JSON version of this page
``columns`` - ``list``
List of column names returned by this table, row or query.
``custom_table_templates`` - ``list``
Custom template names that were considered for displaying this row's table, in lookup order.
``database`` - ``str``
Database name
``database_color`` - ``str``
Color assigned to the database
``display_columns`` - ``list``
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
``display_rows`` - ``list``
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys.
``foreign_key_tables`` - ``list``
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL.
``metadata`` - ``dict``
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
``ok`` - ``bool``
True if the data for this page was retrieved without errors
``primary_key_values`` - ``list``
Values of the primary keys for this row, from the URL
``primary_keys`` - ``list``
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
``private`` - ``bool``
Whether this resource is private to the current actor
``query_ms`` - ``float``
Time taken by the SQL queries for this page, in milliseconds
``renderers`` - ``dict``
Dictionary mapping output format names such as ``json`` to URLs for this row in that format.
``row_actions`` - ``list``
Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.
``row_mutation_ui`` - ``bool``
True if the row edit/delete JavaScript UI should be enabled
``rows`` - ``list``
A single-item list containing this row as a dictionary mapping column name to raw value.
``select_templates`` - ``list``
List of template names that were considered for this page, with the selected template prefixed by ``*``.
``settings`` - ``dict``
Dictionary of Datasette's current settings, keyed by setting name.
``table`` - ``str``
Table name
``table_page_data`` - ``dict``
JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs.
``top_row`` - ``callable``
Async callable that renders the ``top_row`` plugin slot for this row and returns HTML.
``url_csv`` - ``str``
URL for the CSV export of this page
``url_csv_hidden_args`` - ``list``
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``.
``url_csv_path`` - ``str``
Path portion of the CSV export URL
.. [[[end]]]

View file

@ -0,0 +1,45 @@
"""
Cog helpers for generating docs/template_context.rst from the Context
dataclasses and TEMPLATE_BASE_CONTEXT - same pattern as json_api_doc.py.
"""
def template_context(cog):
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.template_contexts import PAGES
cog.out("\n")
_section(
cog,
"Base context",
(
"These variables are available on every page rendered by "
"Datasette, including pages rendered by plugins that use "
":ref:`datasette.render_template() <datasette_render_template>`. "
"Plugins can add additional variables using the "
":ref:`plugin_hook_extra_template_vars` hook."
),
)
for name, doc in TEMPLATE_BASE_CONTEXT.items():
cog.out("``{}``\n".format(name))
cog.out(" {}\n\n".format(doc))
for klass in PAGES.values():
title = "{} page".format(klass.__name__.removesuffix("Context"))
intro = "{} Rendered using the ``{}`` template.".format(
klass.__doc__, klass.documented_template
)
_section(cog, title, intro)
if klass.extras_scope is not None:
cog.out(
"Many of these keys are shared with the :ref:`JSON API "
"<json_api>` for this page.\n\n"
)
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
cog.out(" {}\n\n".format(f.help))
def _section(cog, title, intro):
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
cog.out("{}\n\n".format(intro))

View file

@ -23,6 +23,38 @@ class DependentExtra(Extra):
return slow_value + 1
class InternalOnlyExtra(Extra):
description = "Internal extra for HTML templates only"
scopes = {ExtraScope.TABLE}
public = False
async def resolve(self, context):
return "internal"
def test_internal_classes_for_scope():
registry = ExtraRegistry([SlowValueExtra, DependentExtra, InternalOnlyExtra])
assert registry.internal_classes_for_scope(ExtraScope.TABLE) == [InternalOnlyExtra]
assert registry.public_classes_for_scope(ExtraScope.TABLE) == [
SlowValueExtra,
DependentExtra,
]
def _registered_extra_classes():
# Plain Providers are internal dependency plumbing, only Extra
# subclasses surface as documented JSON/template keys
from datasette.views.table_extras import table_extra_registry
return [cls for cls in table_extra_registry.classes if issubclass(cls, Extra)]
@pytest.mark.parametrize("cls", _registered_extra_classes(), ids=lambda cls: cls.key())
def test_registered_extras_have_descriptions(cls):
# Every registered extra is part of the documented template/JSON contract
assert cls.description, "{} is missing a description".format(cls.__name__)
def test_registry_is_built_once_per_scope():
registry = ExtraRegistry([SlowValueExtra, DependentExtra])
first = registry._registry_for_scope(ExtraScope.TABLE)

View file

@ -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

View file

@ -0,0 +1,215 @@
"""
Tests for the documented template context - the contract that custom
template authors can rely on for Datasette 1.0.
"""
import html
import json
import pathlib
from dataclasses import dataclass, field
import pytest
from datasette.app import Datasette, TEMPLATE_BASE_CONTEXT
from datasette.extras import ExtraScope
from datasette.fixtures import write_fixture_database
from datasette.template_contexts import PAGES, documented_context_keys
from datasette.views import Context
def test_documented_fields():
@dataclass
class DemoNested:
name: str
@dataclass
class DemoContext(Context):
name: str = field(metadata={"help": "The name"})
_internal: str = field()
count: int = field(metadata={"help": "How many there are"})
items: list[DemoNested] = field(metadata={"help": "Nested items"})
fields = DemoContext.documented_fields()
assert [(f.name, f.type_name, f.help) for f in fields] == [
("name", "str", "The name"),
("count", "int", "How many there are"),
("items", "list[DemoNested]", "Nested items"),
]
@pytest.mark.parametrize("klass", PAGES.values(), ids=lambda klass: klass.__name__)
def test_context_class_fields_all_have_help(klass):
for context_field in klass.documented_fields():
assert context_field.help, "{}.{} is missing documentation".format(
klass.__name__, context_field.name
)
@pytest.mark.parametrize("klass", PAGES.values(), ids=lambda klass: klass.__name__)
def test_context_class_has_docstring_and_documented_template(klass):
assert klass.__doc__, "{} is missing a docstring".format(klass.__name__)
assert klass.documented_template, "{} is missing a documented_template".format(
klass.__name__
)
def test_from_extra_documentation_comes_from_the_extra_class():
from datasette.views import from_extra
from datasette.views.table_extras import CountExtra
@dataclass
class DemoContext(Context):
extras_scope = ExtraScope.TABLE
count: int = from_extra()
name: str = field(metadata={"help": "The name"})
fields = {f.name: f for f in DemoContext.documented_fields()}
assert fields["count"].help == CountExtra.description
assert fields["count"].from_extra
assert fields["name"].help == "The name"
assert not fields["name"].from_extra
def test_from_extra_must_match_a_registered_extra():
from datasette.views import from_extra
@dataclass
class BadContext(Context):
extras_scope = ExtraScope.TABLE
not_a_real_extra: str = from_extra()
with pytest.raises(KeyError):
BadContext.documented_fields()
def test_from_extra_must_be_available_for_the_scope():
from datasette.views import from_extra
@dataclass
class WrongScopeContext(Context):
extras_scope = ExtraScope.ROW
# count is a TABLE-scope extra, not available for ROW
count: int = from_extra()
with pytest.raises(ValueError):
WrongScopeContext.documented_fields()
@pytest.fixture
def isolate_extra_template_vars_plugins():
# Datasette instances created with plugins_dir (e.g. the session-scoped
# ds_client fixture) register their plugins on the global plugin manager
# for the rest of the process. The contract documents plugin-free
# Datasette core, so unregister any non-default plugin that adds
# template variables via the extra_template_vars hook
from datasette.plugins import pm, DEFAULT_PLUGINS
hook_plugins = {impl.plugin for impl in pm.hook.extra_template_vars.get_hookimpls()}
removed = []
for plugin in list(pm.get_plugins()):
name = pm.get_name(plugin)
if name not in DEFAULT_PLUGINS and plugin in hook_plugins:
pm.unregister(plugin)
removed.append((plugin, name))
yield
for plugin, name in removed:
pm.register(plugin, name)
@pytest.fixture(scope="module")
def context_ds(tmp_path_factory):
db_path = tmp_path_factory.mktemp("template-context") / "fixtures.db"
write_fixture_database(db_path)
ds = Datasette(
[str(db_path)],
settings={"num_sql_threads": 1, "template_debug": True},
config={
"databases": {
"fixtures": {
"queries": {
"neighborhood_search": {
"sql": (
"select _neighborhood from facetable "
"where _neighborhood like '%' || :text || '%'"
),
"title": "Search neighborhoods",
}
}
}
}
},
)
yield ds
for db in ds.databases.values():
if not db.is_memory:
db.close()
async def get_template_context(ds, path):
sep = "&" if "?" in path else "?"
response = await ds.client.get(path + sep + "_context=1")
assert response.status_code == 200, path
body = html.unescape(response.text.removeprefix("<pre>").removesuffix("</pre>"))
return json.loads(body)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"page_name,path",
(
("database", "/fixtures"),
("table", "/fixtures/facetable"),
("table", "/fixtures/facetable?_city_id__exact=1"),
("row", "/fixtures/facetable/1"),
("query", "/fixtures/-/query?sql=select+*+from+facetable"),
("query", "/fixtures/neighborhood_search?text=cork"),
),
)
async def test_template_context_matches_documented_contract(
context_ds, isolate_extra_template_vars_plugins, page_name, path
):
# The full contract: every key in the rendered template context is
# documented, and every documented key is present in the context
documented = documented_context_keys(page_name)
actual = {
key
for key in await get_template_context(context_ds, path)
if not key.startswith("_")
}
undocumented = actual - documented
no_longer_present = documented - actual
assert not undocumented, (
"Undocumented keys in {} template context: {} - add them to the "
"page's Context class".format(page_name, sorted(undocumented))
)
assert not no_longer_present, (
"Documented keys missing from {} template context: {} - this would "
"break custom templates".format(page_name, sorted(no_longer_present))
)
def test_base_context_keys_all_have_docs():
for name, doc in TEMPLATE_BASE_CONTEXT.items():
assert doc, "Base context key {} is missing docs".format(name)
def test_template_context_docs_cover_every_documented_key():
docs_path = pathlib.Path(__file__).parent.parent / "docs" / "template_context.rst"
assert docs_path.exists(), "docs/template_context.rst is missing"
docs = docs_path.read_text()
for name in TEMPLATE_BASE_CONTEXT:
assert "``{}``".format(name) in docs, name
for page_name, klass in PAGES.items():
title = "{} page".format(klass.__name__.removesuffix("Context"))
assert title in docs, title
for context_field in klass.documented_fields():
assert "``{}``".format(context_field.name) in docs, "{} ({} page)".format(
context_field.name, page_name
)
assert (
"``{}`` - ``{}``".format(context_field.name, context_field.type_name)
in docs
), "{} type ({} page)".format(context_field.name, page_name)