mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Port Datasette from Sanic to ASGI + Uvicorn (#518)
Datasette now uses ASGI internally, and no longer depends on Sanic. It now uses Uvicorn as the underlying HTTP server. This was thirteen months in the making... for full details see the issue: https://github.com/simonw/datasette/issues/272 And for a full sequence of commits plus commentary, see the pull request: https://github.com/simonw/datasette/pull/518
This commit is contained in:
parent
35429f9089
commit
ba8db9679f
19 changed files with 1510 additions and 947 deletions
|
|
@ -1,5 +1,7 @@
|
|||
from datasette.app import Datasette
|
||||
from datasette.utils import sqlite3
|
||||
from asgiref.testing import ApplicationCommunicator
|
||||
from asgiref.sync import async_to_sync
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
|
|
@ -10,16 +12,82 @@ import sys
|
|||
import string
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
class TestResponse:
|
||||
def __init__(self, status, headers, body):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
return json.loads(self.text)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self.body.decode("utf8")
|
||||
|
||||
|
||||
class TestClient:
|
||||
def __init__(self, sanic_test_client):
|
||||
self.sanic_test_client = sanic_test_client
|
||||
max_redirects = 5
|
||||
|
||||
def get(self, path, allow_redirects=True):
|
||||
return self.sanic_test_client.get(
|
||||
path, allow_redirects=allow_redirects, gather_request=False
|
||||
def __init__(self, asgi_app):
|
||||
self.asgi_app = asgi_app
|
||||
|
||||
@async_to_sync
|
||||
async def get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
|
||||
return await self._get(path, allow_redirects, redirect_count, method)
|
||||
|
||||
async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
|
||||
query_string = b""
|
||||
if "?" in path:
|
||||
path, _, query_string = path.partition("?")
|
||||
query_string = query_string.encode("utf8")
|
||||
instance = ApplicationCommunicator(
|
||||
self.asgi_app,
|
||||
{
|
||||
"type": "http",
|
||||
"http_version": "1.0",
|
||||
"method": method,
|
||||
"path": unquote(path),
|
||||
"raw_path": path.encode("ascii"),
|
||||
"query_string": query_string,
|
||||
"headers": [[b"host", b"localhost"]],
|
||||
},
|
||||
)
|
||||
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"
|
||||
headers = dict(
|
||||
[(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, 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._get(
|
||||
location, allow_redirects=True, redirect_count=redirect_count + 1
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def make_app_client(
|
||||
|
|
@ -32,6 +100,7 @@ def make_app_client(
|
|||
is_immutable=False,
|
||||
extra_databases=None,
|
||||
inspect_data=None,
|
||||
static_mounts=None,
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filepath = os.path.join(tmpdir, filename)
|
||||
|
|
@ -73,9 +142,10 @@ def make_app_client(
|
|||
plugins_dir=plugins_dir,
|
||||
config=config,
|
||||
inspect_data=inspect_data,
|
||||
static_mounts=static_mounts,
|
||||
)
|
||||
ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n))))
|
||||
client = TestClient(ds.app().test_client)
|
||||
client = TestClient(ds.app())
|
||||
client.ds = ds
|
||||
yield client
|
||||
|
||||
|
|
@ -88,7 +158,7 @@ def app_client():
|
|||
@pytest.fixture(scope="session")
|
||||
def app_client_no_files():
|
||||
ds = Datasette([])
|
||||
client = TestClient(ds.app().test_client)
|
||||
client = TestClient(ds.app())
|
||||
client.ds = ds
|
||||
yield client
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import urllib
|
|||
def test_homepage(app_client):
|
||||
response = app_client.get("/.json")
|
||||
assert response.status == 200
|
||||
assert "application/json; charset=utf-8" == response.headers["content-type"]
|
||||
assert response.json.keys() == {"fixtures": 0}.keys()
|
||||
d = response.json["fixtures"]
|
||||
assert d["name"] == "fixtures"
|
||||
|
|
@ -771,8 +772,8 @@ def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pag
|
|||
fetched.extend(response.json["rows"])
|
||||
path = response.json["next_url"]
|
||||
if path:
|
||||
assert response.json["next"]
|
||||
assert urllib.parse.urlencode({"_next": response.json["next"]}) in path
|
||||
path = path.replace("http://localhost", "")
|
||||
assert count < 30, "Possible infinite loop detected"
|
||||
|
||||
assert expected_rows == len(fetched)
|
||||
|
|
@ -812,6 +813,8 @@ def test_paginate_compound_keys(app_client):
|
|||
response = app_client.get(path)
|
||||
fetched.extend(response.json["rows"])
|
||||
path = response.json["next_url"]
|
||||
if path:
|
||||
path = path.replace("http://localhost", "")
|
||||
assert page < 100
|
||||
assert 1001 == len(fetched)
|
||||
assert 21 == page
|
||||
|
|
@ -833,6 +836,8 @@ def test_paginate_compound_keys_with_extra_filters(app_client):
|
|||
response = app_client.get(path)
|
||||
fetched.extend(response.json["rows"])
|
||||
path = response.json["next_url"]
|
||||
if path:
|
||||
path = path.replace("http://localhost", "")
|
||||
assert 2 == page
|
||||
expected = [r[3] for r in generate_compound_rows(1001) if "d" in r[3]]
|
||||
assert expected == [f["content"] for f in fetched]
|
||||
|
|
@ -881,6 +886,8 @@ def test_sortable(app_client, query_string, sort_key, human_description_en):
|
|||
assert human_description_en == response.json["human_description_en"]
|
||||
fetched.extend(response.json["rows"])
|
||||
path = response.json["next_url"]
|
||||
if path:
|
||||
path = path.replace("http://localhost", "")
|
||||
assert 5 == page
|
||||
expected = list(generate_sortable_rows(201))
|
||||
expected.sort(key=sort_key)
|
||||
|
|
@ -1191,6 +1198,7 @@ def test_plugins_json(app_client):
|
|||
def test_versions_json(app_client):
|
||||
response = app_client.get("/-/versions.json")
|
||||
assert "python" in response.json
|
||||
assert "3.0" == response.json.get("asgi")
|
||||
assert "version" in response.json["python"]
|
||||
assert "full" in response.json["python"]
|
||||
assert "datasette" in response.json
|
||||
|
|
@ -1236,6 +1244,8 @@ def test_page_size_matching_max_returned_rows(
|
|||
fetched.extend(response.json["rows"])
|
||||
assert len(response.json["rows"]) in (1, 50)
|
||||
path = response.json["next_url"]
|
||||
if path:
|
||||
path = path.replace("http://localhost", "")
|
||||
assert 201 == len(fetched)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ def test_table_csv(app_client):
|
|||
response = app_client.get("/fixtures/simple_primary_key.csv")
|
||||
assert response.status == 200
|
||||
assert not response.headers.get("Access-Control-Allow-Origin")
|
||||
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
|
||||
assert "text/plain; charset=utf-8" == response.headers["content-type"]
|
||||
assert EXPECTED_TABLE_CSV == response.text
|
||||
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ def test_table_csv_cors_headers(app_client_with_cors):
|
|||
def test_table_csv_with_labels(app_client):
|
||||
response = app_client.get("/fixtures/facetable.csv?_labels=1")
|
||||
assert response.status == 200
|
||||
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
|
||||
assert "text/plain; charset=utf-8" == response.headers["content-type"]
|
||||
assert EXPECTED_TABLE_WITH_LABELS_CSV == response.text
|
||||
|
||||
|
||||
|
|
@ -68,14 +68,14 @@ def test_custom_sql_csv(app_client):
|
|||
"/fixtures.csv?sql=select+content+from+simple_primary_key+limit+2"
|
||||
)
|
||||
assert response.status == 200
|
||||
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
|
||||
assert "text/plain; charset=utf-8" == response.headers["content-type"]
|
||||
assert EXPECTED_CUSTOM_CSV == response.text
|
||||
|
||||
|
||||
def test_table_csv_download(app_client):
|
||||
response = app_client.get("/fixtures/simple_primary_key.csv?_dl=1")
|
||||
assert response.status == 200
|
||||
assert "text/csv; charset=utf-8" == response.headers["Content-Type"]
|
||||
assert "text/csv; charset=utf-8" == response.headers["content-type"]
|
||||
expected_disposition = 'attachment; filename="simple_primary_key.csv"'
|
||||
assert expected_disposition == response.headers["Content-Disposition"]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from .fixtures import ( # noqa
|
|||
METADATA,
|
||||
)
|
||||
import json
|
||||
import pathlib
|
||||
import pytest
|
||||
import re
|
||||
import urllib.parse
|
||||
|
|
@ -16,6 +17,7 @@ import urllib.parse
|
|||
def test_homepage(app_client_two_attached_databases):
|
||||
response = app_client_two_attached_databases.get("/")
|
||||
assert response.status == 200
|
||||
assert "text/html; charset=utf-8" == response.headers["content-type"]
|
||||
soup = Soup(response.body, "html.parser")
|
||||
assert "Datasette Fixtures" == soup.find("h1").text
|
||||
assert (
|
||||
|
|
@ -44,6 +46,29 @@ def test_homepage(app_client_two_attached_databases):
|
|||
] == table_links
|
||||
|
||||
|
||||
def test_http_head(app_client):
|
||||
response = app_client.get("/", method="HEAD")
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
def test_static(app_client):
|
||||
response = app_client.get("/-/static/app2.css")
|
||||
assert response.status == 404
|
||||
response = app_client.get("/-/static/app.css")
|
||||
assert response.status == 200
|
||||
assert "text/css" == response.headers["content-type"]
|
||||
|
||||
|
||||
def test_static_mounts():
|
||||
for client in make_app_client(
|
||||
static_mounts=[("custom-static", str(pathlib.Path(__file__).parent))]
|
||||
):
|
||||
response = client.get("/custom-static/test_html.py")
|
||||
assert response.status == 200
|
||||
response = client.get("/custom-static/not_exists.py")
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
def test_memory_database_page():
|
||||
for client in make_app_client(memory=True):
|
||||
response = client.get("/:memory:")
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ Tests for various datasette helper functions.
|
|||
"""
|
||||
|
||||
from datasette import utils
|
||||
from datasette.utils.asgi import Request
|
||||
from datasette.filters import Filters
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from sanic.request import Request
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
|
@ -53,7 +53,7 @@ def test_urlsafe_components(path, expected):
|
|||
],
|
||||
)
|
||||
def test_path_with_added_args(path, added_args, expected):
|
||||
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
|
||||
request = Request.fake(path)
|
||||
actual = utils.path_with_added_args(request, added_args)
|
||||
assert expected == actual
|
||||
|
||||
|
|
@ -67,11 +67,11 @@ def test_path_with_added_args(path, added_args, expected):
|
|||
],
|
||||
)
|
||||
def test_path_with_removed_args(path, args, expected):
|
||||
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
|
||||
request = Request.fake(path)
|
||||
actual = utils.path_with_removed_args(request, args)
|
||||
assert expected == actual
|
||||
# Run the test again but this time use the path= argument
|
||||
request = Request("/".encode("utf8"), {}, "1.1", "GET", None)
|
||||
request = Request.fake("/")
|
||||
actual = utils.path_with_removed_args(request, args, path=path)
|
||||
assert expected == actual
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ def test_path_with_removed_args(path, args, expected):
|
|||
],
|
||||
)
|
||||
def test_path_with_replaced_args(path, args, expected):
|
||||
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
|
||||
request = Request.fake(path)
|
||||
actual = utils.path_with_replaced_args(request, args)
|
||||
assert expected == actual
|
||||
|
||||
|
|
@ -363,7 +363,7 @@ def test_table_columns():
|
|||
],
|
||||
)
|
||||
def test_path_with_format(path, format, extra_qs, expected):
|
||||
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
|
||||
request = Request.fake(path)
|
||||
actual = utils.path_with_format(request, format, extra_qs)
|
||||
assert expected == actual
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue