One consistent pattern: every page context is a Context dataclass

datasette/template_contexts.py is now a thin index with no
documentation strings of its own - the docs live next to the code:

- Each page's Context class (DatabaseContext, QueryContext,
  TableContext, RowContext) carries a docstring, its template name and
  help metadata on view-added fields, in the view module itself
- extra_field() fields document themselves from the Extra classes
- The keys render_template() adds to every page are documented in
  TEMPLATE_BASE_CONTEXT in app.py, next to the code that adds them,
  with the contract tests keeping the two in sync

docs/template_context.rst is regenerated from the dataclasses, so the
table and row pages now include field types like the others.

Refs #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-06-11 07:08:21 -07:00
commit 8e01542fe9
8 changed files with 184 additions and 387 deletions

View file

@ -313,6 +313,32 @@ def _to_string(value):
return json.dumps(value, default=str)
# 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 Request object, or None",
"crumb_items": "Async function returning breadcrumb navigation items for the current page",
"urls": "Object with methods for constructing URLs to pages within Datasette - see datasette.urls in the internals documentation",
"actor": "The currently authenticated actor dictionary, or None",
"menu_links": "Async function returning links for the Datasette application menu, including those added by plugins",
"display_actor": "Function returning a display string for an actor dictionary",
"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",
"zip": "Python's zip() builtin, made available to template logic",
"body_scripts": "List of script blocks for the page body contributed by plugins",
"format_bytes": "Function that formats a number of bytes as a human-readable size",
"show_messages": "Function returning any messages set for the current user, clearing them in the process",
"extra_css_urls": "List of {url, sri} dictionaries of extra CSS stylesheets to include on the page, from plugins and configuration",
"extra_js_urls": "List of {url, sri, module} dictionaries of extra JavaScript URLs to include on the page",
"base_url": "The configured base_url setting",
"csrftoken": "Function returning the CSRF token for the current request",
"datasette_version": "The version of Datasette that is running",
}
class Datasette:
# Message constants:
INFO = 1
@ -2216,6 +2242,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,
**{

View file

@ -1,240 +1,40 @@
"""
The documented template context for Datasette's core HTML pages.
Index of the documented template contexts for Datasette's core HTML pages.
This module is the source of truth for the template context contract:
the set of variables that custom templates can rely on for each page.
It is consumed by the contract tests in tests/test_template_context.py,
which assert that the real rendered context for each page exactly
matches what is documented here, and by docs/template_context_doc.py
which generates the documentation in docs/template_context.rst.
This module deliberately contains no documentation strings of its own -
the documentation lives next to the code it describes:
Documentation for each key comes from one of three places:
- 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 extra_field() 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.
- Pages that render a Context dataclass (database, query) use the
``help`` metadata on each dataclass field
- Keys provided by registered extras use the ``description`` from the
Extra class
- Keys added inline by view code are documented in this module
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 dataclasses import dataclass
from datasette.extras import ExtraScope
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.table_extras import table_extra_registry
@dataclass(frozen=True)
class TemplateContextKey:
name: str
doc: str
def _keys(**docs):
return tuple(TemplateContextKey(name, doc) for name, doc in docs.items())
# Added by Datasette.render_template() to the context for every page,
# including pages rendered by plugins
BASE_CONTEXT_KEYS = _keys(
request="The current Request object, or None",
crumb_items="Async function returning breadcrumb navigation items for the current page",
urls="Object with methods for constructing URLs to pages within Datasette - see datasette.urls in the internals documentation",
actor="The currently authenticated actor dictionary, or None",
menu_links="Async function returning links for the Datasette application menu, including those added by plugins",
display_actor="Function returning a display string for an actor dictionary",
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",
zip="Python's zip() builtin, made available to template logic",
body_scripts="List of script blocks for the page body contributed by plugins",
format_bytes="Function that formats a number of bytes as a human-readable size",
show_messages="Function returning any messages set for the current user, clearing them in the process",
extra_css_urls="List of {url, sri} dictionaries of extra CSS stylesheets to include on the page, from plugins and configuration",
extra_js_urls="List of {url, sri, module} dictionaries of extra JavaScript URLs to include on the page",
base_url="The configured base_url setting",
csrftoken="Function returning the CSRF token for the current request",
datasette_version="The version of Datasette that is running",
)
@dataclass(frozen=True)
class PageContext:
# Identifier used in tests and documentation, e.g. "table"
name: str
title: str
description: str
# The default template used to render this page
template: str
# For pages rendered from a Context dataclass
context_class: type = None
# For pages whose context includes resolved extras
extras_scope: ExtraScope = None
extra_keys: tuple = ()
# Keys added inline by the view code, documented here
keys: tuple = ()
def documented_keys(self):
"Every page-specific documented key, excluding BASE_CONTEXT_KEYS"
documented = []
if self.context_class is not None:
documented.extend(
TemplateContextKey(f.name, f.help)
for f in self.context_class.documented_fields()
)
for name in self.extra_keys:
cls = table_extra_registry.classes_by_name[name]
documented.append(TemplateContextKey(name, cls.description or ""))
documented.extend(self.keys)
return sorted(documented, key=lambda key: key.name)
_SHARED_DOCS = dict(
ok="True if the data for this page was retrieved without errors",
rows="The rows for this page, as a list of dictionaries mapping column name to value",
query_ms="Time taken by the SQL queries for this page, in milliseconds",
select_templates="List of template names that were considered for this page, the one used marked with an asterisk",
settings="Dictionary of Datasette's current settings",
alternate_url_json="URL for the JSON version of this page",
url_csv="URL for the CSV export of this page",
url_csv_path="Path portion of the CSV export URL",
url_csv_hidden_args="(name, value) pairs for hidden form fields used by the CSV export form",
renderers="Dictionary mapping output format names (e.g. json) to their URLs for this page",
display_columns="Column objects formatted for the HTML table display",
display_rows="Row data formatted for the HTML table display",
custom_table_templates="Custom template names that were considered for displaying this table",
)
def _shared(*names):
return tuple(TemplateContextKey(name, _SHARED_DOCS[name]) for name in names)
from datasette.views.row import RowContext
from datasette.views.table import TableContext
PAGES = {
page.name: page
for page in (
PageContext(
name="database",
title="Database",
description="The page listing the tables, views and queries in a database, e.g. /fixtures.",
template="database.html",
context_class=DatabaseContext,
),
PageContext(
name="query",
title="Query",
description="The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name).",
template="query.html",
context_class=QueryContext,
),
PageContext(
name="table",
title="Table",
description="The page showing the rows in a table or SQL view, e.g. /fixtures/facetable.",
template="table.html",
extras_scope=ExtraScope.TABLE,
extra_keys=(
"actions",
"all_columns",
"columns",
"count",
"count_sql",
"custom_table_templates",
"database",
"database_color",
"display_columns",
"display_rows",
"expandable_columns",
"facet_results",
"facets_timed_out",
"filters",
"form_hidden_args",
"human_description_en",
"is_view",
"metadata",
"next_url",
"primary_keys",
"private",
"query",
"renderers",
"set_column_type_ui",
"sorted_facet_results",
"suggested_facets",
"table",
"table_definition",
"view_definition",
),
keys=_shared(
"ok",
"rows",
"query_ms",
"select_templates",
"settings",
"alternate_url_json",
"url_csv",
"url_csv_path",
"url_csv_hidden_args",
)
+ _keys(
allow_execute_sql="True if the current actor can execute custom SQL against this database",
append_querystring="Function that appends additional querystring arguments to a URL",
count_limit="The maximum number of rows Datasette will count before showing an approximation",
datasette_allow_facet='The string "true" or "false" reflecting the allow_facet setting',
extra_wheres_for_ui="Extra where clauses from ?_where=, with links to remove them",
filter_columns="List of columns offered by the filter interface",
fix_path="Function that applies the base_url prefix to a path",
is_sortable="True if any of the displayed columns can be used to sort",
next="Pagination token for the next page, or None",
path_with_replaced_args="Function for building the current path with modified querystring arguments",
sort="Column the page is sorted by, or None",
sort_desc="Column the page is sorted by in descending order, or None",
supports_search="True if this table has full-text search configured",
top_table="Async function rendering the top_table plugin slot",
),
),
PageContext(
name="row",
title="Row",
description="The page showing an individual row, e.g. /fixtures/facetable/1.",
template="row.html",
extras_scope=ExtraScope.ROW,
extra_keys=(
"columns",
"database",
"database_color",
"foreign_key_tables",
"metadata",
"primary_keys",
"private",
"table",
),
keys=_shared(
"ok",
"rows",
"query_ms",
"select_templates",
"settings",
"alternate_url_json",
"url_csv",
"url_csv_path",
"url_csv_hidden_args",
"renderers",
"display_columns",
"display_rows",
"custom_table_templates",
)
+ _keys(
primary_key_values="Values of the primary keys for this row, from the URL",
row_actions="Row actions made available by plugin hooks",
top_row="Async function rendering the top_row plugin slot",
),
),
)
"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"
page = PAGES[page_name]
return {key.name for key in BASE_CONTEXT_KEYS} | {
key.name for key in page.documented_keys()
return set(TEMPLATE_BASE_CONTEXT) | {
f.name for f in PAGES[page_name].documented_fields()
}

View file

@ -228,6 +228,10 @@ class DatabaseView(View):
@dataclass
class DatabaseContext(Context):
"The page listing the tables, views and queries in a database, e.g. /fixtures."
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"}
@ -281,6 +285,10 @@ class DatabaseContext(Context):
@dataclass
class QueryContext(Context):
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
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(

View file

@ -23,8 +23,9 @@ from .table_extras import RowExtraContext, resolve_row_extras, table_extra_regis
@dataclass
class RowContext(Context):
"The page showing an individual row, e.g. /fixtures/facetable/1"
"The page showing an individual row, e.g. /fixtures/facetable/1."
template = "row.html"
extras_scope = ExtraScope.ROW
# Fields resolved by registered extras - their documentation comes

View file

@ -59,8 +59,9 @@ from .table_extras import (
@dataclass
class TableContext(Context):
"The page showing the rows in a table or SQL view, e.g. /fixtures/facetable"
"The page showing the rows in a table or SQL view, e.g. /fixtures/facetable."
template = "table.html"
extras_scope = ExtraScope.TABLE
# Fields resolved by registered extras - their documentation comes

View file

@ -250,160 +250,160 @@ The page showing the rows in a table or SQL view, e.g. /fixtures/facetable. Rend
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``actions``
``actions`` - ``callable``
Table or view actions made available by plugin hooks
``all_columns``
``all_columns`` - ``list``
All columns in the table, regardless of _col/_nocol filtering
``allow_execute_sql``
``allow_execute_sql`` - ``bool``
True if the current actor can execute custom SQL against this database
``alternate_url_json``
``alternate_url_json`` - ``str``
URL for the JSON version of this page
``append_querystring``
``append_querystring`` - ``callable``
Function that appends additional querystring arguments to a URL
``columns``
``columns`` - ``list``
Column names returned by this query
``count``
``count`` - ``int``
Total count of rows matching these filters
``count_limit``
``count_limit`` - ``int``
The maximum number of rows Datasette will count before showing an approximation
``count_sql``
``count_sql`` - ``str``
SQL query used to calculate the total count
``custom_table_templates``
``custom_table_templates`` - ``list``
Custom template names considered for this table
``database``
``database`` - ``str``
Database name
``database_color``
``database_color`` - ``str``
Color assigned to the database
``datasette_allow_facet``
``datasette_allow_facet`` - ``str``
The string "true" or "false" reflecting the allow_facet setting
``display_columns``
``display_columns`` - ``list``
Column metadata used by the HTML table display
``display_rows``
``display_rows`` - ``list``
Row data formatted for the HTML table display
``expandable_columns``
``expandable_columns`` - ``list``
Foreign key columns that can be expanded with labels
``extra_wheres_for_ui``
``extra_wheres_for_ui`` - ``list``
Extra where clauses from ?_where=, with links to remove them
``facet_results``
``facet_results`` - ``dict``
Results of facets calculated against this data
``facets_timed_out``
``facets_timed_out`` - ``list``
Facet calculations that timed out
``filter_columns``
``filter_columns`` - ``list``
List of columns offered by the filter interface
``filters``
``filters`` - ``Filters``
Filters object used by the HTML table interface
``fix_path``
``fix_path`` - ``callable``
Function that applies the base_url prefix to a path
``form_hidden_args``
``form_hidden_args`` - ``list``
Hidden form arguments used by the HTML table interface
``human_description_en``
``human_description_en`` - ``str``
Human-readable description of the filters
``is_sortable``
``is_sortable`` - ``bool``
True if any of the displayed columns can be used to sort
``is_view``
``is_view`` - ``bool``
Whether this resource is a view instead of a table
``metadata``
``metadata`` - ``dict``
Metadata about the table, database or stored query
``next``
``next`` - ``str``
Pagination token for the next page, or None
``next_url``
``next_url`` - ``str``
Full URL for the next page of results
``ok``
``ok`` - ``bool``
True if the data for this page was retrieved without errors
``path_with_replaced_args``
``path_with_replaced_args`` - ``callable``
Function for building the current path with modified querystring arguments
``primary_keys``
``primary_keys`` - ``list``
Primary keys for this table
``private``
``private`` - ``bool``
Whether this resource is private to the current actor
``query``
``query`` - ``dict``
Details of the underlying SQL query
``query_ms``
``query_ms`` - ``float``
Time taken by the SQL queries for this page, in milliseconds
``renderers``
``renderers`` - ``dict``
Alternative output renderers available for this table
``rows``
``rows`` - ``list``
The rows for this page, as a list of dictionaries mapping column name to value
``select_templates``
``select_templates`` - ``list``
List of template names that were considered for this page, the one used marked with an asterisk
``set_column_type_ui``
``set_column_type_ui`` - ``dict``
Column type UI metadata for this table
``settings``
``settings`` - ``dict``
Dictionary of Datasette's current settings
``sort``
``sort`` - ``str``
Column the page is sorted by, or None
``sort_desc``
``sort_desc`` - ``str``
Column the page is sorted by in descending order, or None
``sorted_facet_results``
``sorted_facet_results`` - ``list``
Facet results sorted for display
``suggested_facets``
``suggested_facets`` - ``list``
Suggestions for facets that might return interesting results
``supports_search``
``supports_search`` - ``bool``
True if this table has full-text search configured
``table``
``table`` - ``str``
Table name
``table_definition``
``table_definition`` - ``str``
SQL definition for this table
``top_table``
``top_table`` - ``callable``
Async function rendering the top_table plugin slot
``url_csv``
``url_csv`` - ``str``
URL for the CSV export of this page
``url_csv_hidden_args``
``url_csv_hidden_args`` - ``list``
(name, value) pairs for hidden form fields used by the CSV export form
``url_csv_path``
``url_csv_path`` - ``str``
Path portion of the CSV export URL
``view_definition``
``view_definition`` - ``str``
SQL definition for this view
Row page
@ -413,76 +413,76 @@ The page showing an individual row, e.g. /fixtures/facetable/1. Rendered using t
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``alternate_url_json``
``alternate_url_json`` - ``str``
URL for the JSON version of this page
``columns``
``columns`` - ``list``
Column names returned by this query
``custom_table_templates``
``custom_table_templates`` - ``list``
Custom template names that were considered for displaying this table
``database``
``database`` - ``str``
Database name
``database_color``
``database_color`` - ``str``
Color assigned to the database
``display_columns``
``display_columns`` - ``list``
Column objects formatted for the HTML table display
``display_rows``
``display_rows`` - ``list``
Row data formatted for the HTML table display
``foreign_key_tables``
``foreign_key_tables`` - ``list``
Tables that link to this row using foreign keys
``metadata``
``metadata`` - ``dict``
Metadata about the table, database or stored query
``ok``
``ok`` - ``bool``
True if the data for this page was retrieved without errors
``primary_key_values``
``primary_key_values`` - ``list``
Values of the primary keys for this row, from the URL
``primary_keys``
``primary_keys`` - ``list``
Primary keys for this table
``private``
``private`` - ``bool``
Whether this resource is private to the current actor
``query_ms``
``query_ms`` - ``float``
Time taken by the SQL queries for this page, in milliseconds
``renderers``
``renderers`` - ``dict``
Dictionary mapping output format names (e.g. json) to their URLs for this page
``row_actions``
``row_actions`` - ``list``
Row actions made available by plugin hooks
``rows``
``rows`` - ``list``
The rows for this page, as a list of dictionaries mapping column name to value
``select_templates``
``select_templates`` - ``list``
List of template names that were considered for this page, the one used marked with an asterisk
``settings``
``settings`` - ``dict``
Dictionary of Datasette's current settings
``table``
``table`` - ``str``
Table name
``top_row``
``top_row`` - ``callable``
Async function rendering the top_row plugin slot
``url_csv``
``url_csv`` - ``str``
URL for the CSV export of this page
``url_csv_hidden_args``
``url_csv_hidden_args`` - ``list``
(name, value) pairs for hidden form fields used by the CSV export form
``url_csv_path``
``url_csv_path`` - ``str``
Path portion of the CSV export URL
.. [[[end]]]

View file

@ -1,11 +1,12 @@
"""
Cog helpers for generating docs/template_context.rst from the manifest
in datasette/template_contexts.py - same pattern as json_api_doc.py.
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.template_contexts import BASE_CONTEXT_KEYS, PAGES
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.template_contexts import PAGES
cog.out("\n")
_section(
@ -19,36 +20,26 @@ def template_context(cog):
":ref:`plugin_hook_extra_template_vars` hook."
),
)
_untyped_keys(cog, BASE_CONTEXT_KEYS)
for name, doc in TEMPLATE_BASE_CONTEXT.items():
cog.out("``{}``\n".format(name))
cog.out(" {}\n\n".format(doc))
for page in PAGES.values():
_section(
cog,
"{} page".format(page.title),
"{} Rendered using the ``{}`` template.".format(
page.description, page.template
),
for klass in PAGES.values():
title = "{} page".format(klass.__name__.removesuffix("Context"))
intro = "{} Rendered using the ``{}`` template.".format(
klass.__doc__, klass.template
)
if page.context_class is not None:
for f in sorted(
page.context_class.documented_fields(), key=lambda f: f.name
):
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
cog.out(" {}\n\n".format(f.help))
else:
_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"
)
_untyped_keys(cog, page.documented_keys())
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))
def _untyped_keys(cog, keys):
for key in keys:
cog.out("``{}``\n".format(key.name))
cog.out(" {}\n\n".format(key.doc))

View file

@ -10,17 +10,11 @@ from dataclasses import dataclass, field
import pytest
from datasette.app import Datasette
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 (
BASE_CONTEXT_KEYS,
PAGES,
documented_context_keys,
)
from datasette.template_contexts import PAGES, documented_context_keys
from datasette.views import Context
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.table_extras import table_extra_registry
def test_documented_fields():
@ -36,14 +30,20 @@ def test_documented_fields():
]
@pytest.mark.parametrize("klass", (DatabaseContext, QueryContext))
def test_context_dataclass_fields_all_have_help(klass):
@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 help metadata".format(
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_template(klass):
assert klass.__doc__, "{} is missing a docstring".format(klass.__name__)
assert klass.template, "{} is missing a template".format(klass.__name__)
def test_extra_field_documentation_comes_from_the_extra_class():
from datasette.views import extra_field
from datasette.views.table_extras import CountExtra
@ -169,8 +169,8 @@ async def test_template_context_matches_documented_contract(
undocumented = actual - documented
no_longer_present = documented - actual
assert not undocumented, (
"Undocumented keys in {} template context: {} - document them in "
"datasette/template_contexts.py".format(page_name, sorted(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 "
@ -178,53 +178,21 @@ async def test_template_context_matches_documented_contract(
)
def test_table_context_fields_match_documented_contract():
from datasette.views.table import TableContext
assert {f.name for f in TableContext.documented_fields()} == {
key.name for key in PAGES["table"].documented_keys()
}
def test_row_context_fields_match_documented_contract():
from datasette.views.row import RowContext
assert {f.name for f in RowContext.documented_fields()} == {
key.name for key in PAGES["row"].documented_keys()
}
def test_base_context_keys_all_have_docs():
for key in BASE_CONTEXT_KEYS:
assert key.doc, "Base context key {} is missing docs".format(key.name)
@pytest.mark.parametrize("page", PAGES.values(), ids=lambda page: page.name)
def test_page_documented_keys_all_have_docs(page):
for key in page.documented_keys():
assert key.doc, "{} page key {} is missing docs".format(page.name, key.name)
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 key in BASE_CONTEXT_KEYS:
assert "``{}``".format(key.name) in docs, key.name
for page in PAGES.values():
assert page.title in docs, page.title
for key in page.documented_keys():
assert "``{}``".format(key.name) in docs, "{} ({} page)".format(
key.name, page.name
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
)
@pytest.mark.parametrize("page", PAGES.values(), ids=lambda page: page.name)
def test_page_extra_keys_are_registered_extras(page):
for name in page.extra_keys:
cls = table_extra_registry.classes_by_name.get(name)
assert cls is not None, "{} is not a registered extra".format(name)
assert page.extras_scope is not None
assert cls.available_for(
page.extras_scope
), "{} extra is not available for scope {}".format(name, page.extras_scope)