From 11eb1e026f3d84cb771f8d6e204939cbaee130cd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 31 Oct 2020 11:16:28 -0700 Subject: [PATCH] datasette.urls.table(..., format="json"), closes #1035 Also improved tests for datasette.urls and added format= to some other methods --- datasette/app.py | 42 +++++++++++++++++++++++++----------- datasette/utils/__init__.py | 10 +++++---- datasette/views/base.py | 10 +++++---- datasette/views/database.py | 4 ++-- docs/internals.rst | 14 ++++++------ tests/test_internals_urls.py | 42 +++++++++++++++++++++++++++++------- tests/test_utils.py | 10 ++++++--- 7 files changed, 92 insertions(+), 40 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8cff657782..3a06d91154 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -53,6 +53,7 @@ format_bytes, module_from_path, parse_metadata, + path_with_format, resolve_env_secrets, sqlite3, to_css_class, @@ -1285,13 +1286,16 @@ class Urls: def __init__(self, ds): self.ds = ds - def path(self, path): + def path(self, path, format=None): if path.startswith("/"): path = path[1:] - return self.ds.config("base_url") + path + path = self.ds.config("base_url") + path + if format is not None: + path = path_with_format(path=path, format=format) + return path - def instance(self): - return self.path("") + def instance(self, format=None): + return self.path("", format=format) def static(self, path): return self.path("-/static/{}".format(path)) @@ -1302,21 +1306,33 @@ def static_plugins(self, plugin, path): def logout(self): return self.path("-/logout") - def database(self, database): + def database(self, database, format=None): db = self.ds.databases[database] if self.ds.config("hash_urls") and db.hash: - return self.path("{}-{}".format(database, db.hash[:HASH_LENGTH])) + path = self.path( + "{}-{}".format(database, db.hash[:HASH_LENGTH]), format=format + ) else: - return self.path(database) + path = self.path(database, format=format) + return path - def table(self, database, table): - return "{}/{}".format(self.database(database), urllib.parse.quote_plus(table)) + def table(self, database, table, format=None): + path = "{}/{}".format(self.database(database), urllib.parse.quote_plus(table)) + if format is not None: + path = path_with_format(path=path, format=format) + return path - def query(self, database, query): - return "{}/{}".format(self.database(database), urllib.parse.quote_plus(query)) + def query(self, database, query, format=None): + path = "{}/{}".format(self.database(database), urllib.parse.quote_plus(query)) + if format is not None: + path = path_with_format(path=path, format=format) + return path - def row(self, database, table, row_path): - return "{}/{}".format(self.table(database, table), row_path) + def row(self, database, table, row_path, format=None): + path = "{}/{}".format(self.table(database, table), row_path) + if format is not None: + path = path_with_format(path=path, format=format) + return path def row_blob(self, database, table, row_path, column): return self.table(database, table) + "/{}.blob?_blob_column={}".format( diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 33decbfc3e..bf36178416 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -678,9 +678,11 @@ async def resolve_table_and_format( return table_and_format, None -def path_with_format(request, format, extra_qs=None, replace_format=None): +def path_with_format( + *, request=None, path=None, format=None, extra_qs=None, replace_format=None +): qs = extra_qs or {} - path = request.path + path = request.path if request else path if replace_format and path.endswith(".{}".format(replace_format)): path = path[: -(1 + len(replace_format))] if "." in path: @@ -689,11 +691,11 @@ def path_with_format(request, format, extra_qs=None, replace_format=None): path = "{}.{}".format(path, format) if qs: extra = urllib.parse.urlencode(sorted(qs.items())) - if request.query_string: + if request and request.query_string: path = "{}?{}&{}".format(path, request.query_string, extra) else: path = "{}?{}".format(path, extra) - elif request.query_string: + elif request and request.query_string: path = "{}?{}".format(path, request.query_string) return path diff --git a/datasette/views/base.py b/datasette/views/base.py index 6ca78934d8..430489c14d 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -333,8 +333,8 @@ async def stream_fn(r): cell = self.ds.absolute_url( request, path_with_format( - request, - "blob", + request=request, + format="blob", extra_qs={ "_blob_column": column, "_blob_hash": hashlib.sha256( @@ -535,11 +535,13 @@ async def view_get(self, request, database, hash, correct_hash_provided, **kwarg it_can_render = await await_me_maybe(it_can_render) if it_can_render: renderers[key] = path_with_format( - request, key, {**url_labels_extra} + request=request, format=key, extra_qs={**url_labels_extra} ) url_csv_args = {"_size": "max", **url_labels_extra} - url_csv = path_with_format(request, "csv", url_csv_args) + url_csv = path_with_format( + request=request, format="csv", extra_qs=url_csv_args + ) url_csv_path = url_csv.split("?")[0] context = { **data, diff --git a/datasette/views/database.py b/datasette/views/database.py index 8b9e883349..3ed60f4e8a 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -346,8 +346,8 @@ async def extra_template(): ) elif isinstance(display_value, bytes): blob_url = path_with_format( - request, - "blob", + request=request, + format="blob", extra_qs={ "_blob_column": column, "_blob_hash": hashlib.sha256( diff --git a/docs/internals.rst b/docs/internals.rst index 4ebeb983be..ee7fe6e4d7 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -396,10 +396,10 @@ datasette.urls The ``datasette.urls`` object contains methods for building URLs to pages within Datasette. Plugins should use this to link to pages, since these methods take into account any :ref:`config_base_url` configuration setting that might be in effect. -``datasette.urls.instance()`` - Returns the URL to the Datasette instance root page. This is usually ``"/"`` +``datasette.urls.instance(format=None)`` + Returns the URL to the Datasette instance root page. This is usually ``"/"``. -``datasette.urls.path(path)`` +``datasette.urls.path(path, format=None)`` Takes a path and returns the full path, taking ``base_url`` into account. For example, ``datasette.urls.path("-/logout")`` will return the path to the logout page, which will be ``"/-/logout"`` by default or ``/prefix-path/-/logout`` if ``base_url`` is set to ``/prefix-path/`` @@ -423,13 +423,13 @@ The ``datasette.urls`` object contains methods for building URLs to pages within ``datasette.url.static_plugins("datasette_cluster_map", "datasette-cluster-map.js")`` would return ``"/-/static-plugins/datasette_cluster_map/datasette-cluster-map.js"`` -``datasette.urls.database(database_name)`` +``datasette.urls.database(database_name, format=None)`` Returns the URL to a database page, for example ``"/fixtures"`` -``datasette.urls.table(database_name, table_name)`` +``datasette.urls.table(database_name, table_name, format=None)`` Returns the URL to a table page, for example ``"/fixtures/facetable"`` -``datasette.urls.query(database_name, query_name)`` +``datasette.urls.query(database_name, query_name, format=None)`` Returns the URL to a query page, for example ``"/fixtures/pragma_cache_size"`` These functions can be accessed via the ``{{ urls }}`` object in Datasette templates, for example: @@ -441,6 +441,8 @@ These functions can be accessed via the ``{{ urls }}`` object in Datasette templ facetable table pragma_cache_size query +Use the ``format="json"`` (or ``"csv"`` or other formats supported by plugins) arguments to get back URLs to the JSON representation. This is usually the path with ``.json`` added on the end, but it may use ``?_format=json`` in cases where the path already includes ``.json``, for example a URL to a table named ``table.json``. + .. _internals_database: Database class diff --git a/tests/test_internals_urls.py b/tests/test_internals_urls.py index 6498ee4366..005903dfa0 100644 --- a/tests/test_internals_urls.py +++ b/tests/test_internals_urls.py @@ -82,18 +82,44 @@ def test_logout(ds, base_url, expected): @pytest.mark.parametrize( - "base_url,expected", + "base_url,format,expected", + [ + ("/", None, "/:memory:"), + ("/prefix/", None, "/prefix/:memory:"), + ("/", "json", "/:memory:.json"), + ], +) +def test_database(ds, base_url, format, expected): + ds._config["base_url"] = base_url + assert ds.urls.database(":memory:", format=format) == expected + + +@pytest.mark.parametrize( + "base_url,name,format,expected", + [ + ("/", "name", None, "/:memory:/name"), + ("/prefix/", "name", None, "/prefix/:memory:/name"), + ("/", "name", "json", "/:memory:/name.json"), + ("/", "name.json", "json", "/:memory:/name.json?_format=json"), + ], +) +def test_table_and_query(ds, base_url, name, format, expected): + ds._config["base_url"] = base_url + assert ds.urls.table(":memory:", name, format=format) == expected + assert ds.urls.query(":memory:", name, format=format) == expected + + +@pytest.mark.parametrize( + "base_url,format,expected", [ - ("/", "/:memory:"), - ("/prefix/", "/prefix/:memory:"), + ("/", None, "/:memory:/facetable/1"), + ("/prefix/", None, "/prefix/:memory:/facetable/1"), + ("/", "json", "/:memory:/facetable/1.json"), ], ) -def test_database(ds, base_url, expected): +def test_row(ds, base_url, format, expected): ds._config["base_url"] = base_url - assert ds.urls.database(":memory:") == expected - # Do table and query while we are here - assert ds.urls.table(":memory:", "name") == expected + "/name" - assert ds.urls.query(":memory:", "name") == expected + "/name" + assert ds.urls.row(":memory:", "facetable", "1", format=format) == expected @pytest.mark.parametrize("base_url", ["/", "/prefix/"]) diff --git a/tests/test_utils.py b/tests/test_utils.py index bae3b6859b..2d2ff52da6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -382,15 +382,19 @@ def test_table_columns(): ) def test_path_with_format(path, format, extra_qs, expected): request = Request.fake(path) - actual = utils.path_with_format(request, format, extra_qs) + actual = utils.path_with_format(request=request, format=format, extra_qs=extra_qs) assert expected == actual def test_path_with_format_replace_format(): request = Request.fake("/foo/bar.csv") - assert utils.path_with_format(request, "blob") == "/foo/bar.csv?_format=blob" assert ( - utils.path_with_format(request, "blob", replace_format="csv") == "/foo/bar.blob" + utils.path_with_format(request=request, format="blob") + == "/foo/bar.csv?_format=blob" + ) + assert ( + utils.path_with_format(request=request, format="blob", replace_format="csv") + == "/foo/bar.blob" )