From df4c0fde0f96c58fd6f4c61f53bb668b47e31ebd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 16 Sep 2026 10:27:35 -0700 Subject: [PATCH 1/2] Make request header lookups case-insensitive Closes #1861 --- datasette/utils/asgi.py | 19 +++++++++++--- docs/changelog.rst | 1 + docs/internals.rst | 2 +- tests/test_internals_request.py | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 6dac071b..2d4a6cff 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -83,6 +83,19 @@ SAMESITE_VALUES = ("strict", "lax", "none") DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB +class _RequestHeaders(dict): + """Incoming headers with lowercase keys and case-insensitive lookups.""" + + def __getitem__(self, key): + return super().__getitem__(key.lower()) + + def get(self, key, default=None): + return super().get(key.lower(), default) + + def __contains__(self, key): + return super().__contains__(key.lower()) + + class Request: def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES): self.scope = scope @@ -112,10 +125,10 @@ class Request: @property def headers(self): - return { - k.decode("latin-1").lower(): v.decode("latin-1") + return _RequestHeaders( + (k.decode("latin-1").lower(), v.decode("latin-1")) for k, v in self.scope.get("headers") or [] - } + ) @property def host(self): diff --git a/docs/changelog.rst b/docs/changelog.rst index a22f2254..5ffa0cd3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ Datasette plugins can now use **background tasks** to run code independent of th Bug fixes ~~~~~~~~~ +- :ref:`request.headers ` now supports case-insensitive header lookups, so ``request.headers.get("Content-Type")`` works as well as ``request.headers.get("content-type")``. (:issue:`1861`) - CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`) - The :ref:`render_cell() ` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`) - Numeric comparison filters now correctly handle decimal values, negative numbers and scientific notation when filtering computed columns and SQL views. Thanks, `Rami Abdelrazzaq `__. (:issue:`1681`, :pr:`2876`) diff --git a/docs/internals.rst b/docs/internals.rst index 8607e6b8..f2b0622d 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -26,7 +26,7 @@ The request object is passed to various plugin hooks. It represents an incoming The request scheme - usually ``https`` or ``http``. ``.headers`` - dictionary (str -> str) - A dictionary of incoming HTTP request headers. Header names have been converted to lowercase. + A dictionary of incoming HTTP request headers. Header lookups using ``request.headers["Content-Type"]``, ``request.headers.get("Content-Type")`` and ``"Content-Type" in request.headers`` are case-insensitive. Header names are lowercase when iterating over the dictionary. ``.cookies`` - dictionary (str -> str) A dictionary of incoming cookies diff --git a/tests/test_internals_request.py b/tests/test_internals_request.py index e982628b..91ef368f 100644 --- a/tests/test_internals_request.py +++ b/tests/test_internals_request.py @@ -35,6 +35,51 @@ def _receive_chunks(chunks): return receive +@pytest.mark.parametrize( + "header_name", [b"content-type", b"Content-Type", b"CONTENT-TYPE"] +) +@pytest.mark.parametrize("lookup", ["content-type", "Content-Type", "CONTENT-TYPE"]) +def test_request_headers_case_insensitive(header_name, lookup): + request = Request({"headers": [(header_name, b"application/json")]}, None) + assert request.headers.get(lookup) == "application/json" + assert request.headers[lookup] == "application/json" + assert lookup in request.headers + + +def test_request_headers_mapping(): + request = Request( + { + "headers": [ + (b"Content-Type", b"application/json"), + (b"X-Title", "café".encode("latin-1")), + (b"CONTENT-TYPE", b"text/plain"), + ] + }, + None, + ) + headers = request.headers + expected = {"content-type": "text/plain", "x-title": "café"} + assert headers == expected + assert dict(headers) == expected + assert list(headers) == list(expected) + assert list(headers.keys()) == list(expected.keys()) + assert list(headers.items()) == list(expected.items()) + assert json.loads(json.dumps(headers)) == expected + assert headers["Content-Type"] == "text/plain" + assert headers["X-Title"] == "café" + + +@pytest.mark.parametrize("scope", [{}, {"headers": None}, {"headers": []}]) +def test_request_headers_missing(scope): + headers = Request(scope, None).headers + assert headers == {} + assert headers.get("Content-Type") is None + assert headers.get("Content-Type", "default") == "default" + assert "Content-Type" not in headers + with pytest.raises(KeyError): + headers["Content-Type"] + + @pytest.mark.asyncio async def test_request_post_vars(): scope = { From 6dd5297b342916396bd34d592e5a80ee9bc739fa Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 16 Sep 2026 10:30:11 -0700 Subject: [PATCH 2/2] Allow extra_template_vars to resolve to None Closes #2005 --- datasette/app.py | 2 ++ datasette/hookspecs.py | 2 +- docs/changelog.rst | 1 + docs/plugin_hooks.rst | 7 +++-- tests/test_plugins.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index e6e3410e..a6998bef 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2587,6 +2587,8 @@ ORDER BY allowed.parent, allowed.child datasette=self, ): extra_vars = await await_me_maybe(extra_vars) + if extra_vars is None: + continue assert isinstance( extra_vars, dict ), f"extra_vars is of type {type(extra_vars)}" diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 0b807f8c..49d8e8ea 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -50,7 +50,7 @@ def extra_body_script( def extra_template_vars( template, database, table, columns, view_name, request, datasette ): - """Extra template variables to be made available to the template - can return dict or callable or awaitable""" + """Extra template variables to be made available to the template - can return dict, None, callable or awaitable""" @hookspec diff --git a/docs/changelog.rst b/docs/changelog.rst index 5ffa0cd3..eb7b1073 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ Datasette plugins can now use **background tasks** to run code independent of th Bug fixes ~~~~~~~~~ +- The :ref:`extra_template_vars() ` plugin hook can now return a function or awaitable that resolves to ``None`` when no extra variables are needed. (:issue:`2005`) - :ref:`request.headers ` now supports case-insensitive header lookups, so ``request.headers.get("Content-Type")`` works as well as ``request.headers.get("content-type")``. (:issue:`1861`) - CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`) - The :ref:`render_cell() ` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 8a9ce5da..3efff7a4 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -217,7 +217,7 @@ Extra template variables that should be made available in the rendered template ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)`` -This hook can return one of three different types: +This hook supports the following return values: Dictionary If you return a dictionary its keys and values will be merged into the template context. @@ -228,6 +228,9 @@ Function that returns a dictionary Function that returns an awaitable function that returns a dictionary You can also return a function which returns an awaitable function which returns a dictionary. +``None`` + The hook itself, or a function or awaitable it returns, can return ``None`` when no extra variables are needed. Variables returned by other plugins are still included. + Datasette runs Jinja2 in `async mode `__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template. .. warning:: @@ -254,8 +257,6 @@ This example returns an awaitable function which adds a list of ``hidden_table_n return { "hidden_table_names": await db.hidden_table_names() } - else: - return {} return hidden_table_names diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 69d1f953..1084d270 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -429,6 +429,72 @@ def test_hook_extra_template_vars(restore_working_directory): } == extra_template_vars_from_awaitable +@pytest.mark.asyncio +@pytest.mark.parametrize( + "return_style", ["direct", "callable", "async_callable", "awaitable"] +) +async def test_hook_extra_template_vars_none(ds_client, return_style): + class OtherPlugin: + @hookimpl + def extra_template_vars(self): + return {"other": "present"} + + class ConditionalPlugin: + @hookimpl + def extra_template_vars(self, view_name): + def inner(): + if view_name == "database": + return {"conditional": "database"} + + async def async_inner(): + return inner() + + if return_style == "direct": + return inner() + elif return_style == "callable": + return inner + elif return_style == "async_callable": + return async_inner + else: + return async_inner() + + other_plugin = OtherPlugin() + conditional_plugin = ConditionalPlugin() + pm.register(other_plugin) + pm.register(conditional_plugin) + try: + template = ds_client.ds.get_jinja_environment().from_string( + "{{ other }}:{{ conditional|default('missing') }}" + ) + for view_name, expected in ( + ("database", "present:database"), + ("index", "present:missing"), + ): + rendered = await ds_client.ds.render_template(template, view_name=view_name) + assert rendered == expected + finally: + pm.unregister(conditional_plugin) + pm.unregister(other_plugin) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_value", [False, 0, "", [], ()]) +async def test_hook_extra_template_vars_invalid(ds_client, invalid_value): + class InvalidPlugin: + @hookimpl + def extra_template_vars(self): + return lambda: invalid_value + + plugin = InvalidPlugin() + pm.register(plugin) + try: + template = ds_client.ds.get_jinja_environment().from_string("test") + with pytest.raises(AssertionError, match="extra_vars is of type"): + await ds_client.ds.render_template(template) + finally: + pm.unregister(plugin) + + def test_plugins_async_template_function(restore_working_directory): with make_app_client( template_dir=str(pathlib.Path(__file__).parent / "test_templates")