kopia lustrzana https://github.com/simonw/datasette
datasette.client internal requests mechanism
Closes #943 * Datasette now requires httpx>=0.15 * Support OPTIONS without 500, closes #1001 * Added internals tests for datasette.client methods * Datasette's own test mechanism now uses httpx to simulate requests * Tests simulate HTTP 1.1 now * Added base_url in a bunch more places * Mark some tests as xfail - will remove that when new httpx release ships: #1005pull/1008/head
rodzic
7249ac5ca0
commit
8f97b9b58e
|
@ -4,8 +4,8 @@ import collections
|
|||
import datetime
|
||||
import glob
|
||||
import hashlib
|
||||
import httpx
|
||||
import inspect
|
||||
import itertools
|
||||
from itsdangerous import BadSignature
|
||||
import json
|
||||
import os
|
||||
|
@ -18,7 +18,6 @@ import urllib.parse
|
|||
from concurrent import futures
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from markupsafe import Markup
|
||||
from itsdangerous import URLSafeSerializer
|
||||
import jinja2
|
||||
|
@ -62,7 +61,6 @@ from .utils.asgi import (
|
|||
Forbidden,
|
||||
NotFound,
|
||||
Request,
|
||||
Response,
|
||||
asgi_static,
|
||||
asgi_send,
|
||||
asgi_send_html,
|
||||
|
@ -312,6 +310,7 @@ class Datasette:
|
|||
self._register_renderers()
|
||||
self._permission_checks = collections.deque(maxlen=200)
|
||||
self._root_token = secrets.token_hex(32)
|
||||
self.client = DatasetteClient(self)
|
||||
|
||||
async def invoke_startup(self):
|
||||
for hook in pm.hook.startup(datasette=self):
|
||||
|
@ -1209,3 +1208,45 @@ def route_pattern_from_filepath(filepath):
|
|||
|
||||
class NotFoundExplicit(NotFound):
|
||||
pass
|
||||
|
||||
|
||||
class DatasetteClient:
|
||||
def __init__(self, ds):
|
||||
self.app = ds.app()
|
||||
|
||||
def _fix(self, path):
|
||||
if path.startswith("/"):
|
||||
path = "http://localhost{}".format(path)
|
||||
return path
|
||||
|
||||
async def get(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.get(self._fix(path), **kwargs)
|
||||
|
||||
async def options(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.options(self._fix(path), **kwargs)
|
||||
|
||||
async def head(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.head(self._fix(path), **kwargs)
|
||||
|
||||
async def post(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.post(self._fix(path), **kwargs)
|
||||
|
||||
async def put(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.put(self._fix(path), **kwargs)
|
||||
|
||||
async def patch(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.patch(self._fix(path), **kwargs)
|
||||
|
||||
async def delete(self, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.delete(self._fix(path), **kwargs)
|
||||
|
||||
async def request(self, method, path, **kwargs):
|
||||
async with httpx.AsyncClient(app=self.app) as client:
|
||||
return await client.request(method, self._fix(path), **kwargs)
|
||||
|
|
|
@ -450,7 +450,7 @@ def serve(
|
|||
asyncio.get_event_loop().run_until_complete(check_databases(ds))
|
||||
|
||||
if get:
|
||||
client = TestClient(ds.app())
|
||||
client = TestClient(ds)
|
||||
response = client.get(get)
|
||||
click.echo(response.text)
|
||||
exit_code = 0 if response.status == 200 else 1
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
{{ column.name }}
|
||||
{% else %}
|
||||
{% if column.name == sort %}
|
||||
<a href="{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }} ▼</a>
|
||||
<a href="{{ base_url }}{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }} ▼</a>
|
||||
{% else %}
|
||||
<a href="{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ column.name }}{% if column.name == sort_desc %} ▲{% endif %}</a>
|
||||
<a href="{{ base_url }}{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ column.name }}{% if column.name == sort_desc %} ▲{% endif %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</th>
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
</form>
|
||||
|
||||
{% if display_rows %}
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ base_url }}{{ url_csv }}">CSV</a></p>
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<p>This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
|
||||
<p>This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
{% for column in display_columns %}
|
||||
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; }
|
||||
{% endfor %}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -111,7 +110,7 @@
|
|||
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ database_url(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||
{% endif %}
|
||||
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ base_url }}{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
||||
|
||||
{% if suggested_facets %}
|
||||
<p class="suggested-facets">
|
||||
|
@ -160,10 +159,10 @@
|
|||
<div id="export" class="advanced-export">
|
||||
<h3>Advanced export</h3>
|
||||
<p>JSON shape:
|
||||
<a href="{{ renderers['json'] }}">default</a>,
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
|
||||
<a href="{{ base_url }}{{ renderers['json'] }}">default</a>,
|
||||
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
|
||||
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
|
||||
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<form action="{{ url_csv_path }}" method="get">
|
||||
|
|
|
@ -1,23 +1,39 @@
|
|||
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
|
||||
from urllib.parse import urlencode
|
||||
import json
|
||||
|
||||
# These wrapper classes pre-date the introduction of
|
||||
# datasette.client and httpx to Datasette. They could
|
||||
# be removed if the Datasette tests are modified to
|
||||
# call datasette.client directly.
|
||||
|
||||
|
||||
class TestResponse:
|
||||
def __init__(self, status, headers, body):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
def __init__(self, httpx_response):
|
||||
self.httpx_response = httpx_response
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.httpx_response.status_code
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self.httpx_response.headers
|
||||
|
||||
@property
|
||||
def body(self):
|
||||
return self.httpx_response.content
|
||||
|
||||
@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()}
|
||||
return dict(self.httpx_response.cookies)
|
||||
|
||||
def cookie_was_deleted(self, cookie):
|
||||
return any(
|
||||
h
|
||||
for h in self.httpx_response.headers.get_list("set-cookie")
|
||||
if h.startswith('{}="";'.format(cookie))
|
||||
)
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
|
@ -31,8 +47,8 @@ class TestResponse:
|
|||
class TestClient:
|
||||
max_redirects = 5
|
||||
|
||||
def __init__(self, asgi_app):
|
||||
self.asgi_app = asgi_app
|
||||
def __init__(self, ds):
|
||||
self.ds = ds
|
||||
|
||||
def actor_cookie(self, actor):
|
||||
return self.ds.sign({"a": actor}, "actor")
|
||||
|
@ -94,61 +110,18 @@ class TestClient:
|
|||
post_body=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")
|
||||
asgi_headers = [[b"host", b"localhost"]]
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
asgi_headers.append([key.encode("utf-8"), value.encode("utf-8")])
|
||||
headers = headers or {}
|
||||
if content_type:
|
||||
asgi_headers.append((b"content-type", content_type.encode("utf-8")))
|
||||
if cookies:
|
||||
sc = SimpleCookie()
|
||||
for key, value in cookies.items():
|
||||
sc[key] = value
|
||||
asgi_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": asgi_headers,
|
||||
}
|
||||
instance = ApplicationCommunicator(self.asgi_app, scope)
|
||||
|
||||
if post_body:
|
||||
body = post_body.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"]]
|
||||
headers["content-type"] = content_type
|
||||
httpx_response = await self.ds.client.request(
|
||||
method,
|
||||
path,
|
||||
allow_redirects=allow_redirects,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
content=post_body,
|
||||
)
|
||||
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)
|
||||
response = TestResponse(httpx_response)
|
||||
if allow_redirects and response.status in (301, 302):
|
||||
assert (
|
||||
redirect_count < self.max_redirects
|
||||
|
|
|
@ -113,6 +113,15 @@ class BaseView:
|
|||
async def options(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
|
||||
async def put(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
|
||||
async def patch(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
|
||||
async def delete(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
|
||||
async def dispatch_request(self, request, *args, **kwargs):
|
||||
handler = getattr(self, request.method.lower(), None)
|
||||
return await handler(request, *args, **kwargs)
|
||||
|
|
1
setup.py
1
setup.py
|
@ -49,6 +49,7 @@ setup(
|
|||
"click-default-group~=1.2.2",
|
||||
"Jinja2>=2.10.3,<2.12.0",
|
||||
"hupper~=1.9",
|
||||
"httpx>=0.15",
|
||||
"pint~=0.9",
|
||||
"pluggy~=0.13.0",
|
||||
"uvicorn~=0.11",
|
||||
|
|
|
@ -144,9 +144,7 @@ def make_app_client(
|
|||
template_dir=template_dir,
|
||||
)
|
||||
ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n))))
|
||||
client = TestClient(ds.app())
|
||||
client.ds = ds
|
||||
yield client
|
||||
yield TestClient(ds)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
@ -158,9 +156,7 @@ def app_client():
|
|||
@pytest.fixture(scope="session")
|
||||
def app_client_no_files():
|
||||
ds = Datasette([])
|
||||
client = TestClient(ds.app())
|
||||
client.ds = ds
|
||||
yield client
|
||||
yield TestClient(ds)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
|
@ -739,6 +739,7 @@ def test_table_shape_object_compound_primary_Key(app_client):
|
|||
assert {"a,b": {"pk1": "a", "pk2": "b", "content": "c"}} == response.json
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_table_with_slashes_in_name(app_client):
|
||||
response = app_client.get(
|
||||
"/fixtures/table%2Fwith%2Fslashes.csv?_shape=objects&_format=json"
|
||||
|
@ -1186,6 +1187,7 @@ def test_row_format_in_querystring(app_client):
|
|||
assert [{"id": "1", "content": "hello"}] == response.json["rows"]
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_row_strange_table_name(app_client):
|
||||
response = app_client.get(
|
||||
"/fixtures/table%2Fwith%2Fslashes.csv/3.json?_shape=objects"
|
||||
|
|
|
@ -87,7 +87,8 @@ def test_logout(app_client):
|
|||
cookies={"ds_actor": app_client.actor_cookie({"id": "test"})},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert "" == response4.cookies["ds_actor"]
|
||||
# The ds_actor cookie should have been unset
|
||||
assert response4.cookie_was_deleted("ds_actor")
|
||||
# Should also have set a message
|
||||
messages = app_client.ds.unsign(response4.cookies["ds_messages"], "messages")
|
||||
assert [["You are now logged out", 2]] == messages
|
||||
|
|
|
@ -108,8 +108,7 @@ def test_metadata_yaml():
|
|||
open_browser=False,
|
||||
return_instance=True,
|
||||
)
|
||||
client = _TestClient(ds.app())
|
||||
client.ds = ds
|
||||
client = _TestClient(ds)
|
||||
response = client.get("/-/metadata.json")
|
||||
assert {"title": "Hello from YAML"} == response.json
|
||||
|
||||
|
|
|
@ -76,9 +76,7 @@ def config_dir_client(tmp_path_factory):
|
|||
)
|
||||
|
||||
ds = Datasette([], config_dir=config_dir)
|
||||
client = _TestClient(ds.app())
|
||||
client.ds = ds
|
||||
yield client
|
||||
yield _TestClient(ds)
|
||||
|
||||
|
||||
def test_metadata(config_dir_client):
|
||||
|
@ -137,8 +135,7 @@ def test_metadata_yaml(tmp_path_factory, filename):
|
|||
config_dir = tmp_path_factory.mktemp("yaml-config-dir")
|
||||
(config_dir / filename).write_text("title: Title from metadata", "utf-8")
|
||||
ds = Datasette([], config_dir=config_dir)
|
||||
client = _TestClient(ds.app())
|
||||
client.ds = ds
|
||||
client = _TestClient(ds)
|
||||
response = client.get("/-/metadata.json")
|
||||
assert 200 == response.status
|
||||
assert {"title": "Title from metadata"} == response.json
|
||||
|
|
|
@ -142,6 +142,7 @@ def test_row_redirects_with_url_hash(app_client_with_hash):
|
|||
assert response.status == 200
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_row_strange_table_name_with_url_hash(app_client_with_hash):
|
||||
response = app_client_with_hash.get(
|
||||
"/fixtures/table%2Fwith%2Fslashes.csv/3", allow_redirects=False
|
||||
|
@ -535,6 +536,7 @@ def test_facets_persist_through_filter_form(app_client):
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_classes",
|
||||
[
|
||||
|
@ -566,6 +568,7 @@ def test_css_classes_on_body(app_client, path, expected_classes):
|
|||
assert classes == expected_classes
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_considered",
|
||||
[
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
from .fixtures import app_client
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datasette(app_client):
|
||||
return app_client.ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,expected_status",
|
||||
[
|
||||
("get", "/", 200),
|
||||
("options", "/", 405),
|
||||
("head", "/", 200),
|
||||
("put", "/", 405),
|
||||
("patch", "/", 405),
|
||||
("delete", "/", 405),
|
||||
],
|
||||
)
|
||||
async def test_client_methods(datasette, method, path, expected_status):
|
||||
client_method = getattr(datasette.client, method)
|
||||
response = await client_method(path)
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == expected_status
|
||||
# Try that again using datasette.client.request
|
||||
response2 = await datasette.client.request(method, path)
|
||||
assert response2.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_post(datasette):
|
||||
response = await datasette.client.post(
|
||||
"/-/messages",
|
||||
data={
|
||||
"message": "A message",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == 302
|
||||
assert "ds_messages" in response.cookies
|
|
@ -25,4 +25,4 @@ def test_messages_are_displayed_and_cleared(app_client):
|
|||
# Messages should be in that HTML
|
||||
assert "xmessagex" in response.text
|
||||
# Cookie should have been set that clears messages
|
||||
assert "" == response.cookies["ds_messages"]
|
||||
assert response.cookie_was_deleted("ds_messages")
|
||||
|
|
|
@ -380,9 +380,7 @@ def view_names_client(tmp_path_factory):
|
|||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(TABLES)
|
||||
return _TestClient(
|
||||
Datasette(
|
||||
[db_path], template_dir=str(templates), plugins_dir=str(plugins)
|
||||
).app()
|
||||
Datasette([db_path], template_dir=str(templates), plugins_dir=str(plugins))
|
||||
)
|
||||
|
||||
|
||||
|
@ -748,7 +746,7 @@ def test_hook_register_magic_parameters(restore_working_directory):
|
|||
response = client.post("/data/runme", {}, csrftoken_from=True)
|
||||
assert 200 == response.status
|
||||
actual = client.get("/data/logs.json?_sort_desc=rowid&_shape=array").json
|
||||
assert [{"rowid": 1, "line": "1.0"}] == actual
|
||||
assert [{"rowid": 1, "line": "1.1"}] == actual
|
||||
# Now try the GET request against get_uuid
|
||||
response_get = client.get("/data/get_uuid.json?_shape=array")
|
||||
assert 200 == response_get.status
|
||||
|
|
Ładowanie…
Reference in New Issue