datasette/docs/upgrade-1.0a20.md
Simon Willison 5d4dfcec6b Fix for link from changelog not working
Annoyingly we now get a warning in the docs build about a duplicate label,
but it seems harmless enough.
2025-11-03 14:38:57 -08:00

8.7 KiB
Raw Permalink Blame History

orphan
true

(upgrade_guide_v1_a20)=

Datasette 1.0a20 plugin upgrade guide

Datasette 1.0a20 makes some breaking changes to Datasette's permission system. Plugins need to be updated if they use any of the following:

  • The register_permissions() plugin hook - this should be replaced with register_actions
  • The permission_allowed() plugin hook - this should be upgraded to use permission_resources_sql().
  • The datasette.permission_allowed() internal method - this should be replaced with datasette.allowed()
  • Logic that grants access to the "root" actor can be removed.

Permissions are now actions

The register_permissions() hook shoud be replaced with register_actions().

Old code:

@hookimpl
def register_permissions(datasette):
    return [
        Permission(
            name="explain-sql",
            abbr=None,
            description="Can explain SQL queries",
            takes_database=True,
            takes_resource=False,
            default=False,
        ),
        Permission(
            name="annotate-rows",
            abbr=None,
            description="Can annotate rows",
            takes_database=True,
            takes_resource=True,
            default=False,
        ),
        Permission(
            name="view-debug-info",
            abbr=None,
            description="Can view debug information",
            takes_database=False,
            takes_resource=False,
            default=False,
        ),
    ]

The new Action does not have a default= parameter.

Here's the equivalent new code:

from datasette import hookimpl
from datasette.permissions import Action
from datasette.resources import DatabaseResource, TableResource

@hookimpl
def register_actions(datasette):
    return [
        Action(
            name="explain-sql",
            description="Explain SQL queries",
            resource_class=DatabaseResource,
        ),
        Action(
            name="annotate-rows",
            description="Annotate rows",
            resource_class=TableResource,
        ),
        Action(
            name="view-debug-info",
            description="View debug information",
        ),
    ]

The abbr= is now optional and defaults to None.

For actions that apply to specific resources (like databases or tables), specify the resource_class instead of takes_parent and takes_child. Note that view-debug-info does not specify a resource_class because it applies globally.

permission_allowed() hook is replaced by permission_resources_sql()

The following old code:

@hookimpl
def permission_allowed(action):
    if action == "permissions-debug":
        return True

Can be replaced by:

from datasette.permissions import PermissionSQL

@hookimpl
def permission_resources_sql(action):
    return PermissionSQL.allow(reason="datasette-allow-permissions-debug")

A .deny(reason="") class method is also available.

For more complex permission checks consult the documentation for that plugin hook: https://docs.datasette.io/en/latest/plugin_hooks.html#permission-resources-sql-datasette-actor-action

Using datasette.allowed() to check permissions instead of datasette.permission_allowed()

The internal method datasette.permission_allowed() has been replaced by datasette.allowed().

The old method looked like this:

can_debug = await datasette.permission_allowed(
    request.actor,
    "view-debug-info",
)
can_explain_sql = await datasette.permission_allowed(
    request.actor,
    "explain-sql",
    resource="database_name",
)
can_annotate_rows = await datasette.permission_allowed(
    request.actor,
    "annotate-rows",
    resource=(database_name, table_name),
)

Note the confusing design here where resource could be either a string or a tuple depending on the permission being checked.

The new keyword-only design makes this a lot more clear:

from datasette.resources import DatabaseResource, TableResource
can_debug = await datasette.allowed(
    actor=request.actor,
    action="view-debug-info",
)
can_explain_sql = await datasette.allowed(
    actor=request.actor,
    action="explain-sql",
    resource=DatabaseResource(database_name),
)
can_annotate_rows = await datasette.allowed(
    actor=request.actor,
    action="annotate-rows",
    resource=TableResource(database_name, table_name),
)

Root user checks are no longer necessary

Some plugins would introduce their own custom permission and then ensure the "root" actor had access to it using a pattern like this:

@hookimpl
def register_permissions(datasette):
    return [
        Permission(
            name="upload-dbs",
            abbr=None,
            description="Upload SQLite database files",
            takes_database=False,
            takes_resource=False,
            default=False,
        )
    ]


@hookimpl
def permission_allowed(actor, action):
    if action == "upload-dbs" and actor and actor.get("id") == "root":
        return True

This is no longer necessary in Datasette 1.0a20 - the "root" actor automatically has all permissions when Datasette is started with the datasette --root option.

The permission_allowed() hook in this example can be entirely removed.

Root-enabled instances during testing

When writing tests that exercise root-only functionality, make sure to set datasette.root_enabled = True on the Datasette instance. Root permissions are only granted automatically when Datasette is started with datasette --root or when the flag is enabled directly in tests.

Target the new APIs exclusively

Datasette 1.0a20s permission system is substantially different from previous releases. Attempting to keep plugin code compatible with both the old permission_allowed() and the new allowed() interfaces leads to brittle workarounds. Prefer to adopt the 1.0a20 APIs (register_actions, permission_resources_sql(), and datasette.allowed()) outright and drop legacy fallbacks.

Fixing async with httpx.AsyncClient(app=app)

Some older plugins may use the following pattern in their tests, which is no longer supported:

app = Datasette([], memory=True).app()
async with httpx.AsyncClient(app=app) as client:
    response = await client.get("http://localhost/path")

The new pattern is to use ds.client like this:

ds = Datasette([], memory=True)
response = await ds.client.get("/path")

Migrating from metadata= to config=

Datasette 1.0 separates metadata (titles, descriptions, licenses) from configuration (settings, plugins, queries, permissions). Plugin tests and code need to be updated accordingly.

Update test constructors

Old code:

ds = Datasette(
    memory=True,
    metadata={
        "databases": {
            "_memory": {"queries": {"my_query": {"sql": "select 1", "title": "My Query"}}}
        },
        "plugins": {
            "my-plugin": {"setting": "value"}
        }
    }
)

New code:

ds = Datasette(
    memory=True,
    config={
        "databases": {
            "_memory": {"queries": {"my_query": {"sql": "select 1", "title": "My Query"}}}
        },
        "plugins": {
            "my-plugin": {"setting": "value"}
        }
    }
)

Update datasette.metadata() calls

The datasette.metadata() method has been removed. Use these methods instead:

Old code:

try:
    title = datasette.metadata(database=database)["queries"][query_name]["title"]
except (KeyError, TypeError):
    pass

New code:

try:
    query_info = await datasette.get_canned_query(database, query_name, request.actor)
    if query_info and "title" in query_info:
        title = query_info["title"]
except (KeyError, TypeError):
    pass

Update render functions to async

If your plugin's render function needs to call datasette.get_canned_query() or other async Datasette methods, it must be declared as async:

Old code:

def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
    # ...
    if query_name:
        title = datasette.metadata(database=database)["queries"][query_name]["title"]

New code:

async def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
    # ...
    if query_name:
        query_info = await datasette.get_canned_query(database, query_name, request.actor)
        if query_info and "title" in query_info:
            title = query_info["title"]

Update query URLs in tests

Datasette now redirects ?sql= parameters from database pages to the query view:

Old code:

response = await ds.client.get("/_memory.atom?sql=select+1")

New code:

response = await ds.client.get("/_memory/-/query.atom?sql=select+1")