From cf6357cf93eade6eb19cde582403b2b598af1920 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:44:50 +0000 Subject: [PATCH] path and full_path arguments for output renderers, closes #1179 Output renderer render() and can_render() callbacks can now accept path and full_path arguments - the path to the HTML version of the page without the .extension, with and without the query string. Also fixed path_with_format() so it replaces the extension the current request used instead of falling back to ?_format=, which Datasette no longer reads. This broke links generated by renderers such as datasette-export-notebook, and the URLs returned by ?_extra=renderers on .json pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TzcVp1Cq5yCRW81GiN88JX --- datasette/utils/__init__.py | 19 +++++-- datasette/views/database.py | 5 ++ datasette/views/row.py | 5 ++ datasette/views/table.py | 3 ++ datasette/views/table_extras.py | 5 ++ docs/json_api.rst | 2 +- docs/plugin_hooks.rst | 6 +++ tests/plugins/register_output_renderer.py | 29 ++++++++++- tests/test_plugins.py | 63 +++++++++++++++++++++++ tests/test_utils.py | 50 ++++++++++++++++++ 10 files changed, 180 insertions(+), 7 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 669d04c7..66243051 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1004,12 +1004,12 @@ def path_with_format( qs = extra_qs or {} if path is None and request: path = request.path + if replace_format is None and request: + # Drop the extension the current request used, e.g. .json + replace_format = request.url_vars.get("format") if replace_format and path.endswith(f".{replace_format}"): path = path[: -(1 + len(replace_format))] - if "." in path: - qs["_format"] = format - else: - path = f"{path}.{format}" + path = f"{path}.{format}" if qs: extra = urllib.parse.urlencode(sorted(qs.items())) if request and request.query_string: @@ -1021,6 +1021,17 @@ def path_with_format( return path +def path_without_format(request, *, include_query_string=False): + "Path for this request with any ``.format`` removed, e.g. ``/db/table.json`` -> ``/db/table``" + path = request.path + format = request.url_vars.get("format") + if format and path.endswith(f".{format}"): + path = path[: -(1 + len(format))] + if include_query_string and request.query_string: + path = f"{path}?{request.query_string}" + return path + + class CustomRow(OrderedDict): # Loose imitation of sqlite3.Row which offers # both index-based AND key-based lookups diff --git a/datasette/views/database.py b/datasette/views/database.py index da207601..35b3ea82 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -26,6 +26,7 @@ from datasette.utils import ( path_with_added_args, path_with_format, path_with_removed_args, + path_without_format, sqlite3, tilde_decode, to_css_class, @@ -902,6 +903,8 @@ class QueryView(View): database=database, table=None, request=request, + path=path_without_format(request), + full_path=path_without_format(request, include_query_string=True), view_name="table", truncated=results.truncated if results else False, error=query_error, @@ -975,6 +978,8 @@ class QueryView(View): database=database, table=data.get("table"), request=request, + path=path_without_format(request), + full_path=path_without_format(request, include_query_string=True), view_name="database", ) it_can_render = await await_me_maybe(it_can_render) diff --git a/datasette/views/row.py b/datasette/views/row.py index 92e75199..b7b9d8cb 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -27,6 +27,7 @@ from datasette.utils import ( path_from_row_pks, path_with_format, path_with_removed_args, + path_without_format, sqlite3, tilde_decode, to_css_class, @@ -235,6 +236,8 @@ class RowView(BaseView): database=database, table=data.get("table"), request=request, + path=path_without_format(request), + full_path=path_without_format(request, include_query_string=True), view_name=self.name, truncated=False, # TODO: support this error=data.get("error"), @@ -298,6 +301,8 @@ class RowView(BaseView): database=data.get("database"), table=data.get("table"), request=request, + path=path_without_format(request), + full_path=path_without_format(request, include_query_string=True), view_name=self.name, ) it_can_render = await await_me_maybe(it_can_render) diff --git a/datasette/views/table.py b/datasette/views/table.py index 0a908c69..6a52dea0 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -43,6 +43,7 @@ from datasette.utils import ( path_with_format, path_with_removed_args, path_with_replaced_args, + path_without_format, sqlite3, tilde_encode, to_css_class, @@ -1856,6 +1857,8 @@ async def table_view_traced(datasette, request): database=resolved.db.name, table=resolved.table, request=request, + path=path_without_format(request), + full_path=path_without_format(request, include_query_string=True), view_name="table", truncated=False, error=None, diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index 6f3437d5..fc849785 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -13,6 +13,7 @@ from datasette.utils import ( path_with_added_args, path_with_format, path_with_removed_args, + path_without_format, to_css_class, ) @@ -1130,6 +1131,10 @@ class RenderersExtra(Extra): database=context.database_name, table=table_name, request=context.request, + path=path_without_format(context.request), + full_path=path_without_format( + context.request, include_query_string=True + ), view_name=view_name, ) it_can_render = await await_me_maybe(it_can_render) diff --git a/docs/json_api.rst b/docs/json_api.rst index 73212e70..d147de04 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -753,7 +753,7 @@ The available table extras are listed below. .. code-block:: json { - "json": "/fixtures/facetable.json?_extra=renderers&_format=json&_labels=on" + "json": "/fixtures/facetable.json?_extra=renderers&_labels=on" } ``custom_table_templates`` diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 3efff7a4..2f5fb1f7 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -625,6 +625,12 @@ When a request is received, the ``"render"`` callback function is called with ze ``request`` - :ref:`internals_request` The current HTTP request. +``path`` - string + The path to the HTML version of this page, without the ``.extension`` - for example ``/fixtures/facetable`` for a request to ``/fixtures/facetable.test``. This includes the :ref:`setting_base_url` prefix if one is configured. + +``full_path`` - string + The same as ``path`` but with the query string included, if there was one - for example ``/fixtures/facetable?_sort=pk``. Use ``datasette.absolute_url(request, full_path)`` to turn this into a full URL. + ``error`` - string or None If an error occurred this string will contain the error message. diff --git a/tests/plugins/register_output_renderer.py b/tests/plugins/register_output_renderer.py index 671f2d1e..68fe1186 100644 --- a/tests/plugins/register_output_renderer.py +++ b/tests/plugins/register_output_renderer.py @@ -5,7 +5,17 @@ from datasette.utils.asgi import Response async def can_render( - datasette, columns, rows, sql, query_name, database, table, request, view_name + datasette, + columns, + rows, + sql, + query_name, + database, + table, + request, + path, + full_path, + view_name, ): # We stash this on datasette so the calling unit test can see it datasette._can_render_saw = { @@ -17,13 +27,26 @@ async def can_render( "database": database, "table": table, "request": request, + "path": path, + "full_path": full_path, "view_name": view_name, } return not request.args.get("_no_can_render") async def render_test_all_parameters( - datasette, columns, rows, sql, query_name, database, table, request, view_name, data + datasette, + columns, + rows, + sql, + query_name, + database, + table, + request, + path, + full_path, + view_name, + data, ): headers = {} for custom_header in request.args.getlist("header"): @@ -41,6 +64,8 @@ async def render_test_all_parameters( "database": database, "table": table, "request": request, + "path": path, + "full_path": full_path, "view_name": view_name, "1+1": result.first()[0], }, diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1084d270..37316d85 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -624,11 +624,50 @@ async def test_hook_register_output_renderer_all_parameters(ds_client): "database": "fixtures", "table": "facetable", "request": '', + "path": "/fixtures/facetable", + "full_path": "/fixtures/facetable", "view_name": "table", "1+1": 2, } +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_path,expected_full_path", + ( + ( + "/fixtures/facetable.testall?_sort_desc=pk&_size=2", + "/fixtures/facetable", + "/fixtures/facetable?_sort_desc=pk&_size=2", + ), + ( + "/fixtures/simple_primary_key/1.testall", + "/fixtures/simple_primary_key/1", + "/fixtures/simple_primary_key/1", + ), + ( + "/fixtures/-/query.testall?sql=select+1", + "/fixtures/-/query", + "/fixtures/-/query?sql=select+1", + ), + ( + "/fixtures/pragma_cache_size.testall", + "/fixtures/pragma_cache_size", + "/fixtures/pragma_cache_size", + ), + ), +) +async def test_hook_register_output_renderer_path_and_full_path( + ds_client, path, expected_path, expected_full_path +): + # https://github.com/simonw/datasette/issues/1179 + response = await ds_client.get(path) + assert response.status_code == 200 + data = json.loads(response.text) + assert data["path"] == expected_path + assert data["full_path"] == expected_full_path + + @pytest.mark.asyncio async def test_hook_register_output_renderer_custom_status_code(ds_client): response = await ds_client.get( @@ -701,10 +740,34 @@ async def test_hook_register_output_renderer_can_render(ds_client): "query_name": None, "database": "fixtures", "table": "facetable", + "path": "/fixtures/facetable", + "full_path": "/fixtures/facetable?_no_can_render=1", "view_name": "table", }.items() <= ds_client.ds._can_render_saw.items() +@pytest.mark.asyncio +async def test_hook_register_output_renderer_renderers_extra_links(ds_client): + # https://github.com/simonw/datasette/issues/1179 + # Links from a .json page previously came out as + # /fixtures/facetable.json?_format=testall - which returned JSON + response = await ds_client.get("/fixtures/facetable.json?_extra=renderers") + assert response.status_code == 200 + renderers = response.json()["renderers"] + assert renderers["json"] == "/fixtures/facetable.json?_extra=renderers&_labels=on" + assert ( + renderers["testall"] + == "/fixtures/facetable.testall?_extra=renderers&_labels=on" + ) + saw = ds_client.ds._can_render_saw + assert saw["path"] == "/fixtures/facetable" + assert saw["full_path"] == "/fixtures/facetable?_extra=renderers" + # Following the link should run the renderer + response = await ds_client.get(renderers["testall"]) + assert response.status_code == 200 + assert response.headers["content-type"] == "text/plain" + + @pytest.mark.asyncio async def test_hook_register_output_renderer_can_render_canned_query(ds_client): # https://github.com/simonw/datasette/issues/2711 diff --git a/tests/test_utils.py b/tests/test_utils.py index 68d4a504..286bb90c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -578,6 +578,56 @@ def test_path_with_format_can_override_request_path(): assert "/foo.json?x=1" == actual +@pytest.mark.parametrize( + "path,url_vars,expected", + [ + # https://github.com/simonw/datasette/issues/1179 + ("/db/table.Notebook", {"format": "Notebook"}, "/db/table.json"), + ("/db/table.csv?_size=max", {"format": "csv"}, "/db/table.json?_size=max"), + ("/db/table", {}, "/db/table.json"), + ], +) +def test_path_with_format_replaces_current_format(path, url_vars, expected): + request = Request.fake(path, url_vars=url_vars) + assert utils.path_with_format(request=request, format="json") == expected + + +def test_path_with_format_replaces_current_format_with_path_override(): + request = Request.fake("/prefix/db/table.csv?x=1", url_vars={"format": "csv"}) + actual = utils.path_with_format( + request=request, path="/db/table.csv", format="json" + ) + assert actual == "/db/table.json?x=1" + + +@pytest.mark.parametrize( + "path,url_vars,expected_path,expected_full_path", + [ + ("/db/table", {}, "/db/table", "/db/table"), + ("/db/table.json", {"format": "json"}, "/db/table", "/db/table"), + ( + "/db/table.Notebook?_sort=pk", + {"format": "Notebook"}, + "/db/table", + "/db/table?_sort=pk", + ), + ( + "/prefix/db/-/query.csv?sql=select+1", + {"format": "csv"}, + "/prefix/db/-/query", + "/prefix/db/-/query?sql=select+1", + ), + ], +) +def test_path_without_format(path, url_vars, expected_path, expected_full_path): + request = Request.fake(path, url_vars=url_vars) + assert utils.path_without_format(request) == expected_path + assert ( + utils.path_without_format(request, include_query_string=True) + == expected_full_path + ) + + @pytest.mark.parametrize( "bytes,expected", [