mirror of
https://github.com/simonw/datasette.git
synced 2026-09-03 15:14:08 +02:00
Merge 9813016b22 into bdc9731740
This commit is contained in:
commit
cae36803bd
9 changed files with 901 additions and 6 deletions
|
|
@ -1,6 +1,7 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.background_tasks import BackgroundTask, BackgroundTaskSupervisor # noqa
|
||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import ( # noqa
|
||||
Forbidden,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from jinja2.exceptions import TemplateNotFound
|
|||
from markupsafe import Markup, escape
|
||||
|
||||
from . import stored_queries, write_sql
|
||||
from .background_tasks import BackgroundTask, BackgroundTaskSupervisor
|
||||
from .column_types import SQLiteType
|
||||
from .csrf import CrossOriginProtectionMiddleware
|
||||
from .database import Database, QueryInterrupted
|
||||
|
|
@ -454,6 +455,7 @@ class Datasette:
|
|||
self.actions = {} # .invoke_startup() will populate this
|
||||
self._column_types = {} # .invoke_startup() will populate this
|
||||
self._setup_db_done = False
|
||||
self._suppress_background_tasks = False
|
||||
try:
|
||||
self._refresh_schemas_lock = asyncio.Lock()
|
||||
self._startup_lock = asyncio.Lock()
|
||||
|
|
@ -467,6 +469,7 @@ class Datasette:
|
|||
self._startup_lock = asyncio.Lock()
|
||||
else:
|
||||
raise
|
||||
self._background_tasks = BackgroundTaskSupervisor(self)
|
||||
self.crossdb = crossdb
|
||||
self.nolock = nolock
|
||||
if memory or crossdb or not self.files:
|
||||
|
|
@ -2836,6 +2839,63 @@ class Datasette:
|
|||
self._setup_db_done = True
|
||||
await self.invoke_startup()
|
||||
|
||||
def add_background_task(self, func, name=None) -> BackgroundTask:
|
||||
"""Register a piece of supervised background work, typically from
|
||||
a plugin's ``startup`` hook.
|
||||
|
||||
``func`` must be a coroutine function taking one positional
|
||||
argument, the ``Datasette`` instance - core calls ``func(self)``.
|
||||
Callable any time after ``__init__``: if background tasks haven't
|
||||
launched yet (the common case - most callers are ``startup`` hooks,
|
||||
which run before launch), this buffers the registration until they
|
||||
do; if they've already launched (e.g. called from a request
|
||||
handler after the server is up), the task starts immediately.
|
||||
|
||||
Returns a :class:`~datasette.background_tasks.BackgroundTask`
|
||||
handle (``.name``, ``.state``, ``.task``, ``.exception``,
|
||||
``.started_at``, ``.plugin``, ``.cancel()``).
|
||||
|
||||
``name`` defaults to ``func.__qualname__``; on a name collision a
|
||||
``-2``, ``-3``, ... suffix is appended, since names are how
|
||||
``/-/tasks`` and log messages identify work.
|
||||
"""
|
||||
return self._background_tasks.add(func, name=name)
|
||||
|
||||
async def start_background_tasks(self):
|
||||
"""Run startup (if it hasn't run yet) and launch every registered
|
||||
background task.
|
||||
|
||||
Public entry point for tests, embedders, and headless CLIs (the
|
||||
``datasette-rss``-style ``fetch --due`` shape) that want supervised
|
||||
background tasks without running a server - equivalent to what
|
||||
happens automatically via ASGI lifespan / the first-request
|
||||
fallback in a served deployment.
|
||||
"""
|
||||
await self.invoke_startup()
|
||||
await self._background_tasks.launch_all()
|
||||
|
||||
async def _launch_background_tasks(self):
|
||||
"""Idempotently launch every registered background task. Private:
|
||||
this is the entry point wired into the lifecycle trigger lists
|
||||
(the second entry in both ``AsgiLifespan`` and
|
||||
``AsgiRunOnFirstRequest``'s ``on_startup``, after
|
||||
``_startup_sequence``) - not something plugins or embedders should
|
||||
call directly; use ``add_background_task`` /
|
||||
``start_background_tasks`` instead.
|
||||
|
||||
Positioned after ``_startup_sequence`` in both trigger lists so
|
||||
launch always happens once every plugin's ``startup`` hook has had
|
||||
a chance to register work - the ordering guarantee that makes
|
||||
``add_background_task`` useful. No-ops when
|
||||
``_suppress_background_tasks`` is set (the ``--get`` CLI path: its
|
||||
one-shot TestClient request flows through the full ASGI stack,
|
||||
including the first-request fallback, but must never launch
|
||||
long-lived background work).
|
||||
"""
|
||||
if self._suppress_background_tasks:
|
||||
return
|
||||
await self._background_tasks.launch_all()
|
||||
|
||||
def app(self):
|
||||
"""Returns an ASGI app function that serves the whole of Datasette"""
|
||||
routes = self._routes()
|
||||
|
|
@ -2848,10 +2908,13 @@ class Datasette:
|
|||
asgi = AsgiTracer(asgi)
|
||||
asgi = AsgiLifespan(
|
||||
asgi,
|
||||
on_startup=[self._startup_sequence],
|
||||
on_startup=[self._startup_sequence, self._launch_background_tasks],
|
||||
on_shutdown=[_close_on_shutdown],
|
||||
)
|
||||
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[self._startup_sequence])
|
||||
asgi = AsgiRunOnFirstRequest(
|
||||
asgi,
|
||||
on_startup=[self._startup_sequence, self._launch_background_tasks],
|
||||
)
|
||||
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
||||
asgi = wrapper(asgi)
|
||||
return asgi
|
||||
|
|
|
|||
254
datasette/background_tasks.py
Normal file
254
datasette/background_tasks.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Supervised background-task registration for Datasette core.
|
||||
|
||||
Plugins that need long-lived background work (a polling loop, a queue
|
||||
consumer, a scheduled job runner) register it with
|
||||
``datasette.add_background_task(func, name=None)`` - typically from a
|
||||
``startup`` plugin hook - instead of fire-and-forgetting their own
|
||||
``asyncio.create_task()``. Core owns:
|
||||
|
||||
- **references**: every launched ``asyncio.Task`` is kept alive on a
|
||||
:class:`BackgroundTaskSupervisor`, so it can never be silently garbage
|
||||
collected the way an unreferenced ``create_task()`` call can be;
|
||||
- **launch timing**: registered work is buffered until
|
||||
:meth:`BackgroundTaskSupervisor.launch_all` runs, which core arranges to
|
||||
happen only after *every* plugin's ``startup`` hook has finished - so
|
||||
a task that depends on another plugin having registered something first
|
||||
doesn't need ``tryfirst=True`` ordering tricks;
|
||||
- **crash surfacing**: an unhandled exception in a background task is
|
||||
logged with its full traceback to the ``datasette.background_tasks``
|
||||
logger and recorded on the handle, instead of becoming an "Task
|
||||
exception was never retrieved" warning nobody sees;
|
||||
- **cancellation**: :meth:`BackgroundTaskSupervisor.cancel_all` cancels
|
||||
every task still running and waits (with a grace period) for them to
|
||||
actually stop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger("datasette.background_tasks")
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _resolve_plugin_name(func: Callable) -> str | None:
|
||||
"""Best-effort, cheap attempt to work out which registered plugin a
|
||||
background-task function belongs to, for the ``.plugin`` field on
|
||||
:class:`BackgroundTask` (used by ``/-/tasks`` and logs).
|
||||
|
||||
This matches ``func``'s module against every currently-registered
|
||||
pluggy plugin's module - the same module a plugin's ``startup`` hook
|
||||
implementation lives in, in the overwhelmingly common case where
|
||||
``add_background_task`` is called directly from (or a couple of
|
||||
frames below) that hook. It deliberately does *not* walk the call
|
||||
stack or otherwise try harder: this is a nice-to-have for
|
||||
introspection, not something worth building heavy machinery for, and
|
||||
returning ``None`` when it can't tell is a fine fallback.
|
||||
"""
|
||||
try:
|
||||
from .plugins import pm
|
||||
|
||||
module = inspect.getmodule(func)
|
||||
if module is None:
|
||||
return None
|
||||
module_name = getattr(module, "__name__", None)
|
||||
if not module_name:
|
||||
return None
|
||||
for plugin in pm.get_plugins():
|
||||
plugin_module = (
|
||||
plugin if inspect.ismodule(plugin) else inspect.getmodule(plugin)
|
||||
)
|
||||
if plugin_module is None:
|
||||
continue
|
||||
plugin_module_name = getattr(plugin_module, "__name__", None)
|
||||
if not plugin_module_name:
|
||||
continue
|
||||
if module_name == plugin_module_name or module_name.startswith(
|
||||
plugin_module_name + "."
|
||||
):
|
||||
return pm.get_name(plugin)
|
||||
except Exception: # noqa: BLE001
|
||||
# Never let plugin-name resolution break task registration.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class BackgroundTask:
|
||||
"""A handle to a single piece of supervised background work.
|
||||
|
||||
States: ``registered`` (added but not yet launched) -> ``running`` ->
|
||||
one of ``completed`` (returned cleanly), ``crashed`` (raised an
|
||||
exception other than ``CancelledError`` - see ``.exception``), or
|
||||
``cancelled`` (``.cancel()`` was called, or it was still running at
|
||||
shutdown).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[[object], Awaitable[None]],
|
||||
plugin: str | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.state = "registered"
|
||||
self.task: asyncio.Task | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.started_at: str | None = None
|
||||
self.plugin = plugin
|
||||
self._func = func
|
||||
self._supervisor: BackgroundTaskSupervisor | None = None
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel this task.
|
||||
|
||||
If it has already been launched, cancels the underlying
|
||||
``asyncio.Task`` - its state becomes ``cancelled`` once the
|
||||
cancellation is observed (asynchronously, via the task's done
|
||||
callback). If it has not been launched yet, this is a no-op as
|
||||
far as asyncio is concerned (there's no task to cancel) but it
|
||||
deregisters the handle from its supervisor so it never runs.
|
||||
"""
|
||||
if self.task is not None:
|
||||
self.task.cancel()
|
||||
elif self._supervisor is not None:
|
||||
self._supervisor._deregister(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BackgroundTask name={self.name!r} state={self.state!r}>"
|
||||
|
||||
|
||||
class BackgroundTaskSupervisor:
|
||||
"""Owns registration and launch of every :class:`BackgroundTask` for a
|
||||
single ``Datasette`` instance.
|
||||
|
||||
Registration (:meth:`add`) is separate from launch
|
||||
(:meth:`launch_all`): plugins register work whenever convenient
|
||||
(typically from a ``startup`` hook, but request handlers can register
|
||||
dynamic per-job work too), and it either sits buffered until
|
||||
:meth:`launch_all` runs, or - if :meth:`launch_all` has already run -
|
||||
starts immediately.
|
||||
|
||||
Strong references to every :class:`BackgroundTask` (and its
|
||||
``asyncio.Task``) are kept for the life of the instance, by design -
|
||||
that's what makes the enrichments-style "fire-and-forget task gets
|
||||
garbage collected mid-flight" bug impossible here. There is currently
|
||||
no pruning of completed/crashed/cancelled tasks, so a plugin that
|
||||
dynamically registers many short-lived tasks over a long process
|
||||
lifetime (a per-job registration pattern, e.g. one task per queued
|
||||
job) will grow this list without bound. That's an accepted v1
|
||||
trade-off in favour of full introspection (``/-/tasks``); revisit
|
||||
with a pruning or capping policy if unbounded growth is reported in
|
||||
practice.
|
||||
"""
|
||||
|
||||
def __init__(self, datasette):
|
||||
self._datasette = datasette
|
||||
self._tasks: list[BackgroundTask] = []
|
||||
self._names = set()
|
||||
self._launched = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def add(self, func, name=None) -> BackgroundTask:
|
||||
base_name = name or getattr(func, "__qualname__", None) or repr(func)
|
||||
actual_name = self._unique_name(base_name)
|
||||
plugin = _resolve_plugin_name(func)
|
||||
handle = BackgroundTask(actual_name, func, plugin=plugin)
|
||||
handle._supervisor = self
|
||||
self._tasks.append(handle)
|
||||
self._names.add(actual_name)
|
||||
if self._launched:
|
||||
self._launch_one(handle)
|
||||
return handle
|
||||
|
||||
def _unique_name(self, base_name: str) -> str:
|
||||
if base_name not in self._names:
|
||||
return base_name
|
||||
n = 2
|
||||
while f"{base_name}-{n}" in self._names:
|
||||
n += 1
|
||||
return f"{base_name}-{n}"
|
||||
|
||||
def _deregister(self, handle: BackgroundTask) -> None:
|
||||
try:
|
||||
self._tasks.remove(handle)
|
||||
except ValueError:
|
||||
pass
|
||||
self._names.discard(handle.name)
|
||||
|
||||
def _launch_one(self, handle: BackgroundTask) -> None:
|
||||
handle.state = "running"
|
||||
handle.started_at = _utcnow_iso()
|
||||
handle.task = asyncio.create_task(
|
||||
handle._func(self._datasette), name=handle.name
|
||||
)
|
||||
handle.task.add_done_callback(functools.partial(_on_task_done, handle))
|
||||
|
||||
async def launch_all(self) -> None:
|
||||
"""Launch every currently-registered task that hasn't launched
|
||||
yet. Idempotent and safe to call concurrently: subsequent (or
|
||||
racing) calls are no-ops once the first has set ``self._launched``.
|
||||
"""
|
||||
if self._launched:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._launched:
|
||||
return
|
||||
self._launched = True
|
||||
for handle in list(self._tasks):
|
||||
if handle.task is None:
|
||||
self._launch_one(handle)
|
||||
|
||||
async def cancel_all(self, grace: float = 5.0) -> None:
|
||||
"""Cancel every task that isn't already done, then wait up to
|
||||
``grace`` seconds for them to actually finish. Stragglers still
|
||||
running after that are logged by name (but left to finish or not
|
||||
on their own - this does not forcibly kill them, asyncio has no
|
||||
mechanism for that).
|
||||
"""
|
||||
handles_by_task = {
|
||||
handle.task: handle for handle in self._tasks if handle.task is not None
|
||||
}
|
||||
pending = [task for task in handles_by_task if not task.done()]
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if not pending:
|
||||
return
|
||||
_done, not_done = await asyncio.wait(pending, timeout=grace)
|
||||
if not_done:
|
||||
names = sorted(handles_by_task[task].name for task in not_done)
|
||||
logger.warning(
|
||||
"%d background task(s) did not finish within the %.1fs grace "
|
||||
"period after cancellation: %s",
|
||||
len(names),
|
||||
grace,
|
||||
", ".join(names),
|
||||
)
|
||||
|
||||
def tasks(self) -> list[BackgroundTask]:
|
||||
"""Return every registered :class:`BackgroundTask`, launched or
|
||||
not, in registration order. Used by the ``/-/tasks`` debug
|
||||
endpoint.
|
||||
"""
|
||||
return list(self._tasks)
|
||||
|
||||
|
||||
def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
handle.state = "cancelled"
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
handle.state = "crashed"
|
||||
handle.exception = exc
|
||||
logger.error("Background task %r crashed", handle.name, exc_info=exc)
|
||||
return
|
||||
handle.state = "completed"
|
||||
|
|
@ -678,6 +678,11 @@ def serve(
|
|||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
# --get never launches background tasks: TestClient's request below
|
||||
# flows through the full ASGI stack, including the
|
||||
# AsgiRunOnFirstRequest fallback, which would otherwise launch them.
|
||||
ds._suppress_background_tasks = True
|
||||
|
||||
client = TestClient(ds)
|
||||
request_headers = {}
|
||||
if token:
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,139 @@ Release all resources held by this ``Datasette`` instance. This calls :ref:`data
|
|||
|
||||
If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed.
|
||||
|
||||
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``.
|
||||
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``. See :ref:`datasette_lifecycle` for where ``close()`` fits into the full startup-to-shutdown sequence.
|
||||
|
||||
.. _datasette_lifecycle:
|
||||
|
||||
Application lifecycle
|
||||
---------------------
|
||||
|
||||
Datasette guarantees a fixed sequence of events between the moment a ``Datasette`` instance is constructed and the moment its resources are released:
|
||||
|
||||
1. ``Datasette(...)`` — the constructor runs synchronously and does not run plugin hooks.
|
||||
2. **Startup** — ``await datasette.invoke_startup()`` runs once: it populates the internal database's catalog of table schemas (:ref:`internals_internal`), loads canned queries and column type configuration, then calls every registered :ref:`plugin_hook_startup` hook, in plugin registration order. When Datasette is being served, table-count precomputation for immutable databases runs immediately before this, as part of the same startup sequence.
|
||||
3. **Background-task launch** — once *every* ``startup`` hook has finished (not before), every task registered with :ref:`datasette_add_background_task` — by any plugin — is launched. A task registered by one plugin's ``startup`` hook can safely depend on state set up by another plugin's ``startup`` hook, because launch only happens after the whole round of hooks completes.
|
||||
4. **Serving** — the instance handles requests (or, for headless or CLI use, does whatever the embedding program does with it).
|
||||
5. **Shutdown** — triggered by the ASGI ``lifespan.shutdown`` event (Ctrl-C, ``SIGTERM``) or the end of a ``datasette serve`` process: every still-running background task is cancelled and given a five-second grace period to actually stop; finally every database connection is released via :ref:`datasette_close`.
|
||||
|
||||
.. admonition:: Startup hooks run on the event loop that serves requests
|
||||
|
||||
In every trigger path below, ``startup`` hooks run on the same ``asyncio`` event loop that goes on to accept connections. It is safe to create loop-bound primitives — ``asyncio.Lock``, ``asyncio.Queue``, ``asyncio.Event``, a raw ``asyncio.create_task()`` call — inside a ``startup`` hook, and to register long-lived background work with :ref:`datasette_add_background_task` there. This was not always true: older Datasette versions ran startup on a temporary event loop in the CLI that was closed before the server's own loop was created, which could silently kill anything scheduled on it.
|
||||
|
||||
Three trigger paths
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- **``datasette serve`` (CLI)** — startup and ``uvicorn.Server.serve()`` both run inside a single ``asyncio.run()`` call, so there is exactly one event loop for the whole life of the process.
|
||||
- **ASGI lifespan** — ``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly.
|
||||
- **First-request fallback** — an internal ``AsgiRunOnFirstRequest`` wrapper runs the same startup work as a safety net for hosts that never send ASGI lifespan events at all: some ASGI mounts, a bare ``app()`` embedded inside another framework, and :ref:`datasette.client <internals_datasette_client>` / test clients, which drive requests directly over ``httpx.ASGITransport`` without ever emitting ``lifespan.startup``. It runs startup exactly once, the first time any non-lifespan scope arrives, guarded by a lock so that concurrent early requests can't run it twice.
|
||||
|
||||
All three paths call the same idempotent internal methods, so it is safe for more than one of them to fire — lifespan startup completing and then a first request arriving afterwards is a no-op the second time. A host that never sends lifespan events and never goes through the CLI degrades to first-request timing: startup runs on the first request instead of before it, exactly as Datasette always worked prior to this lifecycle guarantee. This is a deliberate fallback rather than a regression — see :ref:`datasette_add_background_task` for how to opt out of launching background tasks (the ``--get`` CLI path) or drive startup and launch explicitly (tests, headless embedders).
|
||||
|
||||
.. _datasette_add_background_task:
|
||||
|
||||
.add_background_task(func, name=None)
|
||||
-------------------------------------
|
||||
|
||||
``func`` - async callable
|
||||
A coroutine function taking one positional argument, the ``Datasette`` instance. Core calls ``await func(datasette)``.
|
||||
|
||||
``name`` - string, optional
|
||||
A name for the task, used to identify it in log messages. Defaults to ``func.__qualname__``. If the resulting name collides with an already-registered task, a ``-2``, ``-3``, ... suffix is appended.
|
||||
|
||||
Registers a piece of supervised, long-lived background work — typically called from a :ref:`plugin_hook_startup` hook, though it can be called at any point after the instance exists, including from a request handler. Returns a :ref:`BackgroundTask <BackgroundTask>` handle.
|
||||
|
||||
Registration is separate from launch. Calling this from a ``startup`` hook — the common case — buffers the task; core launches every registered task once *all* ``startup`` hooks have completed, as described in :ref:`datasette_lifecycle`. Calling it after launch has already happened — for example from a request handler, to start a per-job task dynamically — starts the task immediately instead.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import asyncio
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
async def poll_for_updates(datasette):
|
||||
while True:
|
||||
await do_one_poll(datasette)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def startup(datasette):
|
||||
datasette.add_background_task(
|
||||
poll_for_updates, name="my-plugin-poller"
|
||||
)
|
||||
|
||||
Core owns the task for the rest of the process's life:
|
||||
|
||||
- **A strong reference is kept forever**, so the task can never be silently garbage collected the way an unreferenced ``asyncio.create_task()`` call can be.
|
||||
- **A crash is logged, not swallowed.** If ``func`` raises anything other than ``asyncio.CancelledError``, the exception (with its traceback) is logged to the ``datasette.background_tasks`` logger and recorded on the handle's ``.exception``, and the task's ``.state`` becomes ``crashed``. **There is no automatic restart in v1** — a long-running loop should catch and log its own transient errors internally if it wants to keep running after one.
|
||||
- **Cancellation is coordinated.** On shutdown, every task that is still running is cancelled and given a grace period to stop — see :ref:`datasette_lifecycle`.
|
||||
|
||||
Raw ``asyncio.create_task()`` inside a ``startup`` hook now works correctly, because ``startup`` hooks run on the serving event loop (see the admonition in :ref:`datasette_lifecycle`) — the bug that made this unsafe is fixed. But a task created that way is unsupervised: nothing keeps a reference to it, nothing logs its exceptions, and nothing cancels it on shutdown. Prefer ``add_background_task()`` for anything long-lived.
|
||||
|
||||
Launch matrix
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Whether registered tasks actually launch depends on how the instance is being run:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Trigger
|
||||
- Launches registered tasks?
|
||||
* - ASGI lifespan (real server deployments)
|
||||
- Yes, after ``lifespan.startup`` completes
|
||||
* - First-request fallback (lifespan-less hosts)
|
||||
- Yes, on the first request — parity with the lifespan case
|
||||
* - ``datasette serve --get``
|
||||
- Never
|
||||
* - Tests / headless embedders
|
||||
- Only if you call :ref:`datasette_start_background_tasks` explicitly
|
||||
|
||||
``datasette --get`` never launches background tasks, even though its one-shot request flows through the same first-request fallback as everything else: it sets an internal flag before making that request specifically to suppress the launch, since a one-shot CLI invocation has no server loop left running afterwards to keep any launched tasks alive.
|
||||
|
||||
.. _BackgroundTask:
|
||||
|
||||
BackgroundTask objects
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``add_background_task()`` returns a ``BackgroundTask`` handle with the following attributes:
|
||||
|
||||
``.name`` - string
|
||||
The task's (unique) name.
|
||||
|
||||
``.state`` - string
|
||||
One of ``registered`` (added but not yet launched), ``running``, ``completed`` (returned cleanly), ``crashed`` (raised an exception) or ``cancelled``.
|
||||
|
||||
``.task`` - ``asyncio.Task`` or ``None``
|
||||
The underlying ``asyncio.Task``, once launched. ``None`` while still ``registered``.
|
||||
|
||||
``.exception`` - ``BaseException`` or ``None``
|
||||
The exception that crashed the task, if ``.state`` is ``crashed``.
|
||||
|
||||
``.started_at`` - string or ``None``
|
||||
ISO 8601 UTC timestamp of when the task was launched.
|
||||
|
||||
``.plugin`` - string or ``None``
|
||||
Best-effort name of the plugin that registered the task, resolved from the module ``func`` was defined in. Used in log messages; ``None`` if it cannot be determined.
|
||||
|
||||
``.cancel()``
|
||||
Cancel the task. If it has already launched, this cancels the underlying ``asyncio.Task`` — ``.state`` becomes ``cancelled`` once the cancellation is observed. If it has not launched yet, it is removed from the queue so it never runs.
|
||||
|
||||
.. _datasette_start_background_tasks:
|
||||
|
||||
await .start_background_tasks()
|
||||
-------------------------------
|
||||
|
||||
Runs startup (if it has not already run) and launches every task registered with :ref:`datasette_add_background_task`. This is the explicit equivalent of what happens automatically via ASGI lifespan or the first-request fallback in a served deployment — the entry point for tests and headless embedders (a cron-style CLI command that wants supervised background work without running a server) that need background tasks without going through either of those paths.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
datasette = Datasette(memory=True)
|
||||
await datasette.start_background_tasks()
|
||||
|
||||
.. note::
|
||||
|
||||
``start_background_tasks()`` calls ``invoke_startup()`` internally, **not** the fuller startup sequence a served instance uses — so calling it directly, without a prior request through ``datasette.client``, skips the immutable-database table-count precompute that a real server performs as part of startup. This only matters if your code inspects table counts before any request has been made; if you also exercise the instance via ``datasette.client`` (which arms the first-request fallback, and therefore the full startup sequence including table counts), or don't care about table counts up front, there is nothing to worry about.
|
||||
|
||||
.. _datasette_track_event:
|
||||
|
||||
|
|
|
|||
|
|
@ -1157,7 +1157,7 @@ Examples: `datasette-cors <https://datasette.io/plugins/datasette-cors>`__, `dat
|
|||
startup(datasette)
|
||||
------------------
|
||||
|
||||
This hook fires when the Datasette application server first starts up.
|
||||
This hook fires when the Datasette application server first starts up. It runs on the same event loop that goes on to serve requests, so it is safe to create loop-bound primitives and register background work here — see :ref:`datasette_lifecycle` for the full guarantee and the three ways startup can be triggered.
|
||||
|
||||
Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails:
|
||||
|
||||
|
|
@ -1195,6 +1195,7 @@ Potential use-cases:
|
|||
* Create database tables that a plugin needs on startup
|
||||
* Validate the configuration for a plugin on startup, and raise an error if it is invalid
|
||||
* Raise a ``datasette.utils.StartupError("message")`` exception to prevent Datasette from starting and display that message to the user.
|
||||
* Register supervised long-lived background work using :ref:`datasette_add_background_task`, which core launches once every plugin's ``startup()`` hook has finished.
|
||||
|
||||
.. note::
|
||||
|
||||
|
|
|
|||
|
|
@ -78,9 +78,19 @@ Creating a ``Datasette()`` instance like this as useful shortcut in tests, but t
|
|||
datasette = Datasette(memory=True)
|
||||
await datasette.invoke_startup()
|
||||
|
||||
This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls.
|
||||
This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls. It runs on the same event loop that runs your test, matching the guarantee described in :ref:`datasette_lifecycle`.
|
||||
|
||||
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request.
|
||||
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request, via the first-request fallback described in :ref:`datasette_lifecycle`.
|
||||
|
||||
If your plugin also registers work with :ref:`datasette_add_background_task` (typically from a ``startup`` hook) and your test needs that work to actually run, call ``await datasette.start_background_tasks()`` as well - ``invoke_startup()`` alone only runs ``startup`` hooks, it does not launch anything they registered:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
datasette = Datasette(memory=True)
|
||||
await datasette.start_background_tasks()
|
||||
# Any tasks registered by a startup() hook are now running
|
||||
|
||||
A request made through ``datasette.client`` arms both startup and background-task launch automatically, since they're both part of the same first-request fallback - ``start_background_tasks()`` is for tests that need tasks running without making an HTTP request first.
|
||||
|
||||
.. _testing_plugins_datasette_fixtures_database:
|
||||
|
||||
|
|
|
|||
379
tests/test_background_tasks.py
Normal file
379
tests/test_background_tasks.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""
|
||||
Tests for datasette.add_background_task() / start_background_tasks() and the
|
||||
BackgroundTask / BackgroundTaskSupervisor machinery in
|
||||
datasette/background_tasks.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
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. Copied from tests/test_lifespan.py's helper of the
|
||||
same name - mirrors what a real server does: after startup completes
|
||||
it parks waiting for the next event, and we cancel that wait once
|
||||
we've observed the startup response.
|
||||
"""
|
||||
messages_sent = []
|
||||
startup_responded = asyncio.Event()
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if not delivered:
|
||||
delivered = True
|
||||
return {"type": "lifespan.startup"}
|
||||
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_tasks_registered_in_startup_hook_run_after_lifespan_startup():
|
||||
# Two tasks registered by one plugin's startup hook - order preserved,
|
||||
# both running after lifespan startup completes, and no HTTP request
|
||||
# of any kind is issued anywhere in this test.
|
||||
events = []
|
||||
|
||||
async def task_one(datasette):
|
||||
events.append("task_one")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def task_two(datasette):
|
||||
events.append("task_two")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
class TwoTaskPlugin:
|
||||
__name__ = "TwoTaskPlugin"
|
||||
|
||||
@hookimpl
|
||||
def startup(self, datasette):
|
||||
async def inner():
|
||||
datasette.add_background_task(task_one, name="task-one")
|
||||
datasette.add_background_task(task_two, name="task-two")
|
||||
|
||||
return inner
|
||||
|
||||
ds = Datasette(memory=True)
|
||||
pm.register(TwoTaskPlugin(), name="two_task_plugin")
|
||||
try:
|
||||
app = ds.app()
|
||||
messages = await _drive_lifespan_startup(app)
|
||||
assert {"type": "lifespan.startup.complete"} in messages
|
||||
|
||||
handles = ds._background_tasks.tasks()
|
||||
assert [h.name for h in handles] == ["task-one", "task-two"]
|
||||
|
||||
# Let both tasks run their first line of code.
|
||||
await asyncio.sleep(0)
|
||||
assert handles[0].state == "running"
|
||||
assert handles[1].state == "running"
|
||||
assert events == ["task_one", "task_two"]
|
||||
finally:
|
||||
pm.unregister(name="two_task_plugin")
|
||||
await ds._background_tasks.cancel_all(grace=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_launch_waits_for_every_startup_hook_before_running_any_task():
|
||||
# PluginA registers a task from its startup hook; PluginB does the
|
||||
# same from ITS startup hook, which runs after PluginA's (forced with
|
||||
# tryfirst=True on A). Even though A's registration happens first,
|
||||
# A's task body must not actually execute until every startup hook -
|
||||
# including B's - has finished, since launch only happens after
|
||||
# invoke_startup() completes. This is the ordering guarantee that
|
||||
# dissolves datasette-cron's tryfirst=True launch hack.
|
||||
hook_call_order = []
|
||||
seen_names_when_a_ran = {}
|
||||
|
||||
async def task_a(datasette):
|
||||
seen_names_when_a_ran["names"] = [
|
||||
h.name for h in datasette._background_tasks.tasks()
|
||||
]
|
||||
|
||||
async def task_b(datasette):
|
||||
pass
|
||||
|
||||
class PluginA:
|
||||
__name__ = "PluginA"
|
||||
|
||||
@hookimpl(tryfirst=True)
|
||||
def startup(self, datasette):
|
||||
async def inner():
|
||||
hook_call_order.append("A")
|
||||
datasette.add_background_task(task_a, name="task-a")
|
||||
|
||||
return inner
|
||||
|
||||
class PluginB:
|
||||
__name__ = "PluginB"
|
||||
|
||||
@hookimpl
|
||||
def startup(self, datasette):
|
||||
async def inner():
|
||||
hook_call_order.append("B")
|
||||
datasette.add_background_task(task_b, name="task-b")
|
||||
|
||||
return inner
|
||||
|
||||
ds = Datasette(memory=True)
|
||||
pm.register(PluginA(), name="plugin_a")
|
||||
pm.register(PluginB(), name="plugin_b")
|
||||
try:
|
||||
await ds.start_background_tasks()
|
||||
# Confirm A's startup hook really did run (and register task-a)
|
||||
# strictly before B's startup hook ran.
|
||||
assert hook_call_order == ["A", "B"]
|
||||
|
||||
handles = ds._background_tasks.tasks()
|
||||
await asyncio.wait_for(asyncio.gather(*[h.task for h in handles]), timeout=5)
|
||||
# Yet by the time task-a's own body executed (after launch, which
|
||||
# only happens once every startup hook - including B's - has
|
||||
# finished), task-b was already registered.
|
||||
assert "task-b" in seen_names_when_a_ran["names"]
|
||||
finally:
|
||||
pm.unregister(name="plugin_a")
|
||||
pm.unregister(name="plugin_b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_first_requests_launch_background_tasks_exactly_once():
|
||||
launch_count = {"n": 0}
|
||||
|
||||
async def counting_task(datasette):
|
||||
launch_count["n"] += 1
|
||||
|
||||
class CountingTaskPlugin:
|
||||
__name__ = "CountingTaskPlugin"
|
||||
|
||||
@hookimpl
|
||||
def startup(self, datasette):
|
||||
async def inner():
|
||||
datasette.add_background_task(counting_task, name="counting-task")
|
||||
|
||||
return inner
|
||||
|
||||
ds = Datasette(memory=True)
|
||||
pm.register(CountingTaskPlugin(), name="counting_task_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)]
|
||||
)
|
||||
assert all(response.status_code == 200 for response in responses)
|
||||
|
||||
handles = ds._background_tasks.tasks()
|
||||
assert len(handles) == 1
|
||||
await asyncio.wait_for(handles[0].task, timeout=5)
|
||||
assert launch_count["n"] == 1
|
||||
finally:
|
||||
pm.unregister(name="counting_task_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_launch_registration_starts_immediately_and_cancel_works():
|
||||
ds = Datasette(memory=True)
|
||||
await ds.start_background_tasks() # nothing registered yet, but launched
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
async def long_running(datasette):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
handle = ds.add_background_task(long_running, name="dynamic-task")
|
||||
# Registered after launch: starts immediately rather than sitting in
|
||||
# "registered" limbo.
|
||||
assert handle.state == "running"
|
||||
assert handle.task is not None
|
||||
|
||||
await asyncio.wait_for(started.wait(), timeout=5)
|
||||
assert handle.state == "running"
|
||||
|
||||
handle.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handle.task
|
||||
await asyncio.sleep(0)
|
||||
assert handle.state == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_launch_registration_starts_as_registered():
|
||||
ds = Datasette(memory=True)
|
||||
|
||||
async def task(datasette):
|
||||
pass
|
||||
|
||||
handle = ds.add_background_task(task, name="buffered-task")
|
||||
assert handle.state == "registered"
|
||||
assert handle.task is None
|
||||
|
||||
handle.cancel() # not yet launched: deregisters instead of cancelling
|
||||
assert handle not in ds._background_tasks.tasks()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crashing_task_logs_traceback_and_state_is_crashed(caplog):
|
||||
ds = Datasette(memory=True)
|
||||
await ds.start_background_tasks()
|
||||
|
||||
survivor_ran = asyncio.Event()
|
||||
|
||||
async def crashing_task(datasette):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
async def survivor(datasette):
|
||||
survivor_ran.set()
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="datasette.background_tasks"):
|
||||
crash_handle = ds.add_background_task(crashing_task, name="crashing_task")
|
||||
survivor_handle = ds.add_background_task(survivor, name="survivor")
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
crash_handle.task, survivor_handle.task, return_exceptions=True
|
||||
),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert crash_handle.state == "crashed"
|
||||
assert isinstance(crash_handle.exception, RuntimeError)
|
||||
assert str(crash_handle.exception) == "kaboom"
|
||||
|
||||
# The crash must not affect any other task.
|
||||
assert survivor_ran.is_set()
|
||||
assert survivor_handle.state == "completed"
|
||||
|
||||
assert "crashing_task" in caplog.text
|
||||
assert "kaboom" in caplog.text
|
||||
assert "Traceback" in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
||||
|
||||
def test_name_collisions_get_suffixed_and_explicit_names_are_respected():
|
||||
ds = Datasette(memory=True)
|
||||
|
||||
async def noop(datasette):
|
||||
pass
|
||||
|
||||
async def another_noop(datasette):
|
||||
pass
|
||||
|
||||
h1 = ds.add_background_task(noop, name="dup")
|
||||
h2 = ds.add_background_task(another_noop, name="dup")
|
||||
h3 = ds.add_background_task(noop, name="dup")
|
||||
assert [h1.name, h2.name, h3.name] == ["dup", "dup-2", "dup-3"]
|
||||
|
||||
h_explicit = ds.add_background_task(noop, name="explicit-name")
|
||||
assert h_explicit.name == "explicit-name"
|
||||
|
||||
h_default = ds.add_background_task(noop)
|
||||
assert h_default.name == noop.__qualname__
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_tasks_on_bare_datasette():
|
||||
# The headless-CLI path (datasette-rss's `fetch --due` shape): no
|
||||
# server, no lifespan, no first HTTP request - just an explicit call.
|
||||
ran = asyncio.Event()
|
||||
|
||||
async def task(datasette):
|
||||
ran.set()
|
||||
|
||||
ds = Datasette([])
|
||||
assert ds._startup_invoked is False
|
||||
|
||||
handle = ds.add_background_task(task, name="headless-task")
|
||||
assert handle.state == "registered"
|
||||
|
||||
await ds.start_background_tasks()
|
||||
|
||||
assert ds._startup_invoked is True
|
||||
await asyncio.wait_for(ran.wait(), timeout=5)
|
||||
await asyncio.wait_for(handle.task, timeout=5)
|
||||
# handle.task being done only guarantees the coroutine has returned,
|
||||
# not that our done-callback (which updates handle.state) has run yet -
|
||||
# asyncio schedules done-callbacks via call_soon, and awaiting an
|
||||
# already-done future/task returns immediately without giving the loop
|
||||
# a chance to drain its ready queue. Yield once to let it run.
|
||||
await asyncio.sleep(0)
|
||||
assert handle.state == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_cancels_running_tasks_and_leaves_completed_alone():
|
||||
ds = Datasette(memory=True)
|
||||
await ds.start_background_tasks()
|
||||
|
||||
async def forever(datasette):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def quick(datasette):
|
||||
return "done"
|
||||
|
||||
forever_handle = ds.add_background_task(forever, name="forever")
|
||||
quick_handle = ds.add_background_task(quick, name="quick")
|
||||
await asyncio.wait_for(quick_handle.task, timeout=5)
|
||||
assert quick_handle.state == "completed"
|
||||
|
||||
await ds._background_tasks.cancel_all(grace=1.0)
|
||||
|
||||
assert forever_handle.state == "cancelled"
|
||||
assert quick_handle.state == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_logs_stragglers_that_outlive_the_grace_period(caplog):
|
||||
ds = Datasette(memory=True)
|
||||
await ds.start_background_tasks()
|
||||
|
||||
async def stubborn(datasette):
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.sleep(10)
|
||||
# Swallowing CancelledError above and returning normally simulates
|
||||
# a task that ignores cancellation for longer than the grace period.
|
||||
await asyncio.sleep(10)
|
||||
|
||||
handle = ds.add_background_task(stubborn, name="stubborn-task")
|
||||
# Let the task actually start running and reach its first sleep (inside
|
||||
# the CancelledError-suppressing block) before cancelling it - a task
|
||||
# cancelled before it has ever run its first step never enters that
|
||||
# block at all (the throw happens before the coroutine body starts),
|
||||
# so it would finish cancelling immediately instead of behaving like a
|
||||
# straggler.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="datasette.background_tasks"):
|
||||
await ds._background_tasks.cancel_all(grace=0.1)
|
||||
|
||||
assert "stubborn-task" in caplog.text
|
||||
|
||||
# Clean up: actually cancel it now that the test has made its
|
||||
# assertion, so it doesn't leak past the end of the test.
|
||||
handle.task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await handle.task
|
||||
|
|
@ -52,6 +52,56 @@ def test_serve_with_get(tmp_path_factory):
|
|||
pm.unregister(to_unregister)
|
||||
|
||||
|
||||
def test_serve_with_get_does_not_launch_background_tasks(tmp_path_factory):
|
||||
# --get must never launch background tasks, even though its TestClient
|
||||
# request
|
||||
# flows through the full ASGI stack (including the AsgiRunOnFirstRequest
|
||||
# fallback that would otherwise launch them). The plugin's startup hook
|
||||
# itself still runs (registration happens) - only the launch is
|
||||
# suppressed, so the sentinel file the background task would write must
|
||||
# never appear.
|
||||
plugins_dir = tmp_path_factory.mktemp("plugins_for_get_background_tasks")
|
||||
sentinel = plugins_dir / "sentinel.txt"
|
||||
(plugins_dir / "bg_task_for_get.py").write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
from datasette import hookimpl
|
||||
|
||||
@hookimpl
|
||||
def startup(datasette):
|
||||
async def inner():
|
||||
async def task(datasette):
|
||||
with open("{sentinel!s}", "w") as fp:
|
||||
fp.write("ran")
|
||||
|
||||
datasette.add_background_task(task, name="get-sentinel-task")
|
||||
|
||||
return inner
|
||||
""",
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"serve",
|
||||
"--memory",
|
||||
"--plugins-dir",
|
||||
str(plugins_dir),
|
||||
"--get",
|
||||
"/_memory/-/query.json?sql=select+1",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not sentinel.exists()
|
||||
|
||||
to_unregister = next(
|
||||
p for p in pm.get_plugins() if p.__name__ == "bg_task_for_get.py"
|
||||
)
|
||||
pm.unregister(to_unregister)
|
||||
|
||||
|
||||
def test_serve_with_get_headers():
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue