'datasette --get' option, closes #926

Also made a start on the datasette.utils.testing module, refs #898
pull/936/head
Simon Willison 2020-08-11 17:24:40 -07:00 zatwierdzone przez GitHub
rodzic 83eda049af
commit e139a7619f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 242 dodań i 160 usunięć

Wyświetl plik

@ -21,6 +21,7 @@ from .utils import (
StaticMount, StaticMount,
ValueAsBooleanError, ValueAsBooleanError,
) )
from .utils.testing import TestClient
class Config(click.ParamType): class Config(click.ParamType):
@ -335,6 +336,9 @@ def uninstall(packages, yes):
help="Output URL that sets a cookie authenticating the root user", help="Output URL that sets a cookie authenticating the root user",
is_flag=True, is_flag=True,
) )
@click.option(
"--get", help="Run an HTTP GET request against this path, print results and exit",
)
@click.option("--version-note", help="Additional note to show on /-/versions") @click.option("--version-note", help="Additional note to show on /-/versions")
@click.option("--help-config", is_flag=True, help="Show available config options") @click.option("--help-config", is_flag=True, help="Show available config options")
def serve( def serve(
@ -355,6 +359,7 @@ def serve(
config, config,
secret, secret,
root, root,
get,
version_note, version_note,
help_config, help_config,
return_instance=False, return_instance=False,
@ -411,6 +416,12 @@ def serve(
ds = Datasette(files, **kwargs) ds = Datasette(files, **kwargs)
if get:
client = TestClient(ds.app())
response = client.get(get)
click.echo(response.text)
return
if return_instance: if return_instance:
# Private utility mechanism for writing unit tests # Private utility mechanism for writing unit tests
return ds return ds

Wyświetl plik

@ -0,0 +1,151 @@
from datasette.utils import MultiParams
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
from urllib.parse import unquote, quote, urlencode
from http.cookies import SimpleCookie
import json
class TestResponse:
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self.body = body
@property
def cookies(self):
cookie = SimpleCookie()
for header in self.headers.getlist("set-cookie"):
cookie.load(header)
return {key: value.value for key, value in cookie.items()}
@property
def json(self):
return json.loads(self.text)
@property
def text(self):
return self.body.decode("utf8")
class TestClient:
max_redirects = 5
def __init__(self, asgi_app):
self.asgi_app = asgi_app
def actor_cookie(self, actor):
return self.ds.sign({"a": actor}, "actor")
@async_to_sync
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
):
return await self._request(
path, allow_redirects, redirect_count, method, cookies
)
@async_to_sync
async def post(
self,
path,
post_data=None,
allow_redirects=True,
redirect_count=0,
content_type="application/x-www-form-urlencoded",
cookies=None,
csrftoken_from=None,
):
cookies = cookies or {}
post_data = post_data or {}
# Maybe fetch a csrftoken first
if csrftoken_from is not None:
if csrftoken_from is True:
csrftoken_from = path
token_response = await self._request(csrftoken_from, cookies=cookies)
csrftoken = token_response.cookies["ds_csrftoken"]
cookies["ds_csrftoken"] = csrftoken
post_data["csrftoken"] = csrftoken
return await self._request(
path,
allow_redirects,
redirect_count,
"POST",
cookies,
post_data,
content_type,
)
async def _request(
self,
path,
allow_redirects=True,
redirect_count=0,
method="GET",
cookies=None,
post_data=None,
content_type=None,
):
query_string = b""
if "?" in path:
path, _, query_string = path.partition("?")
query_string = query_string.encode("utf8")
if "%" in path:
raw_path = path.encode("latin-1")
else:
raw_path = quote(path, safe="/:,").encode("latin-1")
headers = [[b"host", b"localhost"]]
if content_type:
headers.append((b"content-type", content_type.encode("utf-8")))
if cookies:
sc = SimpleCookie()
for key, value in cookies.items():
sc[key] = value
headers.append([b"cookie", sc.output(header="").encode("utf-8")])
scope = {
"type": "http",
"http_version": "1.0",
"method": method,
"path": unquote(path),
"raw_path": raw_path,
"query_string": query_string,
"headers": headers,
}
instance = ApplicationCommunicator(self.asgi_app, scope)
if post_data:
body = urlencode(post_data, doseq=True).encode("utf-8")
await instance.send_input({"type": "http.request", "body": body})
else:
await instance.send_input({"type": "http.request"})
# First message back should be response.start with headers and status
messages = []
start = await instance.receive_output(2)
messages.append(start)
assert start["type"] == "http.response.start"
response_headers = MultiParams(
[(k.decode("utf8"), v.decode("utf8")) for k, v in start["headers"]]
)
status = start["status"]
# Now loop until we run out of response.body
body = b""
while True:
message = await instance.receive_output(2)
messages.append(message)
assert message["type"] == "http.response.body"
body += message["body"]
if not message.get("more_body"):
break
response = TestResponse(status, response_headers, body)
if allow_redirects and response.status in (301, 302):
assert (
redirect_count < self.max_redirects
), "Redirected {} times, max_redirects={}".format(
redirect_count, self.max_redirects
)
location = response.headers["Location"]
return await self._request(
location, allow_redirects=True, redirect_count=redirect_count + 1
)
return response

Wyświetl plik

@ -33,6 +33,9 @@ Options:
cookies cookies
--root Output URL that sets a cookie authenticating the root user --root Output URL that sets a cookie authenticating the root user
--get TEXT Run an HTTP GET request against this path, print results and
exit
--version-note TEXT Additional note to show on /-/versions --version-note TEXT Additional note to show on /-/versions
--help-config Show available config options --help-config Show available config options
--help Show this message and exit. --help Show this message and exit.

Wyświetl plik

@ -9,7 +9,7 @@ The best way to experience Datasette for the first time is with a demo:
* `fivethirtyeight.datasettes.com <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__ shows Datasette running against over 400 datasets imported from the `FiveThirtyEight GitHub repository <https://github.com/fivethirtyeight/data>`__. * `fivethirtyeight.datasettes.com <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__ shows Datasette running against over 400 datasets imported from the `FiveThirtyEight GitHub repository <https://github.com/fivethirtyeight/data>`__.
* `sf-trees.datasettes.com <https://sf-trees.datasettes.com/trees/Street_Tree_List>`__ demonstrates the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`__ plugin running against 190,000 trees imported from `data.sfgov.org <https://data.sfgov.org/City-Infrastructure/Street-Tree-List/tkzw-k3nq>`__. * `sf-trees.datasettes.com <https://sf-trees.datasettes.com/trees/Street_Tree_List>`__ demonstrates the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`__ plugin running against 190,000 trees imported from `data.sfgov.org <https://data.sfgov.org/City-Infrastructure/Street-Tree-List/tkzw-k3nq>`__.
.. _glitch: .. _getting_started_glitch:
Try Datasette without installing anything using Glitch Try Datasette without installing anything using Glitch
------------------------------------------------------ ------------------------------------------------------
@ -33,6 +33,8 @@ Need some data? Try this `Public Art Data <https://data.seattle.gov/Community/Pu
For more on how this works, see `Running Datasette on Glitch <https://simonwillison.net/2019/Apr/23/datasette-glitch/>`__. For more on how this works, see `Running Datasette on Glitch <https://simonwillison.net/2019/Apr/23/datasette-glitch/>`__.
.. _getting_started_your_computer:
Using Datasette on your own computer Using Datasette on your own computer
------------------------------------ ------------------------------------
@ -40,13 +42,11 @@ First, follow the :ref:`installation` instructions. Now you can run Datasette ag
:: ::
datasette serve path/to/database.db datasette path/to/database.db
This will start a web server on port 8001 - visit http://localhost:8001/ This will start a web server on port 8001 - visit http://localhost:8001/
to access the web interface. to access the web interface.
``serve`` is the default subcommand, you can omit it if you like.
Use Chrome on OS X? You can run datasette against your browser history Use Chrome on OS X? You can run datasette against your browser history
like so: like so:
@ -90,7 +90,7 @@ JSON:
} }
http://localhost:8001/History/downloads.json?_shape=objects will return that data as http://localhost:8001/History/downloads.json?_shape=objects will return that data as
JSON in a more convenient but less efficient format: JSON in a more convenient format:
:: ::
@ -109,7 +109,57 @@ JSON in a more convenient but less efficient format:
] ]
} }
datasette serve options .. _getting_started_datasette_get:
-----------------------
datasette --get
---------------
The ``--get`` option can specify the path to a page within Datasette and cause Datasette to output the content from that path without starting the web server. This means that all of Datasette's functionality can be accessed directly from the command-line. For example::
$ datasette --get '/-/versions.json' | jq .
{
"python": {
"version": "3.8.5",
"full": "3.8.5 (default, Jul 21 2020, 10:48:26) \n[Clang 11.0.3 (clang-1103.0.32.62)]"
},
"datasette": {
"version": "0.46+15.g222a84a.dirty"
},
"asgi": "3.0",
"uvicorn": "0.11.8",
"sqlite": {
"version": "3.32.3",
"fts_versions": [
"FTS5",
"FTS4",
"FTS3"
],
"extensions": {
"json1": null
},
"compile_options": [
"COMPILER=clang-11.0.3",
"ENABLE_COLUMN_METADATA",
"ENABLE_FTS3",
"ENABLE_FTS3_PARENTHESIS",
"ENABLE_FTS4",
"ENABLE_FTS5",
"ENABLE_GEOPOLY",
"ENABLE_JSON1",
"ENABLE_PREUPDATE_HOOK",
"ENABLE_RTREE",
"ENABLE_SESSION",
"MAX_VARIABLE_NUMBER=250000",
"THREADSAFE=1"
]
}
}
.. _getting_started_serve_help:
datasette serve --help
----------------------
Running ``datasette downloads.db`` executes the default ``serve`` sub-command, and is equivalent to running ``datasette serve downloads.db``. The full list of options to that command is shown below.
.. literalinclude:: datasette-serve-help.txt .. literalinclude:: datasette-serve-help.txt

Wyświetl plik

@ -23,7 +23,7 @@ Datasette is a tool for exploring and publishing data. It helps people take data
Datasette is aimed at data journalists, museum curators, archivists, local governments and anyone else who has data that they wish to share with the world. It is part of a :ref:`wider ecosystem of tools and plugins <ecosystem>` dedicated to making working with structured data as productive as possible. Datasette is aimed at data journalists, museum curators, archivists, local governments and anyone else who has data that they wish to share with the world. It is part of a :ref:`wider ecosystem of tools and plugins <ecosystem>` dedicated to making working with structured data as productive as possible.
`Explore a demo <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__, watch `a presentation about the project <https://static.simonwillison.net/static/2018/pybay-datasette/>`__ or :ref:`glitch`. `Explore a demo <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__, watch `a presentation about the project <https://static.simonwillison.net/static/2018/pybay-datasette/>`__ or :ref:`getting_started_glitch`.
More examples: https://github.com/simonw/datasette/wiki/Datasettes More examples: https://github.com/simonw/datasette/wiki/Datasettes

Wyświetl plik

@ -5,7 +5,7 @@
============== ==============
.. note:: .. note::
If you just want to try Datasette out you don't need to install anything: see :ref:`glitch` If you just want to try Datasette out you don't need to install anything: see :ref:`getting_started_glitch`
There are two main options for installing Datasette. You can install it directly There are two main options for installing Datasette. You can install it directly
on to your machine, or you can install it using Docker. on to your machine, or you can install it using Docker.

Wyświetl plik

@ -44,6 +44,7 @@ setup(
package_data={"datasette": ["templates/*.html"]}, package_data={"datasette": ["templates/*.html"]},
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
"asgiref~=3.2.10",
"click~=7.1.1", "click~=7.1.1",
"click-default-group~=1.2.2", "click-default-group~=1.2.2",
"Jinja2>=2.10.3,<2.12.0", "Jinja2>=2.10.3,<2.12.0",
@ -70,7 +71,6 @@ setup(
"pytest>=5.2.2,<6.1.0", "pytest>=5.2.2,<6.1.0",
"pytest-asyncio>=0.10,<0.15", "pytest-asyncio>=0.10,<0.15",
"beautifulsoup4>=4.8.1,<4.10.0", "beautifulsoup4>=4.8.1,<4.10.0",
"asgiref~=3.2.3",
"black~=19.10b0", "black~=19.10b0",
], ],
}, },

Wyświetl plik

@ -1,10 +1,8 @@
from datasette.app import Datasette from datasette.app import Datasette
from datasette.utils import sqlite3, MultiParams from datasette.utils import sqlite3
from asgiref.testing import ApplicationCommunicator from datasette.utils.testing import TestClient
from asgiref.sync import async_to_sync
import click import click
import contextlib import contextlib
from http.cookies import SimpleCookie
import itertools import itertools
import json import json
import os import os
@ -16,7 +14,6 @@ import string
import tempfile import tempfile
import textwrap import textwrap
import time import time
from urllib.parse import unquote, quote, urlencode
# This temp file is used by one of the plugin config tests # This temp file is used by one of the plugin config tests
@ -89,151 +86,6 @@ EXPECTED_PLUGINS = [
] ]
class TestResponse:
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self.body = body
@property
def cookies(self):
cookie = SimpleCookie()
for header in self.headers.getlist("set-cookie"):
cookie.load(header)
return {key: value.value for key, value in cookie.items()}
@property
def json(self):
return json.loads(self.text)
@property
def text(self):
return self.body.decode("utf8")
class TestClient:
max_redirects = 5
def __init__(self, asgi_app):
self.asgi_app = asgi_app
def actor_cookie(self, actor):
return self.ds.sign({"a": actor}, "actor")
@async_to_sync
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
):
return await self._request(
path, allow_redirects, redirect_count, method, cookies
)
@async_to_sync
async def post(
self,
path,
post_data=None,
allow_redirects=True,
redirect_count=0,
content_type="application/x-www-form-urlencoded",
cookies=None,
csrftoken_from=None,
):
cookies = cookies or {}
post_data = post_data or {}
# Maybe fetch a csrftoken first
if csrftoken_from is not None:
if csrftoken_from is True:
csrftoken_from = path
token_response = await self._request(csrftoken_from, cookies=cookies)
csrftoken = token_response.cookies["ds_csrftoken"]
cookies["ds_csrftoken"] = csrftoken
post_data["csrftoken"] = csrftoken
return await self._request(
path,
allow_redirects,
redirect_count,
"POST",
cookies,
post_data,
content_type,
)
async def _request(
self,
path,
allow_redirects=True,
redirect_count=0,
method="GET",
cookies=None,
post_data=None,
content_type=None,
):
query_string = b""
if "?" in path:
path, _, query_string = path.partition("?")
query_string = query_string.encode("utf8")
if "%" in path:
raw_path = path.encode("latin-1")
else:
raw_path = quote(path, safe="/:,").encode("latin-1")
headers = [[b"host", b"localhost"]]
if content_type:
headers.append((b"content-type", content_type.encode("utf-8")))
if cookies:
sc = SimpleCookie()
for key, value in cookies.items():
sc[key] = value
headers.append([b"cookie", sc.output(header="").encode("utf-8")])
scope = {
"type": "http",
"http_version": "1.0",
"method": method,
"path": unquote(path),
"raw_path": raw_path,
"query_string": query_string,
"headers": headers,
}
instance = ApplicationCommunicator(self.asgi_app, scope)
if post_data:
body = urlencode(post_data, doseq=True).encode("utf-8")
await instance.send_input({"type": "http.request", "body": body})
else:
await instance.send_input({"type": "http.request"})
# First message back should be response.start with headers and status
messages = []
start = await instance.receive_output(2)
messages.append(start)
assert start["type"] == "http.response.start"
response_headers = MultiParams(
[(k.decode("utf8"), v.decode("utf8")) for k, v in start["headers"]]
)
status = start["status"]
# Now loop until we run out of response.body
body = b""
while True:
message = await instance.receive_output(2)
messages.append(message)
assert message["type"] == "http.response.body"
body += message["body"]
if not message.get("more_body"):
break
response = TestResponse(status, response_headers, body)
if allow_redirects and response.status in (301, 302):
assert (
redirect_count < self.max_redirects
), "Redirected {} times, max_redirects={}".format(
redirect_count, self.max_redirects
)
location = response.headers["Location"]
return await self._request(
location, allow_redirects=True, redirect_count=redirect_count + 1
)
return response
@contextlib.contextmanager @contextlib.contextmanager
def make_app_client( def make_app_client(
sql_time_limit_ms=None, sql_time_limit_ms=None,

Wyświetl plik

@ -50,6 +50,20 @@ def test_serve_with_inspect_file_prepopulates_table_counts_cache():
assert {"hithere": 44} == db.cached_table_counts assert {"hithere": 44} == db.cached_table_counts
def test_serve_with_get():
runner = CliRunner()
result = runner.invoke(
cli,
["serve", "--memory", "--get", "/:memory:.json?sql=select+sqlite_version()"],
)
assert 0 == result.exit_code, result.output
assert {
"database": ":memory:",
"truncated": False,
"columns": ["sqlite_version()"],
}.items() <= json.loads(result.output).items()
def test_spatialite_error_if_attempt_to_open_spatialite(): def test_spatialite_error_if_attempt_to_open_spatialite():
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@ -102,6 +116,7 @@ def test_metadata_yaml():
secret=None, secret=None,
root=False, root=False,
version_note=None, version_note=None,
get=None,
help_config=False, help_config=False,
return_instance=True, return_instance=True,
) )