jinja2_environment_from_request() plugin hook

Closes #2225
This commit is contained in:
Simon Willison 2024-01-05 14:33:23 -08:00 committed by GitHub
commit c7a4706bcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 128 additions and 25 deletions

View file

@ -420,21 +420,31 @@ class Datasette:
), ),
] ]
) )
self.jinja_env = Environment( environment = Environment(
loader=template_loader, loader=template_loader,
autoescape=True, autoescape=True,
enable_async=True, enable_async=True,
# undefined=StrictUndefined, # undefined=StrictUndefined,
) )
self.jinja_env.filters["escape_css_string"] = escape_css_string environment.filters["escape_css_string"] = escape_css_string
self.jinja_env.filters["quote_plus"] = urllib.parse.quote_plus environment.filters["quote_plus"] = urllib.parse.quote_plus
self.jinja_env.filters["escape_sqlite"] = escape_sqlite self._jinja_env = environment
self.jinja_env.filters["to_css_class"] = to_css_class environment.filters["escape_sqlite"] = escape_sqlite
environment.filters["to_css_class"] = to_css_class
self._register_renderers() self._register_renderers()
self._permission_checks = collections.deque(maxlen=200) self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32) self._root_token = secrets.token_hex(32)
self.client = DatasetteClient(self) self.client = DatasetteClient(self)
def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env
if request:
for environment in pm.hook.jinja2_environment_from_request(
datasette=self, request=request, env=environment
):
pass
return environment
def get_permission(self, name_or_abbr: str) -> "Permission": def get_permission(self, name_or_abbr: str) -> "Permission":
""" """
Returns a Permission object for the given name or abbreviation. Raises KeyError if not found. Returns a Permission object for the given name or abbreviation. Raises KeyError if not found.
@ -514,7 +524,7 @@ class Datasette:
abbrs[p.abbr] = p abbrs[p.abbr] = p
self.permissions[p.name] = p self.permissions[p.name] = p
for hook in pm.hook.prepare_jinja2_environment( for hook in pm.hook.prepare_jinja2_environment(
env=self.jinja_env, datasette=self env=self._jinja_env, datasette=self
): ):
await await_me_maybe(hook) await await_me_maybe(hook)
for hook in pm.hook.startup(datasette=self): for hook in pm.hook.startup(datasette=self):
@ -1218,7 +1228,7 @@ class Datasette:
else: else:
if isinstance(templates, str): if isinstance(templates, str):
templates = [templates] templates = [templates]
template = self.jinja_env.select_template(templates) template = self.get_jinja_environment(request).select_template(templates)
if dataclasses.is_dataclass(context): if dataclasses.is_dataclass(context):
context = dataclasses.asdict(context) context = dataclasses.asdict(context)
body_scripts = [] body_scripts = []
@ -1568,16 +1578,6 @@ class DatasetteRouter:
def __init__(self, datasette, routes): def __init__(self, datasette, routes):
self.ds = datasette self.ds = datasette
self.routes = routes or [] self.routes = routes or []
# Build a list of pages/blah/{name}.html matching expressions
pattern_templates = [
filepath
for filepath in self.ds.jinja_env.list_templates()
if "{" in filepath and filepath.startswith("pages/")
]
self.page_routes = [
(route_pattern_from_filepath(filepath[len("pages/") :]), filepath)
for filepath in pattern_templates
]
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
# Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves # Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves
@ -1677,13 +1677,24 @@ class DatasetteRouter:
route_path = request.scope.get("route_path", request.scope["path"]) route_path = request.scope.get("route_path", request.scope["path"])
# Jinja requires template names to use "/" even on Windows # Jinja requires template names to use "/" even on Windows
template_name = "pages" + route_path + ".html" template_name = "pages" + route_path + ".html"
# Build a list of pages/blah/{name}.html matching expressions
environment = self.ds.get_jinja_environment(request)
pattern_templates = [
filepath
for filepath in environment.list_templates()
if "{" in filepath and filepath.startswith("pages/")
]
page_routes = [
(route_pattern_from_filepath(filepath[len("pages/") :]), filepath)
for filepath in pattern_templates
]
try: try:
template = self.ds.jinja_env.select_template([template_name]) template = environment.select_template([template_name])
except TemplateNotFound: except TemplateNotFound:
template = None template = None
if template is None: if template is None:
# Try for a pages/blah/{name}.html template match # Try for a pages/blah/{name}.html template match
for regex, wildcard_template in self.page_routes: for regex, wildcard_template in page_routes:
match = regex.match(route_path) match = regex.match(route_path)
if match is not None: if match is not None:
context.update(match.groupdict()) context.update(match.groupdict())

View file

@ -57,7 +57,8 @@ def handle_exception(datasette, request, exception):
if request.path.split("?")[0].endswith(".json"): if request.path.split("?")[0].endswith(".json"):
return Response.json(info, status=status, headers=headers) return Response.json(info, status=status, headers=headers)
else: else:
template = datasette.jinja_env.select_template(templates) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html( return Response.html(
await template.render_async( await template.render_async(
dict( dict(

View file

@ -99,6 +99,11 @@ def actors_from_ids(datasette, actor_ids):
"""Returns a dictionary mapping those IDs to actor dictionaries""" """Returns a dictionary mapping those IDs to actor dictionaries"""
@hookspec
def jinja2_environment_from_request(datasette, request, env):
"""Return a Jinja2 environment based on the incoming request"""
@hookspec @hookspec
def filters_from_request(request, database, table, datasette): def filters_from_request(request, database, table, datasette):
""" """

View file

@ -143,7 +143,8 @@ class BaseView:
async def render(self, templates, request, context=None): async def render(self, templates, request, context=None):
context = context or {} context = context or {}
template = self.ds.jinja_env.select_template(templates) environment = self.ds.get_jinja_environment(request)
template = environment.select_template(templates)
template_context = { template_context = {
**context, **context,
**{ **{

View file

@ -143,7 +143,8 @@ class DatabaseView(View):
datasette.urls.path(path_with_format(request=request, format="json")), datasette.urls.path(path_with_format(request=request, format="json")),
) )
templates = (f"database-{to_css_class(database)}.html", "database.html") templates = (f"database-{to_css_class(database)}.html", "database.html")
template = datasette.jinja_env.select_template(templates) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
context = { context = {
**json_data, **json_data,
"database_color": db.color, "database_color": db.color,
@ -594,7 +595,8 @@ class QueryView(View):
f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
) )
template = datasette.jinja_env.select_template(templates) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path(path_with_format(request=request, format="json")), datasette.urls.path(path_with_format(request=request, format="json")),

View file

@ -806,7 +806,8 @@ async def table_view_traced(datasette, request):
f"table-{to_css_class(resolved.db.name)}-{to_css_class(resolved.table)}.html", f"table-{to_css_class(resolved.db.name)}-{to_css_class(resolved.table)}.html",
"table.html", "table.html",
] ]
template = datasette.jinja_env.select_template(templates) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path(path_with_format(request=request, format="json")), datasette.urls.path(path_with_format(request=request, format="json")),

View file

@ -1128,6 +1128,48 @@ These IDs could be integers or strings, depending on how the actors used by the
Example: `datasette-remote-actors <https://github.com/datasette/datasette-remote-actors>`_ Example: `datasette-remote-actors <https://github.com/datasette/datasette-remote-actors>`_
.. _plugin_hook_jinja2_environment_from_request:
jinja2_environment_from_request(datasette, request, env)
--------------------------------------------------------
``datasette`` - :ref:`internals_datasette`
A Datasette instance.
``request`` - :ref:`internals_request` or ``None``
The current HTTP request, if one is available.
``env`` - ``Environment``
The Jinja2 environment that will be used to render the current page.
This hook can be used to return a customized `Jinja environment <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment>`__ based on the incoming request.
If you want to run a single Datasette instance that serves different content for different domains, you can do so like this:
.. code-block:: python
from datasette import hookimpl
from jinja2 import ChoiceLoader, FileSystemLoader
@hookimpl
def jinja2_environment_from_request(request, env):
if request and request.host == "www.niche-museums.com":
return env.overlay(
loader=ChoiceLoader(
[
FileSystemLoader(
"/mnt/niche-museums/templates"
),
env.loader,
]
),
enable_async=True,
)
return env
This uses the Jinja `overlay() method <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment.overlay>`__ to create a new environment identical to the default environment except for having a different template loader, which first looks in the ``/mnt/niche-museums/templates`` directory before falling back on the default loader.
.. _plugin_hook_filters_from_request: .. _plugin_hook_filters_from_request:
filters_from_request(request, database, table, datasette) filters_from_request(request, database, table, datasette)

View file

@ -16,6 +16,7 @@ from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
from datasette.utils.sqlite import sqlite3 from datasette.utils.sqlite import sqlite3
from datasette.utils import CustomRow, StartupError from datasette.utils import CustomRow, StartupError
from jinja2.environment import Template from jinja2.environment import Template
from jinja2 import ChoiceLoader, FileSystemLoader
import base64 import base64
import importlib import importlib
import json import json
@ -563,7 +564,8 @@ async def test_hook_register_output_renderer_can_render(ds_client):
async def test_hook_prepare_jinja2_environment(ds_client): async def test_hook_prepare_jinja2_environment(ds_client):
ds_client.ds._HELLO = "HI" ds_client.ds._HELLO = "HI"
await ds_client.ds.invoke_startup() await ds_client.ds.invoke_startup()
template = ds_client.ds.jinja_env.from_string( environment = ds_client.ds.get_jinja_environment(None)
template = environment.from_string(
"Hello there, {{ a|format_numeric }}, {{ a|to_hello }}, {{ b|select_times_three }}", "Hello there, {{ a|format_numeric }}, {{ a|to_hello }}, {{ b|select_times_three }}",
{"a": 3412341, "b": 5}, {"a": 3412341, "b": 5},
) )
@ -1294,3 +1296,41 @@ async def test_plugin_is_installed():
finally: finally:
pm.unregister(name="DummyPlugin") pm.unregister(name="DummyPlugin")
@pytest.mark.asyncio
async def test_hook_jinja2_environment_from_request(tmpdir):
templates = pathlib.Path(tmpdir / "templates")
templates.mkdir()
(templates / "index.html").write_text("Hello museums!", "utf-8")
class EnvironmentPlugin:
@hookimpl
def jinja2_environment_from_request(self, request, env):
if request and request.host == "www.niche-museums.com":
return env.overlay(
loader=ChoiceLoader(
[
FileSystemLoader(str(templates)),
env.loader,
]
),
enable_async=True,
)
return env
datasette = Datasette(memory=True)
try:
pm.register(EnvironmentPlugin(), name="EnvironmentPlugin")
response = await datasette.client.get("/")
assert response.status_code == 200
assert "Hello museums!" not in response.text
# Try again with the hostname
response2 = await datasette.client.get(
"/", headers={"host": "www.niche-museums.com"}
)
assert response2.status_code == 200
assert "Hello museums!" in response2.text
finally:
pm.unregister(name="EnvironmentPlugin")