Skip to content

Commit

Permalink
'datasette --get' option, refs #926
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Aug 11, 2020
1 parent adfe304 commit d1cdcbf
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 151 deletions.
11 changes: 11 additions & 0 deletions datasette/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
StaticMount,
ValueAsBooleanError,
)
from .utils.testing import TestClient


class Config(click.ParamType):
Expand Down Expand Up @@ -342,6 +343,9 @@ def uninstall(packages, yes):
help="Output URL that sets a cookie authenticating the root user",
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("--help-config", is_flag=True, help="Show available config options")
def serve(
Expand All @@ -362,6 +366,7 @@ def serve(
config,
secret,
root,
get,
version_note,
help_config,
return_instance=False,
Expand Down Expand Up @@ -418,6 +423,12 @@ def serve(

ds = Datasette(files, **kwargs)

if get:
client = TestClient(ds.app())
response = client.get(get)
click.echo(response.text)
return

if return_instance:
# Private utility mechanism for writing unit tests
return ds
Expand Down
151 changes: 151 additions & 0 deletions datasette/utils/testing.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docs/datasette-serve-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Options:
cookies

--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
--help-config Show available config options
--help Show this message and exit.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def get_version():
package_data={"datasette": ["templates/*.html"]},
include_package_data=True,
install_requires=[
"asgiref~=3.2.10",
"click~=7.1.1",
"click-default-group~=1.2.2",
"Jinja2>=2.10.3,<2.12.0",
Expand All @@ -70,7 +71,6 @@ def get_version():
"pytest>=5.2.2,<6.1.0",
"pytest-asyncio>=0.10,<0.15",
"beautifulsoup4>=4.8.1,<4.10.0",
"asgiref~=3.2.3",
"black~=19.10b0",
],
},
Expand Down
152 changes: 2 additions & 150 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from datasette.app import Datasette
from datasette.utils import sqlite3, MultiParams
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
from datasette.utils import sqlite3
from datasette.utils.testing import TestClient
import click
import contextlib
from http.cookies import SimpleCookie
import itertools
import json
import os
Expand All @@ -16,7 +14,6 @@
import tempfile
import textwrap
import time
from urllib.parse import unquote, quote, urlencode


# This temp file is used by one of the plugin config tests
Expand Down Expand Up @@ -89,151 +86,6 @@
]


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
def make_app_client(
sql_time_limit_ms=None,
Expand Down
1 change: 1 addition & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def test_metadata_yaml():
secret=None,
root=False,
version_note=None,
get=None,
help_config=False,
return_instance=True,
)
Expand Down

0 comments on commit d1cdcbf

Please sign in to comment.