mirror of
https://github.com/simonw/datasette.git
synced 2026-09-19 15:04:06 +02:00
Merge branch 'main' into codex/test-write-wrapper-alter-rollback
This commit is contained in:
commit
0d0bb5dd8c
8 changed files with 137 additions and 8 deletions
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ Bug fixes
|
|||
~~~~~~~~~
|
||||
|
||||
- The :ref:`alter-table API <TableAlterView>` now rolls back schema changes when a :ref:`write_wrapper <plugin_hook_write_wrapper>` raises after the write. (:issue:`2924`, :pr:`2925`)
|
||||
- The :ref:`extra_template_vars() <plugin_hook_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 <internals_request>` 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_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 <https://github.com/RamiNoodle733>`__. (:issue:`1681`, :pr:`2876`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <https://jinja.palletsprojects.com/en/2.10.x/api/#async-support>`__, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue