mirror of
https://github.com/simonw/datasette.git
synced 2026-09-08 01:24:14 +02:00
Compare commits
2 commits
main
...
codex/http
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1145475a | ||
|
|
f712559886 |
14 changed files with 119 additions and 713 deletions
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
|
@ -11,17 +11,16 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v7
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
allow-prereleases: true
|
allow-prereleases: true
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
check-latest: true
|
|
||||||
- name: Build extension for --load-extension test
|
- name: Build extension for --load-extension test
|
||||||
run: |-
|
run: |-
|
||||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import urllib.parse
|
||||||
from concurrent import futures
|
from concurrent import futures
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
from jinja2 import (
|
from jinja2 import (
|
||||||
ChoiceLoader,
|
ChoiceLoader,
|
||||||
|
|
@ -453,10 +453,8 @@ class Datasette:
|
||||||
self.databases = collections.OrderedDict()
|
self.databases = collections.OrderedDict()
|
||||||
self.actions = {} # .invoke_startup() will populate this
|
self.actions = {} # .invoke_startup() will populate this
|
||||||
self._column_types = {} # .invoke_startup() will populate this
|
self._column_types = {} # .invoke_startup() will populate this
|
||||||
self._setup_db_done = False
|
|
||||||
try:
|
try:
|
||||||
self._refresh_schemas_lock = asyncio.Lock()
|
self._refresh_schemas_lock = asyncio.Lock()
|
||||||
self._startup_lock = asyncio.Lock()
|
|
||||||
except RuntimeError as rex:
|
except RuntimeError as rex:
|
||||||
# Workaround for intermittent test failure, see:
|
# Workaround for intermittent test failure, see:
|
||||||
# https://github.com/simonw/datasette/issues/1802
|
# https://github.com/simonw/datasette/issues/1802
|
||||||
|
|
@ -464,7 +462,6 @@ class Datasette:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._refresh_schemas_lock = asyncio.Lock()
|
self._refresh_schemas_lock = asyncio.Lock()
|
||||||
self._startup_lock = asyncio.Lock()
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
self.crossdb = crossdb
|
self.crossdb = crossdb
|
||||||
|
|
@ -2806,52 +2803,24 @@ class Datasette:
|
||||||
raise RowNotFound(db.name, table_name, pk_values)
|
raise RowNotFound(db.name, table_name, pk_values)
|
||||||
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
||||||
|
|
||||||
async def _startup_sequence(self):
|
|
||||||
"""Idempotently run the full startup sequence: table counts for
|
|
||||||
immutable databases, then invoke_startup(). Safe to call more than
|
|
||||||
once and safe to call concurrently - callers block until whichever
|
|
||||||
call got there first has finished.
|
|
||||||
|
|
||||||
This is the single entry point used by both AsgiLifespan (so
|
|
||||||
real deployments finish startup before accepting requests) and
|
|
||||||
AsgiRunOnFirstRequest (the fallback for hosts that never send
|
|
||||||
lifespan events, e.g. DatasetteClient's httpx.ASGITransport), and
|
|
||||||
`datasette serve` (cli.py) calls it too. The fast path below checks
|
|
||||||
both `_startup_invoked` and `_setup_db_done` - not just the former -
|
|
||||||
so that a bare `await ds.invoke_startup()` made by a caller ahead of
|
|
||||||
`_startup_sequence()` (which only sets `_startup_invoked`) can't
|
|
||||||
make this method skip the immutable-database table-count precompute.
|
|
||||||
"""
|
|
||||||
if self._startup_invoked and self._setup_db_done:
|
|
||||||
return
|
|
||||||
async with self._startup_lock:
|
|
||||||
if self._startup_invoked and self._setup_db_done:
|
|
||||||
return
|
|
||||||
if not self._setup_db_done:
|
|
||||||
# First time server starts up, calculate table counts for
|
|
||||||
# immutable databases
|
|
||||||
for database in self.databases.values():
|
|
||||||
if not database.is_mutable:
|
|
||||||
await database.table_counts(limit=60 * 60 * 1000)
|
|
||||||
self._setup_db_done = True
|
|
||||||
await self.invoke_startup()
|
|
||||||
|
|
||||||
def app(self):
|
def app(self):
|
||||||
"""Returns an ASGI app function that serves the whole of Datasette"""
|
"""Returns an ASGI app function that serves the whole of Datasette"""
|
||||||
routes = self._routes()
|
routes = self._routes()
|
||||||
|
|
||||||
|
async def setup_db():
|
||||||
|
# First time server starts up, calculate table counts for immutable databases
|
||||||
|
for database in self.databases.values():
|
||||||
|
if not database.is_mutable:
|
||||||
|
await database.table_counts(limit=60 * 60 * 1000)
|
||||||
|
|
||||||
async def _close_on_shutdown():
|
async def _close_on_shutdown():
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
||||||
if self.setting("trace_debug"):
|
if self.setting("trace_debug"):
|
||||||
asgi = AsgiTracer(asgi)
|
asgi = AsgiTracer(asgi)
|
||||||
asgi = AsgiLifespan(
|
asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown])
|
||||||
asgi,
|
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
|
||||||
on_startup=[self._startup_sequence],
|
|
||||||
on_shutdown=[_close_on_shutdown],
|
|
||||||
)
|
|
||||||
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[self._startup_sequence])
|
|
||||||
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
||||||
asgi = wrapper(asgi)
|
asgi = wrapper(asgi)
|
||||||
return asgi
|
return asgi
|
||||||
|
|
@ -3246,14 +3215,14 @@ class DatasetteClient:
|
||||||
with _DatasetteClientContext():
|
with _DatasetteClientContext():
|
||||||
if skip_permission_checks:
|
if skip_permission_checks:
|
||||||
with SkipPermissions():
|
with SkipPermissions():
|
||||||
async with httpx.AsyncClient(
|
async with httpx2.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=self.app),
|
transport=httpx2.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await getattr(client, method)(self._fix(path), **kwargs)
|
return await getattr(client, method)(self._fix(path), **kwargs)
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient(
|
async with httpx2.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=self.app),
|
transport=httpx2.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await getattr(client, method)(self._fix(path), **kwargs)
|
return await getattr(client, method)(self._fix(path), **kwargs)
|
||||||
|
|
@ -3300,10 +3269,10 @@ class DatasetteClient:
|
||||||
method: HTTP method (e.g., "GET", "POST", "PUT")
|
method: HTTP method (e.g., "GET", "POST", "PUT")
|
||||||
path: The path to request
|
path: The path to request
|
||||||
skip_permission_checks: If True, bypass all permission checks for this request
|
skip_permission_checks: If True, bypass all permission checks for this request
|
||||||
**kwargs: Additional arguments to pass to httpx
|
**kwargs: Additional arguments to pass to httpx2
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
httpx.Response: The response from the request
|
httpx2.Response: The response from the request
|
||||||
"""
|
"""
|
||||||
from datasette.permissions import SkipPermissions
|
from datasette.permissions import SkipPermissions
|
||||||
|
|
||||||
|
|
@ -3312,16 +3281,16 @@ class DatasetteClient:
|
||||||
with _DatasetteClientContext():
|
with _DatasetteClientContext():
|
||||||
if skip_permission_checks:
|
if skip_permission_checks:
|
||||||
with SkipPermissions():
|
with SkipPermissions():
|
||||||
async with httpx.AsyncClient(
|
async with httpx2.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=self.app),
|
transport=httpx2.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await client.request(
|
return await client.request(
|
||||||
method, self._fix(path, avoid_path_rewrites), **kwargs
|
method, self._fix(path, avoid_path_rewrites), **kwargs
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient(
|
async with httpx2.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=self.app),
|
transport=httpx2.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await client.request(
|
return await client.request(
|
||||||
|
|
|
||||||
|
|
@ -663,6 +663,16 @@ def serve(
|
||||||
# Private utility mechanism for writing unit tests
|
# Private utility mechanism for writing unit tests
|
||||||
return ds
|
return ds
|
||||||
|
|
||||||
|
# Run async soundness checks before startup hooks, since invoke_startup
|
||||||
|
# now populates internal tables which requires querying each database
|
||||||
|
run_sync(lambda: check_databases(ds))
|
||||||
|
|
||||||
|
# Run the "startup" plugin hooks
|
||||||
|
try:
|
||||||
|
run_sync(ds.invoke_startup)
|
||||||
|
except StartupError as e:
|
||||||
|
raise click.ClickException(e.args[0])
|
||||||
|
|
||||||
if headers and not get:
|
if headers and not get:
|
||||||
raise click.ClickException("--headers can only be used with --get")
|
raise click.ClickException("--headers can only be used with --get")
|
||||||
|
|
||||||
|
|
@ -670,14 +680,6 @@ def serve(
|
||||||
raise click.ClickException("--token can only be used with --get")
|
raise click.ClickException("--token can only be used with --get")
|
||||||
|
|
||||||
if get:
|
if get:
|
||||||
# --get means we don't run Uvicorn at all
|
|
||||||
run_sync(lambda: check_databases(ds))
|
|
||||||
|
|
||||||
try:
|
|
||||||
run_sync(ds.invoke_startup)
|
|
||||||
except StartupError as e:
|
|
||||||
raise click.ClickException(e.args[0])
|
|
||||||
|
|
||||||
client = TestClient(ds)
|
client = TestClient(ds)
|
||||||
request_headers = {}
|
request_headers = {}
|
||||||
if token:
|
if token:
|
||||||
|
|
@ -702,54 +704,34 @@ def serve(
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
return
|
return
|
||||||
|
|
||||||
# check_databases, invoke_startup() and the uvicorn server all run on a
|
# Start the server
|
||||||
# single event loop, so that anything a plugin's "startup" hook schedules
|
url = None
|
||||||
# on the loop (asyncio.create_task, Lock/Queue/Event objects, ...) is
|
if root:
|
||||||
# still alive when the server starts handling requests.
|
ds.root_enabled = True
|
||||||
async def _serve_async():
|
url = "http://{}:{}{}?token={}".format(
|
||||||
# Populate internal catalog tables before invoke_startup
|
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||||
await check_databases(ds)
|
)
|
||||||
|
click.echo(url)
|
||||||
# Run the full startup sequence (immutable-database table-count
|
if open_browser:
|
||||||
# precompute + the "startup" plugin hooks) via the same entry point
|
if url is None:
|
||||||
# AsgiLifespan/AsgiRunOnFirstRequest use, so it's not skipped when
|
# Figure out most convenient URL - to table, database or homepage
|
||||||
# uvicorn's lifespan.startup fires moments later.
|
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||||
try:
|
url = f"http://{host}:{port}{path}"
|
||||||
await ds._startup_sequence()
|
webbrowser.open(url)
|
||||||
except StartupError as e:
|
uvicorn_kwargs = {
|
||||||
raise click.ClickException(e.args[0])
|
"host": host,
|
||||||
|
"port": port,
|
||||||
# Start the server
|
"log_level": "info",
|
||||||
url = None
|
"lifespan": "on",
|
||||||
if root:
|
"workers": 1,
|
||||||
ds.root_enabled = True
|
}
|
||||||
url = "http://{}:{}{}?token={}".format(
|
if uds:
|
||||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
uvicorn_kwargs["uds"] = uds
|
||||||
)
|
if ssl_keyfile:
|
||||||
click.echo(url)
|
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||||
if open_browser:
|
if ssl_certfile:
|
||||||
if url is None:
|
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
||||||
# Figure out most convenient URL - to table, database or homepage
|
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||||
path = await initial_path_for_datasette(ds)
|
|
||||||
url = f"http://{host}:{port}{path}"
|
|
||||||
webbrowser.open(url)
|
|
||||||
uvicorn_kwargs = {
|
|
||||||
"host": host,
|
|
||||||
"port": port,
|
|
||||||
"log_level": "info",
|
|
||||||
"lifespan": "on",
|
|
||||||
"workers": 1,
|
|
||||||
}
|
|
||||||
if uds:
|
|
||||||
uvicorn_kwargs["uds"] = uds
|
|
||||||
if ssl_keyfile:
|
|
||||||
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
|
||||||
if ssl_certfile:
|
|
||||||
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
|
||||||
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
|
|
||||||
await server.serve()
|
|
||||||
|
|
||||||
asyncio.run(_serve_async())
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from http.cookies import Morsel, SimpleCookie
|
from http.cookies import Morsel, SimpleCookie
|
||||||
|
|
@ -301,24 +300,12 @@ class AsgiLifespan:
|
||||||
while True:
|
while True:
|
||||||
message = await receive()
|
message = await receive()
|
||||||
if message["type"] == "lifespan.startup":
|
if message["type"] == "lifespan.startup":
|
||||||
try:
|
for fn in self.on_startup:
|
||||||
for fn in self.on_startup:
|
await fn()
|
||||||
await fn()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.startup.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.startup.complete"})
|
await send({"type": "lifespan.startup.complete"})
|
||||||
elif message["type"] == "lifespan.shutdown":
|
elif message["type"] == "lifespan.shutdown":
|
||||||
try:
|
for fn in self.on_shutdown:
|
||||||
for fn in self.on_shutdown:
|
await fn()
|
||||||
await fn()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.shutdown.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.shutdown.complete"})
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
|
@ -637,23 +624,10 @@ class AsgiRunOnFirstRequest:
|
||||||
self.asgi = asgi
|
self.asgi = asgi
|
||||||
self.on_startup = on_startup
|
self.on_startup = on_startup
|
||||||
self._started = False
|
self._started = False
|
||||||
# Guards against concurrent early requests interleaving with startup:
|
|
||||||
# without this, several requests could all observe `_started is
|
|
||||||
# False` and proceed before any of them finish running the hooks.
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
async def __call__(self, scope, receive, send):
|
||||||
# Leave "lifespan" scope events alone - this shim only exists as a
|
if not self._started:
|
||||||
# fallback for hosts that never send them. It wraps AsgiLifespan, so
|
self._started = True
|
||||||
# if it ran on_startup here too, a startup exception would escape
|
for hook in self.on_startup:
|
||||||
# before AsgiLifespan's own try/except got a chance to turn it into
|
await hook()
|
||||||
# a lifespan.startup.failed message.
|
|
||||||
if scope["type"] != "lifespan" and not self._started:
|
|
||||||
async with self._lock:
|
|
||||||
# Re-check: another request may have finished startup while
|
|
||||||
# we were waiting for the lock.
|
|
||||||
if not self._started:
|
|
||||||
for hook in self.on_startup:
|
|
||||||
await hook()
|
|
||||||
self._started = True
|
|
||||||
return await self.asgi(scope, receive, send)
|
return await self.asgi(scope, receive, send)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from urllib.parse import urlencode
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
# These wrapper classes pre-date the introduction of
|
# These wrapper classes pre-date the introduction of
|
||||||
# datasette.client and httpx to Datasette. They could
|
# datasette.client and HTTPX2 to Datasette. They could
|
||||||
# be removed if the Datasette tests are modified to
|
# be removed if the Datasette tests are modified to
|
||||||
# call datasette.client directly.
|
# call datasette.client directly.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
.. _v1_0_unreleased:
|
||||||
|
|
||||||
|
Unreleased
|
||||||
|
----------
|
||||||
|
|
||||||
|
- Datasette now uses `HTTPX2 <https://httpx2.pydantic.dev/>`__ in place of HTTPX. (:issue:`2879`)
|
||||||
|
|
||||||
.. _v1_0_a38:
|
.. _v1_0_a38:
|
||||||
|
|
||||||
1.0a38 (2026-08-06)
|
1.0a38 (2026-08-06)
|
||||||
|
|
@ -1429,7 +1436,7 @@ Other changes
|
||||||
- The request object now provides a ``request.full_path`` property, which returns the path including any query string. (:issue:`1184`)
|
- The request object now provides a ``request.full_path`` property, which returns the path including any query string. (:issue:`1184`)
|
||||||
- Better error message for disallowed ``PRAGMA`` clauses in SQL queries. (:issue:`1185`)
|
- Better error message for disallowed ``PRAGMA`` clauses in SQL queries. (:issue:`1185`)
|
||||||
- ``datasette publish heroku`` now deploys using ``python-3.8.7``.
|
- ``datasette publish heroku`` now deploys using ``python-3.8.7``.
|
||||||
- New plugin testing documentation on :ref:`testing_plugins_pytest_httpx`. (:issue:`1198`)
|
- New plugin testing documentation for mocking outbound HTTP calls. (:issue:`1198`)
|
||||||
- All ``?_*`` query string parameters passed to the table page are now persisted in hidden form fields, so parameters such as ``?_size=10`` will be correctly passed to the next page when query filters are changed. (:issue:`1194`)
|
- All ``?_*`` query string parameters passed to the table page are now persisted in hidden form fields, so parameters such as ``?_size=10`` will be correctly passed to the next page when query filters are changed. (:issue:`1194`)
|
||||||
- Fixed a bug loading a database file called ``test-database (1).sqlite``. (:issue:`1181`)
|
- Fixed a bug loading a database file called ``test-database (1).sqlite``. (:issue:`1181`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1594,32 +1594,32 @@ datasette.client
|
||||||
|
|
||||||
Plugins can make internal simulated HTTP requests to the Datasette instance within which they are running. This ensures that all of Datasette's external JSON APIs are also available to plugins, while avoiding the overhead of making an external HTTP call to access those APIs.
|
Plugins can make internal simulated HTTP requests to the Datasette instance within which they are running. This ensures that all of Datasette's external JSON APIs are also available to plugins, while avoiding the overhead of making an external HTTP call to access those APIs.
|
||||||
|
|
||||||
The ``datasette.client`` object is a wrapper around the `HTTPX Python library <https://www.python-httpx.org/>`__, providing an async-friendly API that is similar to the widely used `Requests library <https://requests.readthedocs.io/>`__.
|
The ``datasette.client`` object is a wrapper around the `HTTPX2 Python library <https://httpx2.pydantic.dev/>`__, providing an async-friendly API that is similar to the widely used `Requests library <https://requests.readthedocs.io/>`__.
|
||||||
|
|
||||||
It offers the following methods:
|
It offers the following methods:
|
||||||
|
|
||||||
``await datasette.client.get(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.get(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal GET request against that path.
|
Execute an internal GET request against that path.
|
||||||
|
|
||||||
``await datasette.client.post(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.post(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal POST request. Use ``data={"name": "value"}`` to pass form parameters.
|
Execute an internal POST request. Use ``data={"name": "value"}`` to pass form parameters.
|
||||||
|
|
||||||
``await datasette.client.options(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.options(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal OPTIONS request.
|
Execute an internal OPTIONS request.
|
||||||
|
|
||||||
``await datasette.client.head(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.head(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal HEAD request.
|
Execute an internal HEAD request.
|
||||||
|
|
||||||
``await datasette.client.put(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.put(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal PUT request.
|
Execute an internal PUT request.
|
||||||
|
|
||||||
``await datasette.client.patch(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.patch(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal PATCH request.
|
Execute an internal PATCH request.
|
||||||
|
|
||||||
``await datasette.client.delete(path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.delete(path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal DELETE request.
|
Execute an internal DELETE request.
|
||||||
|
|
||||||
``await datasette.client.request(method, path, **kwargs)`` - returns HTTPX Response
|
``await datasette.client.request(method, path, **kwargs)`` - returns HTTPX2 Response
|
||||||
Execute an internal request with the given HTTP method against that path.
|
Execute an internal request with the given HTTP method against that path.
|
||||||
|
|
||||||
These methods can be used with :ref:`internals_datasette_urls` - for example:
|
These methods can be used with :ref:`internals_datasette_urls` - for example:
|
||||||
|
|
@ -1636,7 +1636,7 @@ These methods can be used with :ref:`internals_datasette_urls` - for example:
|
||||||
|
|
||||||
``datasette.client`` methods automatically take the current :ref:`setting_base_url` setting into account, whether or not you use the ``datasette.urls`` family of methods to construct the path.
|
``datasette.client`` methods automatically take the current :ref:`setting_base_url` setting into account, whether or not you use the ``datasette.urls`` family of methods to construct the path.
|
||||||
|
|
||||||
For documentation on available ``**kwargs`` options and the shape of the HTTPX Response object refer to the `HTTPX Async documentation <https://www.python-httpx.org/async/>`__.
|
For documentation on available ``**kwargs`` options and the shape of the HTTPX2 Response object refer to the `HTTPX2 Async documentation <https://httpx2.pydantic.dev/async/>`__.
|
||||||
|
|
||||||
.. _internals_datasette_client_actor:
|
.. _internals_datasette_client_actor:
|
||||||
|
|
||||||
|
|
@ -2623,12 +2623,12 @@ This example uses trace to record the start, end and duration of any HTTP GET re
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette.tracer import trace
|
from datasette.tracer import trace
|
||||||
import httpx
|
import httpx2
|
||||||
|
|
||||||
|
|
||||||
async def fetch_url(url):
|
async def fetch_url(url):
|
||||||
with trace("fetch-url", url=url):
|
with trace("fetch-url", url=url):
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx2.AsyncClient() as client:
|
||||||
return await client.get(url)
|
return await client.get(url)
|
||||||
|
|
||||||
.. _internals_tracer_trace_child_tasks:
|
.. _internals_tracer_trace_child_tasks:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ If you use the template described in :ref:`writing_plugins_cookiecutter` your pl
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
This test uses the :ref:`internals_datasette_client` object to exercise a test instance of Datasette. ``datasette.client`` is a wrapper around the `HTTPX <https://www.python-httpx.org/>`__ Python library which can imitate HTTP requests using ASGI. This is the recommended way to write tests against a Datasette instance.
|
This test uses the :ref:`internals_datasette_client` object to exercise a test instance of Datasette. ``datasette.client`` is a wrapper around the `HTTPX2 <https://httpx2.pydantic.dev/>`__ Python library which can imitate HTTP requests using ASGI. This is the recommended way to write tests against a Datasette instance.
|
||||||
|
|
||||||
This test also uses the `pytest-asyncio <https://pypi.org/project/pytest-asyncio/>`__ package to add support for ``async def`` test functions running under pytest.
|
This test also uses the `pytest-asyncio <https://pypi.org/project/pytest-asyncio/>`__ package to add support for ``async def`` test functions running under pytest.
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ If you need to opt out of this behavior, add the following to your ``pytest.ini`
|
||||||
Using datasette.client in tests
|
Using datasette.client in tests
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
||||||
The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX async client <https://www.python-httpx.org/async/>`__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test.
|
The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX2 async client <https://httpx2.pydantic.dev/async/>`__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test.
|
||||||
|
|
||||||
A simple test looks like this:
|
A simple test looks like this:
|
||||||
|
|
||||||
|
|
@ -271,75 +271,6 @@ If you want to create that test database repeatedly for every individual test fu
|
||||||
# This fixture will be executed repeatedly for every test
|
# This fixture will be executed repeatedly for every test
|
||||||
...
|
...
|
||||||
|
|
||||||
.. _testing_plugins_pytest_httpx:
|
|
||||||
|
|
||||||
Testing outbound HTTP calls with pytest-httpx
|
|
||||||
---------------------------------------------
|
|
||||||
|
|
||||||
If your plugin makes outbound HTTP calls - for example datasette-auth-github or datasette-import-table - you may need to mock those HTTP requests in your tests.
|
|
||||||
|
|
||||||
The `pytest-httpx <https://pypi.org/project/pytest-httpx/>`__ package is a useful library for mocking calls. It can be tricky to use with Datasette though since it mocks all HTTPX requests, and Datasette's own testing mechanism uses HTTPX internally.
|
|
||||||
|
|
||||||
To avoid breaking your tests, you can return ``["localhost"]`` from the ``non_mocked_hosts()`` fixture.
|
|
||||||
|
|
||||||
As an example, here's a very simple plugin which executes an HTTP response and returns the resulting content:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def register_routes():
|
|
||||||
return [
|
|
||||||
(r"^/-/fetch-url$", fetch_url),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_url(datasette, request):
|
|
||||||
if request.method == "GET":
|
|
||||||
return Response.html("""
|
|
||||||
<form action="/-/fetch-url" method="post">
|
|
||||||
<input name="url"><input type="submit">
|
|
||||||
</form>""")
|
|
||||||
vars = await request.post_vars()
|
|
||||||
url = vars["url"]
|
|
||||||
return Response.text(httpx.get(url).text)
|
|
||||||
|
|
||||||
Here's a test for that plugin that mocks the HTTPX outbound request:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.app import Datasette
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def non_mocked_hosts():
|
|
||||||
# This ensures httpx-mock will not affect Datasette's own
|
|
||||||
# httpx calls made in the tests by datasette.client:
|
|
||||||
return ["localhost"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_outbound_http_call(httpx_mock):
|
|
||||||
httpx_mock.add_response(
|
|
||||||
url="https://www.example.com/",
|
|
||||||
text="Hello world",
|
|
||||||
)
|
|
||||||
datasette = Datasette([], memory=True)
|
|
||||||
response = await datasette.client.post(
|
|
||||||
"/-/fetch-url",
|
|
||||||
data={"url": "https://www.example.com/"},
|
|
||||||
)
|
|
||||||
assert response.text == "Hello world"
|
|
||||||
|
|
||||||
outbound_request = httpx_mock.get_request()
|
|
||||||
assert (
|
|
||||||
outbound_request.url == "https://www.example.com/"
|
|
||||||
)
|
|
||||||
|
|
||||||
.. _testing_plugins_register_in_test:
|
.. _testing_plugins_register_in_test:
|
||||||
|
|
||||||
Registering a plugin for the duration of a test
|
Registering a plugin for the duration of a test
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ dependencies = [
|
||||||
"click-default-group>=1.2.3",
|
"click-default-group>=1.2.3",
|
||||||
"Jinja2>=2.10.3",
|
"Jinja2>=2.10.3",
|
||||||
"hupper>=1.9",
|
"hupper>=1.9",
|
||||||
"httpx>=0.20,<1.0",
|
"httpx2>=2.0",
|
||||||
"pluggy>=1.0",
|
"pluggy>=1.0",
|
||||||
"uvicorn>=0.29",
|
"uvicorn>=0.11",
|
||||||
"aiofiles>=0.4",
|
"aiofiles>=0.4",
|
||||||
"PyYAML>=5.3",
|
"PyYAML>=5.3",
|
||||||
"mergedeep>=1.1.1",
|
"mergedeep>=1.1.1",
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import importlib.metadata
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
@ -33,31 +32,17 @@ UNDOCUMENTED_PERMISSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def wait_until_responds(url, timeout=5.0, client=httpx, process=None, **kwargs):
|
def wait_until_responds(url, timeout=5.0, client=httpx2, **kwargs):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
# If the server died there is no point waiting out the timeout - fail
|
|
||||||
# now, with its output, instead of after `timeout` seconds of silence
|
|
||||||
if process is not None and process.poll() is not None:
|
|
||||||
raise AssertionError(
|
|
||||||
"Server exited early with returncode {}\n{}".format(
|
|
||||||
process.returncode, process.stdout.read().decode("utf-8")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
client.get(url, **kwargs)
|
client.get(url, **kwargs)
|
||||||
return
|
return
|
||||||
except httpx.TransportError:
|
except httpx2.ConnectError:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
raise AssertionError(f"Timed out waiting for {url} to respond")
|
raise AssertionError(f"Timed out waiting for {url} to respond")
|
||||||
|
|
||||||
|
|
||||||
def find_free_port():
|
|
||||||
with socket.socket() as sock:
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
return sock.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bare_ds():
|
def bare_ds():
|
||||||
"""
|
"""
|
||||||
|
|
@ -292,8 +277,8 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
||||||
cwd=tempfile.gettempdir(),
|
cwd=tempfile.gettempdir(),
|
||||||
)
|
)
|
||||||
# Poll until available
|
# Poll until available
|
||||||
transport = httpx.HTTPTransport(uds=uds)
|
transport = httpx2.HTTPTransport(uds=uds)
|
||||||
client = httpx.Client(transport=transport)
|
client = httpx2.Client(transport=transport)
|
||||||
try:
|
try:
|
||||||
wait_until_responds(
|
wait_until_responds(
|
||||||
"http://localhost/_memory.json", timeout=30.0, client=client
|
"http://localhost/_memory.json", timeout=30.0, client=client
|
||||||
|
|
@ -316,71 +301,6 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def serve_with_plugins(tmp_path):
|
|
||||||
"""Factory fixture for starting ``datasette serve`` in a subprocess with
|
|
||||||
plugins written to a temporary ``--plugins-dir``.
|
|
||||||
|
|
||||||
For tests that need the real serve path: event-loop wiring, exit codes,
|
|
||||||
signals. The usual in-process ``pm.register`` plugin pattern can't reach
|
|
||||||
a subprocess, so plugin source is written out as importable files instead.
|
|
||||||
|
|
||||||
Unlike ``ds_localhost_http_server`` this is function-scoped and takes a
|
|
||||||
fresh port each time, because each test needs its own plugins. Call it as::
|
|
||||||
|
|
||||||
proc, port = serve_with_plugins({"my_plugin": PLUGIN_SOURCE})
|
|
||||||
|
|
||||||
``plugins`` maps module name to Python source. Pass
|
|
||||||
``wait_for_startup=False`` when the server is expected to fail during
|
|
||||||
startup rather than begin serving. Extra CLI arguments are passed through.
|
|
||||||
Every process started is terminated when the test ends.
|
|
||||||
"""
|
|
||||||
processes = []
|
|
||||||
|
|
||||||
def start(plugins, *extra_args, wait_for_startup=True):
|
|
||||||
plugins_dir = tmp_path / "plugins"
|
|
||||||
plugins_dir.mkdir(exist_ok=True)
|
|
||||||
for module_name, source in plugins.items():
|
|
||||||
(plugins_dir / f"{module_name}.py").write_text(source, "utf-8")
|
|
||||||
port = find_free_port()
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"datasette",
|
|
||||||
"--memory",
|
|
||||||
"--plugins-dir",
|
|
||||||
str(plugins_dir),
|
|
||||||
"-h",
|
|
||||||
"127.0.0.1",
|
|
||||||
"-p",
|
|
||||||
str(port),
|
|
||||||
*extra_args,
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
|
||||||
cwd=tempfile.gettempdir(),
|
|
||||||
)
|
|
||||||
processes.append(proc)
|
|
||||||
if wait_for_startup:
|
|
||||||
wait_until_responds(
|
|
||||||
f"http://127.0.0.1:{port}/-/versions.json", process=proc
|
|
||||||
)
|
|
||||||
return proc, port
|
|
||||||
|
|
||||||
yield start
|
|
||||||
|
|
||||||
for proc in processes:
|
|
||||||
if proc.poll() is None:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
proc.wait()
|
|
||||||
|
|
||||||
|
|
||||||
# Import fixtures from fixtures.py to make them available
|
# Import fixtures from fixtures.py to make them available
|
||||||
from .fixtures import ( # noqa: F401
|
from .fixtures import ( # noqa: F401
|
||||||
TEMP_PLUGIN_SECRET_FILE,
|
TEMP_PLUGIN_SECRET_FILE,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
@pytest.mark.serial
|
||||||
def test_serve_localhost_http(ds_localhost_http_server):
|
def test_serve_localhost_http(ds_localhost_http_server):
|
||||||
response = httpx.get("http://localhost:8041/_memory.json")
|
response = httpx2.get("http://localhost:8041/_memory.json")
|
||||||
assert {
|
assert {
|
||||||
"database": "_memory",
|
"database": "_memory",
|
||||||
"path": "/_memory",
|
"path": "/_memory",
|
||||||
|
|
@ -21,127 +20,11 @@ def test_serve_localhost_http(ds_localhost_http_server):
|
||||||
)
|
)
|
||||||
def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
|
def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
|
||||||
_, uds = ds_unix_domain_socket_server
|
_, uds = ds_unix_domain_socket_server
|
||||||
transport = httpx.HTTPTransport(uds=uds)
|
transport = httpx2.HTTPTransport(uds=uds)
|
||||||
client = httpx.Client(transport=transport)
|
client = httpx2.Client(transport=transport)
|
||||||
response = client.get("http://localhost/_memory.json")
|
response = client.get("http://localhost/_memory.json")
|
||||||
assert {
|
assert {
|
||||||
"database": "_memory",
|
"database": "_memory",
|
||||||
"path": "/_memory",
|
"path": "/_memory",
|
||||||
"tables": [],
|
"tables": [],
|
||||||
}.items() <= response.json().items()
|
}.items() <= response.json().items()
|
||||||
|
|
||||||
|
|
||||||
# Shaped after datasette-litestream's startup hook, which schedules a
|
|
||||||
# background task with asyncio.get_running_loop().create_task(...):
|
|
||||||
# https://github.com/datasette/datasette-litestream
|
|
||||||
MARKER_TASK_PLUGIN = """
|
|
||||||
import asyncio
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
datasette._startup_calls = getattr(datasette, "_startup_calls", 0) + 1
|
|
||||||
|
|
||||||
async def _mark():
|
|
||||||
# Must await before setting the flag: a task with no internal
|
|
||||||
# await point could finish on the throwaway loop before it
|
|
||||||
# closed, masking the regression this test guards against.
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
datasette._marker_task_ran = True
|
|
||||||
|
|
||||||
asyncio.get_running_loop().create_task(_mark())
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def register_routes():
|
|
||||||
async def marker_status(datasette):
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"marker_task_ran": getattr(datasette, "_marker_task_ran", False),
|
|
||||||
"startup_calls": getattr(datasette, "_startup_calls", 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return [(r"^/-/marker-task-ran$", marker_status)]
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
STARTUP_ERROR_PLUGIN = """
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils import StartupError
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
raise StartupError("boom from plugin")
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
def test_startup_hook_background_task_runs_on_serving_loop(serve_with_plugins):
|
|
||||||
"""
|
|
||||||
Litestream-shaped regression test: a startup hook that does
|
|
||||||
asyncio.get_running_loop().create_task(...) must have that task
|
|
||||||
actually execute before/while the server is handling requests. This
|
|
||||||
only holds if invoke_startup() and uvicorn.Server.serve() share one
|
|
||||||
event loop. This test fails against unmodified main, where
|
|
||||||
invoke_startup() runs on a throwaway loop that is closed before
|
|
||||||
uvicorn opens its own loop to serve.
|
|
||||||
"""
|
|
||||||
_, port = serve_with_plugins({"marker_task_plugin": MARKER_TASK_PLUGIN})
|
|
||||||
# The fixture has already waited for the server to answer requests. The
|
|
||||||
# marker task deliberately awaits before setting its flag, so poll for a
|
|
||||||
# moment rather than assuming it landed before the first request arrived.
|
|
||||||
deadline = time.time() + 3.0
|
|
||||||
payload = {}
|
|
||||||
while time.time() < deadline:
|
|
||||||
payload = httpx.get(
|
|
||||||
f"http://127.0.0.1:{port}/-/marker-task-ran", timeout=1.0
|
|
||||||
).json()
|
|
||||||
if payload["marker_task_ran"]:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
assert payload.get("marker_task_ran"), (
|
|
||||||
"The startup hook's asyncio.create_task(...) never ran - "
|
|
||||||
"invoke_startup() and the server are not sharing an event loop"
|
|
||||||
)
|
|
||||||
# Polling above means this test would also pass if the startup hook were
|
|
||||||
# re-run on the serving loop by the first-request fallback - which would
|
|
||||||
# hide exactly the bug being tested. invoke_startup() is idempotent today
|
|
||||||
# so that cannot happen; assert it explicitly so that if the idempotency
|
|
||||||
# guard is ever removed this test fails loudly instead of silently
|
|
||||||
# becoming a no-op.
|
|
||||||
assert payload["startup_calls"] == 1, (
|
|
||||||
"startup hook ran {} times - the marker may have been set by a "
|
|
||||||
"re-run on the serving loop rather than by the original task".format(
|
|
||||||
payload["startup_calls"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
def test_startup_error_fails_fast_before_port_binds(serve_with_plugins):
|
|
||||||
"""
|
|
||||||
A "startup" plugin hook that raises StartupError must fail fast: print
|
|
||||||
the message, exit non-zero, and never accept a connection on the port -
|
|
||||||
the failure must happen before uvicorn.Server binds the socket.
|
|
||||||
"""
|
|
||||||
proc, port = serve_with_plugins(
|
|
||||||
{"startup_error_plugin": STARTUP_ERROR_PLUGIN}, wait_for_startup=False
|
|
||||||
)
|
|
||||||
stdout, _ = proc.communicate(timeout=15)
|
|
||||||
output = stdout.decode("utf-8")
|
|
||||||
assert proc.returncode not in (0, None), output
|
|
||||||
assert "boom from plugin" in output, output
|
|
||||||
|
|
||||||
# Nothing is listening on the port now the process has exited. This
|
|
||||||
# confirms the socket was not left bound; on its own it cannot prove the
|
|
||||||
# failure preceded the bind, since a port nothing ever touched also
|
|
||||||
# refuses connections.
|
|
||||||
with (
|
|
||||||
pytest.raises(OSError),
|
|
||||||
socket.create_connection(("127.0.0.1", port), timeout=0.2),
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import httpx
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ async def datasette_with_permissions():
|
||||||
async def test_client_methods(datasette, method, path, expected_status):
|
async def test_client_methods(datasette, method, path, expected_status):
|
||||||
client_method = getattr(datasette.client, method)
|
client_method = getattr(datasette.client, method)
|
||||||
response = await client_method(path)
|
response = await client_method(path)
|
||||||
assert isinstance(response, httpx.Response)
|
assert isinstance(response, httpx2.Response)
|
||||||
assert response.status_code == expected_status
|
assert response.status_code == expected_status
|
||||||
# Try that again using datasette.client.request
|
# Try that again using datasette.client.request
|
||||||
response2 = await datasette.client.request(method, path)
|
response2 = await datasette.client.request(method, path)
|
||||||
|
|
@ -63,7 +63,7 @@ async def test_client_post(datasette, prefix):
|
||||||
"message": "A message",
|
"message": "A message",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert isinstance(response, httpx.Response)
|
assert isinstance(response, httpx2.Response)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert "ds_messages" in response.cookies
|
assert "ds_messages" in response.cookies
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -135,7 +135,7 @@ async def test_skip_permission_checks_all_methods(datasette_with_permissions, me
|
||||||
response = await client_method("/test_db.json", skip_permission_checks=True)
|
response = await client_method("/test_db.json", skip_permission_checks=True)
|
||||||
# We don't check status code since some methods might not be allowed,
|
# We don't check status code since some methods might not be allowed,
|
||||||
# but we verify the request doesn't fail due to permissions
|
# but we verify the request doesn't fail due to permissions
|
||||||
assert isinstance(response, httpx.Response)
|
assert isinstance(response, httpx2.Response)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -340,7 +340,7 @@ async def test_actor_parameter_all_http_methods(datasette, method):
|
||||||
client_method = getattr(datasette.client, method)
|
client_method = getattr(datasette.client, method)
|
||||||
# Just verify no TypeError about unexpected 'actor' kwarg
|
# Just verify no TypeError about unexpected 'actor' kwarg
|
||||||
response = await client_method("/", actor={"id": "root"})
|
response = await client_method("/", actor={"id": "root"})
|
||||||
assert isinstance(response, httpx.Response)
|
assert isinstance(response, httpx2.Response)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
"""
|
|
||||||
Tests for wiring Datasette startup (setup_db table counts + invoke_startup)
|
|
||||||
into the ASGI lifespan protocol.
|
|
||||||
|
|
||||||
These exercise Datasette._startup_sequence() via three different callers:
|
|
||||||
- AsgiLifespan, by hand-driving lifespan.startup messages (no HTTP request)
|
|
||||||
- AsgiRunOnFirstRequest, the fallback for hosts that never send lifespan
|
|
||||||
events (this is what DatasetteClient / plain httpx.ASGITransport uses)
|
|
||||||
- Both at once, to prove startup hooks run at most once
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.app import Datasette
|
|
||||||
from datasette.database import Database
|
|
||||||
from datasette.plugins import pm
|
|
||||||
|
|
||||||
|
|
||||||
async def _drive_lifespan_startup(app):
|
|
||||||
"""Send a single lifespan.startup message into app's ASGI lifespan loop
|
|
||||||
and return the list of messages sent back - without ever sending
|
|
||||||
lifespan.shutdown. Mirrors what a real server does: after startup
|
|
||||||
completes it parks waiting for the next event. We cancel that wait
|
|
||||||
once we've observed the startup response, rather than closing the
|
|
||||||
Datasette instance down with a shutdown message.
|
|
||||||
"""
|
|
||||||
messages_sent = []
|
|
||||||
startup_responded = asyncio.Event()
|
|
||||||
delivered = False
|
|
||||||
|
|
||||||
async def receive():
|
|
||||||
nonlocal delivered
|
|
||||||
if not delivered:
|
|
||||||
delivered = True
|
|
||||||
return {"type": "lifespan.startup"}
|
|
||||||
# No further messages: block until the task is cancelled below,
|
|
||||||
# same as a real server parked waiting for lifespan.shutdown.
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
async def send(message):
|
|
||||||
messages_sent.append(message)
|
|
||||||
startup_responded.set()
|
|
||||||
|
|
||||||
task = asyncio.create_task(app({"type": "lifespan"}, receive, send))
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(startup_responded.wait(), timeout=5)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
return messages_sent
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lifespan_startup_runs_before_any_request():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
assert ds._startup_invoked is False
|
|
||||||
app = ds.app()
|
|
||||||
|
|
||||||
messages = await _drive_lifespan_startup(app)
|
|
||||||
|
|
||||||
assert {"type": "lifespan.startup.complete"} in messages
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
# Internal catalog tables should be populated too, entirely without an
|
|
||||||
# HTTP request having been made.
|
|
||||||
internal_db = ds.get_internal_database()
|
|
||||||
databases = await internal_db.execute("select * from catalog_databases")
|
|
||||||
assert len(databases.rows) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lifespan_startup_failure_reports_lifespan_startup_failed():
|
|
||||||
class RaisingStartupPlugin:
|
|
||||||
__name__ = "RaisingStartupPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
raise RuntimeError("boom from startup hook")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(RaisingStartupPlugin(), name="raising_startup_plugin")
|
|
||||||
try:
|
|
||||||
app = ds.app()
|
|
||||||
messages = await _drive_lifespan_startup(app)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="raising_startup_plugin")
|
|
||||||
|
|
||||||
assert messages == [
|
|
||||||
{"type": "lifespan.startup.failed", "message": "boom from startup hook"}
|
|
||||||
]
|
|
||||||
# The exception happened before invoke_startup() got to the end of its
|
|
||||||
# body, so startup is not considered to have completed.
|
|
||||||
assert ds._startup_invoked is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_runs_exactly_once_across_lifespan_and_first_request():
|
|
||||||
call_count = {"n": 0}
|
|
||||||
|
|
||||||
class CountingStartupPlugin:
|
|
||||||
__name__ = "CountingStartupPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
call_count["n"] += 1
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(CountingStartupPlugin(), name="counting_startup_plugin")
|
|
||||||
try:
|
|
||||||
# Build the ASGI app once, the way a real deployment does - and
|
|
||||||
# reuse the SAME app instance for both the lifespan drive and the
|
|
||||||
# HTTP requests below, since a fresh ds.app() call would reset the
|
|
||||||
# AsgiRunOnFirstRequest fallback's state.
|
|
||||||
app = ds.app()
|
|
||||||
|
|
||||||
messages = await _drive_lifespan_startup(app)
|
|
||||||
assert {"type": "lifespan.startup.complete"} in messages
|
|
||||||
assert call_count["n"] == 1
|
|
||||||
|
|
||||||
# A first HTTP request (as if the host never sent lifespan events,
|
|
||||||
# or lifespan already ran) should not run the hook again.
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport, base_url="http://localhost"
|
|
||||||
) as client:
|
|
||||||
response1 = await client.get("/-/versions.json")
|
|
||||||
assert response1.status_code == 200
|
|
||||||
# ... nor should a second, repeat request.
|
|
||||||
response2 = await client.get("/-/versions.json")
|
|
||||||
assert response2.status_code == 200
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="counting_startup_plugin")
|
|
||||||
|
|
||||||
assert call_count["n"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_lifespan_first_request_still_triggers_startup():
|
|
||||||
# Pin today's behavior: a client that never drives ASGI lifespan events
|
|
||||||
# at all (like httpx.ASGITransport, which DatasetteClient uses) still
|
|
||||||
# gets startup armed by the AsgiRunOnFirstRequest fallback.
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
assert ds._startup_invoked is False
|
|
||||||
app = ds.app()
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport, base_url="http://localhost"
|
|
||||||
) as client:
|
|
||||||
response = await client.get("/-/versions.json")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
internal_db = ds.get_internal_database()
|
|
||||||
databases = await internal_db.execute("select * from catalog_databases")
|
|
||||||
assert len(databases.rows) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_datasette_client_first_request_triggers_startup():
|
|
||||||
# Same as above, but through the real DatasetteClient (ds.client) that
|
|
||||||
# plugins and tests actually use, to confirm nothing regressed there.
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
assert ds._startup_invoked is False
|
|
||||||
response = await ds.client.get("/-/versions.json")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_first_requests_all_wait_for_slow_startup():
|
|
||||||
call_count = {"n": 0}
|
|
||||||
|
|
||||||
class SlowStartupPlugin:
|
|
||||||
__name__ = "SlowStartupPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
call_count["n"] += 1
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(SlowStartupPlugin(), name="slow_startup_plugin")
|
|
||||||
try:
|
|
||||||
app = ds.app()
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport, base_url="http://localhost"
|
|
||||||
) as client:
|
|
||||||
responses = await asyncio.gather(
|
|
||||||
*[client.get("/-/versions.json") for _ in range(10)]
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="slow_startup_plugin")
|
|
||||||
|
|
||||||
# Every one of the 10 simultaneous first requests must have blocked
|
|
||||||
# until startup actually finished, not raced ahead of it.
|
|
||||||
assert all(response.status_code == 200 for response in responses)
|
|
||||||
assert call_count["n"] == 1
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_setup_db_still_runs_when_invoke_startup_ran_first(tmp_path, monkeypatch):
|
|
||||||
# Regression test: `datasette serve` (cli.py _serve_async) calls
|
|
||||||
# ds.invoke_startup() directly, before uvicorn ever sends a
|
|
||||||
# lifespan.startup event that drives _startup_sequence(). If
|
|
||||||
# _startup_sequence()'s fast path only checked `_startup_invoked`, it
|
|
||||||
# would see startup already done and skip the immutable-database
|
|
||||||
# table-count precompute (setup_db) entirely - a silent regression
|
|
||||||
# versus main, where AsgiRunOnFirstRequest ran setup_db unconditionally
|
|
||||||
# on request #1.
|
|
||||||
db_path = tmp_path / "immutable.db"
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.execute("create table t (id integer primary key)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
ds = Datasette([], immutables=[str(db_path)])
|
|
||||||
|
|
||||||
call_count = {"n": 0}
|
|
||||||
original_table_counts = Database.table_counts
|
|
||||||
|
|
||||||
async def counting_table_counts(self, *args, **kwargs):
|
|
||||||
call_count["n"] += 1
|
|
||||||
return await original_table_counts(self, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Database, "table_counts", counting_table_counts)
|
|
||||||
|
|
||||||
# Simulate the CLI path: invoke_startup() runs directly and completes
|
|
||||||
# BEFORE _startup_sequence() ever gets a chance to run setup_db.
|
|
||||||
await ds.invoke_startup()
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
assert call_count["n"] == 0
|
|
||||||
|
|
||||||
# The lifespan/first-request path (or the CLI itself, per the fix)
|
|
||||||
# calling the shared entry point afterwards must still precompute
|
|
||||||
# table counts for immutable databases.
|
|
||||||
await ds._startup_sequence()
|
|
||||||
assert call_count["n"] == 1
|
|
||||||
assert ds._setup_db_done is True
|
|
||||||
|
|
||||||
# Idempotency: a second call must not recompute.
|
|
||||||
await ds._startup_sequence()
|
|
||||||
assert call_count["n"] == 1
|
|
||||||
|
|
@ -5,7 +5,7 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from datasette.fixtures import write_fixture_database
|
from datasette.fixtures import write_fixture_database
|
||||||
|
|
@ -34,11 +34,11 @@ def wait_for_server(process, url, timeout=30):
|
||||||
f"stderr:\n{stderr}"
|
f"stderr:\n{stderr}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = httpx.get(url, timeout=1.0)
|
response = httpx2.get(url, timeout=1.0)
|
||||||
if response.status_code < 500:
|
if response.status_code < 500:
|
||||||
return
|
return
|
||||||
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
|
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
|
||||||
except httpx.HTTPError as ex:
|
except httpx2.HTTPError as ex:
|
||||||
last_error = repr(ex)
|
last_error = repr(ex)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if process.poll() is None:
|
if process.poll() is None:
|
||||||
|
|
@ -336,7 +336,7 @@ def project_rows(datasette_server, **filters):
|
||||||
"_shape": "objects",
|
"_shape": "objects",
|
||||||
**{key: str(value) for key, value in filters.items()},
|
**{key: str(value) for key, value in filters.items()},
|
||||||
}
|
}
|
||||||
response = httpx.get(f"{datasette_server}data/projects.json", params=params)
|
response = httpx2.get(f"{datasette_server}data/projects.json", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()["rows"]
|
return response.json()["rows"]
|
||||||
|
|
||||||
|
|
@ -348,7 +348,7 @@ def project_row(datasette_server, pk):
|
||||||
|
|
||||||
|
|
||||||
def binary_file_blob(datasette_server, pk):
|
def binary_file_blob(datasette_server, pk):
|
||||||
response = httpx.get(
|
response = httpx2.get(
|
||||||
f"{datasette_server}data/binary_files/{pk}.blob",
|
f"{datasette_server}data/binary_files/{pk}.blob",
|
||||||
params={"_blob_column": "data"},
|
params={"_blob_column": "data"},
|
||||||
)
|
)
|
||||||
|
|
@ -369,7 +369,7 @@ def bulk_default_rows(datasette_server, **filters):
|
||||||
"_shape": "objects",
|
"_shape": "objects",
|
||||||
**{key: str(value) for key, value in filters.items()},
|
**{key: str(value) for key, value in filters.items()},
|
||||||
}
|
}
|
||||||
response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
|
response = httpx2.get(f"{datasette_server}data/bulk_defaults.json", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()["rows"]
|
return response.json()["rows"]
|
||||||
|
|
||||||
|
|
@ -379,7 +379,7 @@ def upsert_item_rows(datasette_server, **filters):
|
||||||
"_shape": "objects",
|
"_shape": "objects",
|
||||||
**{key: str(value) for key, value in filters.items()},
|
**{key: str(value) for key, value in filters.items()},
|
||||||
}
|
}
|
||||||
response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
|
response = httpx2.get(f"{datasette_server}data/upsert_items.json", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()["rows"]
|
return response.json()["rows"]
|
||||||
|
|
||||||
|
|
@ -473,7 +473,7 @@ def test_create_table_flow(page, datasette_server):
|
||||||
page.wait_for_url("**/data/playwright_created")
|
page.wait_for_url("**/data/playwright_created")
|
||||||
assert "playwright_created" in page.locator("h1").inner_text()
|
assert "playwright_created" in page.locator("h1").inner_text()
|
||||||
|
|
||||||
response = httpx.get(
|
response = httpx2.get(
|
||||||
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
|
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -487,7 +487,7 @@ def test_create_table_flow(page, datasette_server):
|
||||||
assert data["column_types"] == {
|
assert data["column_types"] == {
|
||||||
"metadata": {"type": "json", "config": None},
|
"metadata": {"type": "json", "config": None},
|
||||||
}
|
}
|
||||||
schema_response = httpx.get(
|
schema_response = httpx2.get(
|
||||||
f"{datasette_server}data/-/query.json",
|
f"{datasette_server}data/-/query.json",
|
||||||
params={
|
params={
|
||||||
"sql": (
|
"sql": (
|
||||||
|
|
@ -603,7 +603,7 @@ def test_create_table_from_data_flow(page, datasette_server):
|
||||||
dialog.locator(".table-create-save").click()
|
dialog.locator(".table-create-save").click()
|
||||||
page.wait_for_url("**/data/playwright_from_data")
|
page.wait_for_url("**/data/playwright_from_data")
|
||||||
|
|
||||||
response = httpx.get(
|
response = httpx2.get(
|
||||||
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
|
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -639,7 +639,7 @@ def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
|
||||||
dialog.locator(".table-create-save").click()
|
dialog.locator(".table-create-save").click()
|
||||||
page.wait_for_url("**/data/playwright_numeric_blanks")
|
page.wait_for_url("**/data/playwright_numeric_blanks")
|
||||||
|
|
||||||
response = httpx.get(
|
response = httpx2.get(
|
||||||
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
|
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -648,7 +648,7 @@ def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
|
||||||
{"name": "B", "score": None},
|
{"name": "B", "score": None},
|
||||||
]
|
]
|
||||||
|
|
||||||
schema_response = httpx.get(
|
schema_response = httpx2.get(
|
||||||
f"{datasette_server}data/-/query.json",
|
f"{datasette_server}data/-/query.json",
|
||||||
params={
|
params={
|
||||||
"sql": (
|
"sql": (
|
||||||
|
|
@ -856,7 +856,7 @@ def test_alter_table_flow(page, datasette_server):
|
||||||
|
|
||||||
columns = []
|
columns = []
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns")
|
response = httpx2.get(f"{datasette_server}data/projects.json?_extra=columns")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
columns = response.json()["columns"]
|
columns = response.json()["columns"]
|
||||||
if "status" in columns:
|
if "status" in columns:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue