mirror of
https://github.com/simonw/datasette.git
synced 2026-09-08 01:24:14 +02:00
Compare commits
1 commit
main
...
asg017/db-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6bbcc3298 |
6 changed files with 205 additions and 2 deletions
|
|
@ -45,7 +45,7 @@ from . import stored_queries, write_sql
|
|||
from .column_types import SQLiteType
|
||||
from .csrf import CrossOriginProtectionMiddleware
|
||||
from .database import Database, QueryInterrupted
|
||||
from .events import Event
|
||||
from .events import AddDatabaseEvent, Event, RemoveDatabaseEvent
|
||||
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
||||
from .renderer import json_renderer
|
||||
from .resources import DatabaseResource, TableResource
|
||||
|
|
@ -423,6 +423,9 @@ class Datasette:
|
|||
):
|
||||
self._startup_invoked = False
|
||||
self._closed = False
|
||||
# Strong references to in-flight fire-and-forget event dispatch
|
||||
# tasks, so they cannot be garbage-collected before completing
|
||||
self._pending_event_tasks = set()
|
||||
assert config_dir is None or isinstance(
|
||||
config_dir, Path
|
||||
), "config_dir= should be a pathlib.Path"
|
||||
|
|
@ -935,6 +938,14 @@ class Datasette:
|
|||
new_databases[name] = db
|
||||
# don't mutate! that causes race conditions with live import
|
||||
self.databases = new_databases
|
||||
self._track_event_soon(
|
||||
AddDatabaseEvent(
|
||||
actor=None,
|
||||
database=db.name,
|
||||
path=str(Path(db.path).resolve()) if db.path else None,
|
||||
is_memory=db.is_memory,
|
||||
)
|
||||
)
|
||||
return db
|
||||
|
||||
def add_memory_database(self, memory_name, name=None, route=None):
|
||||
|
|
@ -943,10 +954,41 @@ class Datasette:
|
|||
)
|
||||
|
||||
def remove_database(self, name):
|
||||
self.get_database(name).close()
|
||||
db = self.get_database(name)
|
||||
# Capture event details before close() - is_temp_disk databases
|
||||
# delete their backing file during close()
|
||||
path = str(Path(db.path).resolve()) if db.path else None
|
||||
is_memory = db.is_memory
|
||||
# Fire the event only after close() returns: close() drains any
|
||||
# queued writes, so listeners doing a final read of the file see
|
||||
# everything
|
||||
db.close()
|
||||
new_databases = self.databases.copy()
|
||||
new_databases.pop(name)
|
||||
self.databases = new_databases
|
||||
self._track_event_soon(
|
||||
RemoveDatabaseEvent(
|
||||
actor=None,
|
||||
database=name,
|
||||
path=path,
|
||||
is_memory=is_memory,
|
||||
)
|
||||
)
|
||||
|
||||
def _track_event_soon(self, event):
|
||||
# Best-effort fire-and-forget event dispatch from synchronous code.
|
||||
# If startup has not run (event classes are not yet registered) or
|
||||
# there is no running event loop, the event is intentionally
|
||||
# dropped - lifecycle events are documented as runtime-only
|
||||
if not self._startup_invoked:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
task = loop.create_task(self.track_event(event))
|
||||
self._pending_event_tasks.add(task)
|
||||
task.add_done_callback(self._pending_event_tasks.discard)
|
||||
|
||||
def close(self):
|
||||
"""Release all resources held by this Datasette instance.
|
||||
|
|
|
|||
|
|
@ -241,6 +241,54 @@ class DeleteRowEvent(Event):
|
|||
pks: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddDatabaseEvent(Event):
|
||||
"""
|
||||
Event name: ``add-database``
|
||||
|
||||
A new database was attached to this Datasette instance while it was
|
||||
running, using :ref:`datasette.add_database() <datasette_add_database>`.
|
||||
|
||||
:ivar database: The name the database was attached as.
|
||||
:type database: str
|
||||
:ivar path: Resolved absolute filesystem path to the database file, or ``None`` for in-memory databases.
|
||||
:type path: str or None
|
||||
:ivar is_memory: ``True`` if this is an in-memory database.
|
||||
:type is_memory: bool
|
||||
"""
|
||||
|
||||
name = "add-database"
|
||||
database: str
|
||||
path: str | None
|
||||
is_memory: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveDatabaseEvent(Event):
|
||||
"""
|
||||
Event name: ``remove-database``
|
||||
|
||||
A database was detached from this Datasette instance using
|
||||
:ref:`datasette.remove_database() <datasette_remove_database>`. The
|
||||
database file itself is not deleted by Datasette core, and any queued
|
||||
writes have been flushed by the time this event is delivered - with one
|
||||
exception: temporary on-disk databases remove their backing file when
|
||||
they are closed, so for those the path in this event no longer exists.
|
||||
|
||||
:ivar database: The name the database was attached as.
|
||||
:type database: str
|
||||
:ivar path: Resolved absolute filesystem path to the database file, or ``None`` for in-memory databases.
|
||||
:type path: str or None
|
||||
:ivar is_memory: ``True`` if this was an in-memory database.
|
||||
:type is_memory: bool
|
||||
"""
|
||||
|
||||
name = "remove-database"
|
||||
database: str
|
||||
path: str | None
|
||||
is_memory: bool
|
||||
|
||||
|
||||
@hookimpl
|
||||
def write_wrapper(datasette, database, request, transaction):
|
||||
def wrapper(conn, track_event):
|
||||
|
|
@ -291,4 +339,6 @@ def register_events():
|
|||
UpsertRowsEvent,
|
||||
UpdateRowEvent,
|
||||
DeleteRowEvent,
|
||||
AddDatabaseEvent,
|
||||
RemoveDatabaseEvent,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ Note that these events will *not* fire for changes made to a SQLite database by
|
|||
|
||||
Plugins can listen for events using the {ref}`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes - or additional classes {ref}`registered by other plugins <plugin_hook_register_events>`.
|
||||
|
||||
## Delivery guarantees for database lifecycle events
|
||||
|
||||
The ``add-database`` and ``remove-database`` events have some specific delivery characteristics:
|
||||
|
||||
- Delivery is asynchronous. Listeners run shortly after the change, not before the triggering ``add_database()`` or ``remove_database()`` call returns.
|
||||
- These events fire only for changes made at runtime - while an event loop is running, after Datasette's startup has completed. Databases attached while the instance is starting up do not produce events: plugins that need to see those should iterate over ``datasette.databases`` in their own {ref}`plugin_hook_startup` hook.
|
||||
- Rapid successive changes involving the same database name may reach listeners interleaved. Listeners should tolerate events arriving out of order.
|
||||
- ``event.actor`` is ``None`` for programmatic calls made by Datasette itself or by plugins.
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: datasette.events
|
||||
:members:
|
||||
|
|
|
|||
|
|
@ -1357,6 +1357,8 @@ Use ``is_mutable=False`` to add an immutable database.
|
|||
"CREATE TABLE foo(id integer primary key)"
|
||||
)
|
||||
|
||||
Calling this method while the instance is running - after ``invoke_startup()`` has completed, with an event loop running - emits an ``add-database`` :ref:`event <events>`. Databases attached during startup do not emit events: plugins that need to see those should iterate over ``datasette.databases`` in their own :ref:`plugin_hook_startup` hook.
|
||||
|
||||
.. _datasette_add_memory_database:
|
||||
|
||||
.add_memory_database(memory_name, name=None, route=None)
|
||||
|
|
@ -1392,6 +1394,8 @@ The ``name`` and ``route`` parameters are optional and work the same way as they
|
|||
|
||||
This removes a database that has been previously added. ``name=`` is the unique name of that database.
|
||||
|
||||
The database is closed but its file is not deleted. When called while the instance is running this emits a ``remove-database`` :ref:`event <events>` after the database has been closed - since closing flushes any queued writes first, an event listener can safely perform a final read of the database file. The exception is temporary on-disk databases, which remove their backing file when closed.
|
||||
|
||||
.. _datasette_close:
|
||||
|
||||
.close()
|
||||
|
|
|
|||
|
|
@ -476,3 +476,98 @@ async def test_datasette_render_template_dataclass_values_not_deep_copied():
|
|||
await ds.invoke_startup()
|
||||
rendered = await ds.render_template("error.html", context)
|
||||
assert "shallow-copied-value" in rendered
|
||||
|
||||
|
||||
def _lifecycle_events(ds):
|
||||
return [
|
||||
event
|
||||
for event in getattr(ds, "_tracked_events", [])
|
||||
if event.name in ("add-database", "remove-database")
|
||||
]
|
||||
|
||||
|
||||
async def _drain_event_tasks(ds):
|
||||
await asyncio.gather(*ds._pending_event_tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_database_fires_event(tmp_path):
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
path = str(tmp_path / "data.db")
|
||||
sqlite3.connect(path).execute("vacuum")
|
||||
db = ds.add_database(Database(ds, path=path, is_mutable=True))
|
||||
await _drain_event_tasks(ds)
|
||||
events = _lifecycle_events(ds)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.name == "add-database"
|
||||
assert event.database == db.name == "data"
|
||||
assert event.path == os.path.abspath(path)
|
||||
assert event.is_memory is False
|
||||
assert event.actor is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_memory_database_fires_event():
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
ds.add_memory_database("test_add_memory_database_event")
|
||||
await _drain_event_tasks(ds)
|
||||
events = _lifecycle_events(ds)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.name == "add-database"
|
||||
assert event.database == "test_add_memory_database_event"
|
||||
assert event.path is None
|
||||
assert event.is_memory is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_database_fires_event(tmp_path):
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
path = str(tmp_path / "data.db")
|
||||
sqlite3.connect(path).execute("vacuum")
|
||||
db = ds.add_database(Database(ds, path=path, is_mutable=True))
|
||||
ds.remove_database(db.name)
|
||||
await _drain_event_tasks(ds)
|
||||
events = _lifecycle_events(ds)
|
||||
assert [event.name for event in events] == ["add-database", "remove-database"]
|
||||
event = events[1]
|
||||
assert event.database == "data"
|
||||
assert event.path == os.path.abspath(path)
|
||||
assert event.is_memory is False
|
||||
assert event.actor is None
|
||||
# remove_database never deletes the file
|
||||
assert os.path.exists(path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_database_no_event_before_startup():
|
||||
ds = Datasette(memory=True)
|
||||
# invoke_startup() has not run - no event, and no AssertionError from
|
||||
# track_event()'s event_classes check
|
||||
ds.add_database(Database(ds, memory_name="pre_startup_db"))
|
||||
assert ds._pending_event_tasks == set()
|
||||
assert _lifecycle_events(ds) == []
|
||||
|
||||
|
||||
def test_add_database_no_event_without_running_loop():
|
||||
ds = Datasette(memory=True)
|
||||
asyncio.run(ds.invoke_startup())
|
||||
# Startup has run but there is no running event loop now
|
||||
ds.add_database(Database(ds, memory_name="no_loop_db"))
|
||||
assert ds._pending_event_tasks == set()
|
||||
assert _lifecycle_events(ds) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_database_event_uses_renamed_name():
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
ds.add_memory_database("first_mem", name="clash")
|
||||
ds.add_memory_database("second_mem", name="clash")
|
||||
await _drain_event_tasks(ds)
|
||||
events = _lifecycle_events(ds)
|
||||
assert [event.database for event in events] == ["clash", "clash_2"]
|
||||
|
|
|
|||
|
|
@ -1661,6 +1661,9 @@ async def test_hook_register_events():
|
|||
datasette = Datasette(memory=True)
|
||||
await datasette.invoke_startup()
|
||||
assert any(k.__name__ == "OneEvent" for k in datasette.event_classes)
|
||||
# Core database lifecycle events should be registered too
|
||||
registered_names = {k.name for k in datasette.event_classes}
|
||||
assert {"add-database", "remove-database"} <= registered_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue