datasette/tests/test_template_context.py
Simon Willison e889403d3b
Upgrade to ruff>=0.16.0 (#2857)
* ruff>=0.16.0

See https://astral.sh/blog/ruff-v0.16.0

* uv run ruff check . --fix --unsafe-fixes

* Ruff fixes by Claude Code Opus 5
2026-07-25 15:47:08 -07:00

214 lines
7.2 KiB
Python

"""
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 TEMPLATE_BASE_CONTEXT, Datasette
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
), f"{klass.__name__}.{context_field.name} is missing documentation"
@pytest.mark.parametrize("klass", PAGES.values(), ids=lambda klass: klass.__name__)
def test_context_class_has_docstring_and_documented_template(klass):
assert klass.__doc__, f"{klass.__name__} is missing a docstring"
assert (
klass.documented_template
), f"{klass.__name__} is missing a documented_template"
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 DEFAULT_PLUGINS, pm
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, (
f"Undocumented keys in {page_name} template context: {sorted(undocumented)} - add them to the "
"page's Context class"
)
assert not no_longer_present, (
f"Documented keys missing from {page_name} template context: {sorted(no_longer_present)} - this would "
"break custom templates"
)
def test_base_context_keys_all_have_docs():
for name, doc in TEMPLATE_BASE_CONTEXT.items():
assert doc, f"Base context key {name} is missing docs"
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 f"``{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 (
f"``{context_field.name}``" in docs
), f"{context_field.name} ({page_name} page)"
assert (
f"``{context_field.name}`` - ``{context_field.type_name}``" in docs
), f"{context_field.name} type ({page_name} page)"