From 6add534c65f90aa69f1736e7462d87c2b9c83979 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 10:30:14 -0700 Subject: [PATCH 1/6] Implemented custom pages from pages/ in templates, refs #648 --- datasette/app.py | 18 ++++++++++++++++-- datasette/utils/asgi.py | 4 +++- datasette/views/base.py | 1 + tests/test_html.py | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 2a5d569e..2d02e705 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -17,6 +17,7 @@ from markupsafe import Markup import jinja2 from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape from jinja2.environment import Template +from jinja2.exceptions import TemplateNotFound import uvicorn from .views.base import DatasetteError, ureg, AsgiRouter @@ -745,7 +746,7 @@ class DatasetteRouter(AsgiRouter): path = "/" + path[len(base_url) :] return await super().route_path(scope, receive, send, path) - async def handle_404(self, scope, receive, send): + async def handle_404(self, scope, receive, send, exception=None): # If URL has a trailing slash, redirect to URL without it path = scope.get("raw_path", scope["path"].encode("utf8")) if path.endswith(b"/"): @@ -754,7 +755,20 @@ class DatasetteRouter(AsgiRouter): path += b"?" + scope["query_string"] await asgi_send_redirect(send, path.decode("latin1")) else: - await super().handle_404(scope, receive, send) + # Is there a pages/* template matching this path? + template_path = os.path.join("pages", *scope["path"].split("/")) + ".html" + try: + template = self.ds.jinja_env.select_template([template_path]) + except TemplateNotFound: + template = None + if template: + await asgi_send_html( + send, await self.ds.render_template(template), status=200 + ) + else: + await self.handle_500( + scope, receive, send, exception or NotFound("404") + ) async def handle_500(self, scope, receive, send, exception): title = None diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 817c7dd8..ed3138d0 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -119,11 +119,13 @@ class AsgiRouter: new_scope = dict(scope, url_route={"kwargs": match.groupdict()}) try: return await view(new_scope, receive, send) + except NotFound as exception: + return await self.handle_404(scope, receive, send, exception) except Exception as exception: return await self.handle_500(scope, receive, send, exception) return await self.handle_404(scope, receive, send) - async def handle_404(self, scope, receive, send): + async def handle_404(self, scope, receive, send, exception=None): await send( { "type": "http.response.start", diff --git a/datasette/views/base.py b/datasette/views/base.py index 2478bd84..c81af938 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -28,6 +28,7 @@ from datasette.utils.asgi import ( AsgiRouter, AsgiView, NotFound, + NotFound, Response, ) diff --git a/tests/test_html.py b/tests/test_html.py index 1b675d3c..785d2d97 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1241,3 +1241,20 @@ def test_base_url_config(base_url, path): "href_or_src": href, "element_parent": str(el.parent), } + + +def test_custom_template_page(tmpdir): + template_dir = tmpdir.mkdir("page-templates") + pages_dir = template_dir.mkdir("pages") + (pages_dir / "about.html").write_text("ABOUT!", "utf-8") + nested_dir = pages_dir.mkdir("nested") + (nested_dir / "nest.html").write_text("Nest!", "utf-8") + for client in make_app_client(template_dir=str(template_dir)): + response = client.get("/about") + assert 200 == response.status + assert "ABOUT!" == response.text + response = client.get("/nested/nest") + assert 200 == response.status + assert "Nest!" == response.text + response = client.get("/nested/nest2") + assert 404 == response.status From b5509a0b0aa2bc5a4e31e3fde3fea321284ec0e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 10:49:57 -0700 Subject: [PATCH 2/6] Pass view_name=page to custom pages, refs #648 --- datasette/app.py | 4 +++- tests/fixtures.py | 3 +++ tests/test_html.py | 24 ++++++++++++++++++++---- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 2d02e705..354cf5e2 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -763,7 +763,9 @@ class DatasetteRouter(AsgiRouter): template = None if template: await asgi_send_html( - send, await self.ds.render_template(template), status=200 + send, + await self.ds.render_template(template, view_name="page"), + status=200, ) else: await self.handle_500( diff --git a/tests/fixtures.py b/tests/fixtures.py index 8459fd4f..0284ff9c 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -109,6 +109,7 @@ def make_app_client( inspect_data=None, static_mounts=None, template_dir=None, + extra_plugins=None, ): with tempfile.TemporaryDirectory() as tmpdir: filepath = os.path.join(tmpdir, filename) @@ -133,6 +134,8 @@ def make_app_client( os.mkdir(plugins_dir) open(os.path.join(plugins_dir, "my_plugin.py"), "w").write(PLUGIN1) open(os.path.join(plugins_dir, "my_plugin_2.py"), "w").write(PLUGIN2) + for filename, content in (extra_plugins or {}).items(): + open(os.path.join(plugins_dir, filename), "w").write(content) config = config or {} config.update( { diff --git a/tests/test_html.py b/tests/test_html.py index 785d2d97..10ed8e28 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -11,6 +11,7 @@ import json import pathlib import pytest import re +import textwrap import urllib.parse @@ -1243,16 +1244,31 @@ def test_base_url_config(base_url, path): } -def test_custom_template_page(tmpdir): +def test_custom_pages(tmpdir): template_dir = tmpdir.mkdir("page-templates") + extra_plugins = { + "view_name.py": textwrap.dedent( + """ + from datasette import hookimpl + + @hookimpl + def extra_template_vars(view_name): + return { + "view_name": view_name, + } + """ + ) + } pages_dir = template_dir.mkdir("pages") - (pages_dir / "about.html").write_text("ABOUT!", "utf-8") + (pages_dir / "about.html").write_text("ABOUT! view_name:{{ view_name }}", "utf-8") nested_dir = pages_dir.mkdir("nested") (nested_dir / "nest.html").write_text("Nest!", "utf-8") - for client in make_app_client(template_dir=str(template_dir)): + for client in make_app_client( + template_dir=str(template_dir), extra_plugins=extra_plugins + ): response = client.get("/about") assert 200 == response.status - assert "ABOUT!" == response.text + assert "ABOUT! view_name:page" == response.text response = client.get("/nested/nest") assert 200 == response.status assert "Nest!" == response.text From 547559061200de9051bcf85bb39284c7386c3f8e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 11:19:54 -0700 Subject: [PATCH 3/6] custom_header(), custom_status(), custom_redirect(), refs #648 --- datasette/app.py | 38 ++++++++++++++++-- tests/test_custom_pages.py | 80 ++++++++++++++++++++++++++++++++++++++ tests/test_html.py | 32 --------------- 3 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 tests/test_custom_pages.py diff --git a/datasette/app.py b/datasette/app.py index 354cf5e2..baddfc9f 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -762,10 +762,42 @@ class DatasetteRouter(AsgiRouter): except TemplateNotFound: template = None if template: - await asgi_send_html( + headers = {} + status = [200] + + def custom_header(name, value): + headers[name] = value + return "" + + def custom_status(code): + status[0] = code + return "" + + def custom_redirect(location, code=302): + status[0] = code + headers["Location"] = location + return "" + + body = await self.ds.render_template( + template, + { + "custom_header": custom_header, + "custom_status": custom_status, + "custom_redirect": custom_redirect, + }, + view_name="page", + ) + # Pull content-type out into separate parameter + content_type = "text/html" + matches = [k for k in headers if k.lower() == "content-type"] + if matches: + content_type = headers[matches[0]] + await asgi_send( send, - await self.ds.render_template(template, view_name="page"), - status=200, + body, + status=status[0], + headers=headers, + content_type=content_type, ) else: await self.handle_500( diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py new file mode 100644 index 00000000..bd2c427b --- /dev/null +++ b/tests/test_custom_pages.py @@ -0,0 +1,80 @@ +import pytest +from .fixtures import make_app_client + +VIEW_NAME_PLUGIN = """ +from datasette import hookimpl + +@hookimpl +def extra_template_vars(view_name): + return { + "view_name": view_name, + } +""" + + +@pytest.fixture(scope="session") +def custom_pages_client(tmp_path_factory): + template_dir = tmp_path_factory.mktemp("page-templates") + extra_plugins = {"view_name.py": VIEW_NAME_PLUGIN} + pages_dir = template_dir / "pages" + pages_dir.mkdir() + (pages_dir / "about.html").write_text("ABOUT! view_name:{{ view_name }}", "utf-8") + (pages_dir / "202.html").write_text("{{ custom_status(202) }}202!", "utf-8") + (pages_dir / "headers.html").write_text( + '{{ custom_header("x-this-is-foo", "foo") }}FOO' + '{{ custom_header("x-this-is-bar", "bar") }}BAR', + "utf-8", + ) + (pages_dir / "redirect.html").write_text( + '{{ custom_redirect("/example") }}', "utf-8" + ) + (pages_dir / "redirect2.html").write_text( + '{{ custom_redirect("/example", 301) }}', "utf-8" + ) + nested_dir = pages_dir / "nested" + nested_dir.mkdir() + (nested_dir / "nest.html").write_text("Nest!", "utf-8") + for client in make_app_client( + template_dir=str(template_dir), extra_plugins=extra_plugins + ): + yield client + + +def test_custom_pages_view_name(custom_pages_client): + response = custom_pages_client.get("/about") + assert 200 == response.status + assert "ABOUT! view_name:page" == response.text + + +def test_custom_pages_nested(custom_pages_client): + response = custom_pages_client.get("/nested/nest") + assert 200 == response.status + assert "Nest!" == response.text + response = custom_pages_client.get("/nested/nest2") + assert 404 == response.status + + +def test_custom_status(custom_pages_client): + response = custom_pages_client.get("/202") + assert 202 == response.status + assert "202!" == response.text + + +def test_custom_headers(custom_pages_client): + response = custom_pages_client.get("/headers") + assert 200 == response.status + assert "foo" == response.headers["x-this-is-foo"] + assert "bar" == response.headers["x-this-is-bar"] + assert "FOOBAR" == response.text + + +def test_redirect(custom_pages_client): + response = custom_pages_client.get("/redirect", allow_redirects=False) + assert 302 == response.status + assert "/example" == response.headers["Location"] + + +def test_redirect2(custom_pages_client): + response = custom_pages_client.get("/redirect2", allow_redirects=False) + assert 301 == response.status + assert "/example" == response.headers["Location"] diff --git a/tests/test_html.py b/tests/test_html.py index 10ed8e28..b8dc543c 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1242,35 +1242,3 @@ def test_base_url_config(base_url, path): "href_or_src": href, "element_parent": str(el.parent), } - - -def test_custom_pages(tmpdir): - template_dir = tmpdir.mkdir("page-templates") - extra_plugins = { - "view_name.py": textwrap.dedent( - """ - from datasette import hookimpl - - @hookimpl - def extra_template_vars(view_name): - return { - "view_name": view_name, - } - """ - ) - } - pages_dir = template_dir.mkdir("pages") - (pages_dir / "about.html").write_text("ABOUT! view_name:{{ view_name }}", "utf-8") - nested_dir = pages_dir.mkdir("nested") - (nested_dir / "nest.html").write_text("Nest!", "utf-8") - for client in make_app_client( - template_dir=str(template_dir), extra_plugins=extra_plugins - ): - response = client.get("/about") - assert 200 == response.status - assert "ABOUT! view_name:page" == response.text - response = client.get("/nested/nest") - assert 200 == response.status - assert "Nest!" == response.text - response = client.get("/nested/nest2") - assert 404 == response.status From f56022ac9a6781dedc302e4bac63593522f5d1c0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 11:20:43 -0700 Subject: [PATCH 4/6] Removed duplicate import --- datasette/views/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index c81af938..2478bd84 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -28,7 +28,6 @@ from datasette.utils.asgi import ( AsgiRouter, AsgiView, NotFound, - NotFound, Response, ) From cea6dd43ef847a4b91d858c1b7639b256b2979f4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 11:35:36 -0700 Subject: [PATCH 5/6] Custom content-type test --- tests/test_custom_pages.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index bd2c427b..871dd69c 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -25,6 +25,9 @@ def custom_pages_client(tmp_path_factory): '{{ custom_header("x-this-is-bar", "bar") }}BAR', "utf-8", ) + (pages_dir / "atom.html").write_text( + '{{ custom_header("content-type", "application/xml") }}', "utf-8", + ) (pages_dir / "redirect.html").write_text( '{{ custom_redirect("/example") }}', "utf-8" ) @@ -68,6 +71,13 @@ def test_custom_headers(custom_pages_client): assert "FOOBAR" == response.text +def test_custom_content_type(custom_pages_client): + response = custom_pages_client.get("/atom") + assert 200 == response.status + assert response.headers["content-type"] == "application/xml" + assert "" == response.text + + def test_redirect(custom_pages_client): response = custom_pages_client.get("/redirect", allow_redirects=False) assert 302 == response.status From b5bbf1bd88f67a26eb34902bc11f2da7692c7af2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Apr 2020 11:44:37 -0700 Subject: [PATCH 6/6] Documentation for custom pages, refs #648 --- docs/custom_templates.rst | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index 59f09138..4a300e82 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -265,3 +265,64 @@ Here is an example of a custom ``_table.html`` template::

Category: {{ row.display("category_id") }}

{% endfor %} + +.. _custom_pages: + +Custom pages +------------ + +You can add templated pages to your Datasette instance by creating HTML files in a ``pages`` directory within your ``templates`` directory. + +For example, to add a custom page that is served at ``http://localhost/about`` you would create a file in ``templates/pages/about.html``, then start Datasette like this:: + + $ datasette mydb.db --template-dir=templates/ + +You can nest directories within pages to create a nested structure. To create a ``http://localhost:8001/about/map`` page you would create ``templates/pages/about/map.html``. + +Custom headers and status codes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Custom pages default to being served with a content-type of ``text/html`` and a ``200`` status code. You can change these by calling a custom function from within your template. + +For example, to serve a custom page with a ``418 I'm a teapot`` HTTP status code, create a file in ``pages/teapot.html`` containing the following:: + + {{ custom_status(418) }} + + Teapot + + I'm a teapot + + + +To serve a custom HTTP header, add a ``custom_header(name, value)`` function call. For example:: + + {{ custom_status(418) }} + {{ custom_header("x-teapot", "I am") }} + + Teapot + + I'm a teapot + + + +You can verify this is working using ``curl`` like this:: + + $ curl -I 'http://127.0.0.1:8001/teapot' + HTTP/1.1 418 + date: Sun, 26 Apr 2020 18:38:30 GMT + server: uvicorn + x-teapot: I am + content-type: text/html + +Custom redirects +~~~~~~~~~~~~~~~~ + +You can use the ``custom_redirect(location)`` function to redirect users to another page, for example in a file called ``pages/datasette.html``:: + + {{ custom_redirect("https://github.com/simonw/datasette") }} + +Now requests to ``http://localhost:8001/datasette`` will result in a redirect. + +These redirects are served with a ``301 Found`` status code by default. You can send a ``301 Moved Permanently`` code by passing ``301`` as the second argument to the function:: + + {{ custom_redirect("https://github.com/simonw/datasette", 301) }}