mirror of
https://github.com/simonw/datasette.git
synced 2026-06-24 01:34:41 +02:00
242 lines
8.4 KiB
Python
242 lines
8.4 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 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))
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_truncated_replaces_count_limit_context_key(context_ds):
|
|
db = context_ds.databases["fixtures"]
|
|
previous_count_limit = db.count_limit
|
|
previous_cached_table_counts = db._cached_table_counts
|
|
db.count_limit = 10
|
|
db._cached_table_counts = None
|
|
try:
|
|
table_context = await get_template_context(context_ds, "/fixtures/facetable")
|
|
assert table_context["count"] == 11
|
|
assert table_context["count_truncated"] is True
|
|
assert "count_limit" not in table_context
|
|
|
|
database_context = await get_template_context(context_ds, "/fixtures")
|
|
facetable = next(
|
|
table
|
|
for table in database_context["tables"]
|
|
if table["name"] == "facetable"
|
|
)
|
|
assert facetable["count"] == 11
|
|
assert facetable["count_truncated"] is True
|
|
assert "count_limit" not in database_context
|
|
finally:
|
|
db.count_limit = previous_count_limit
|
|
db._cached_table_counts = previous_cached_table_counts
|
|
|
|
|
|
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)
|