Compare commits

..

2 commits

Author SHA1 Message Date
Simon Willison
472caf4edf Install Playwright in CI 2025-11-08 16:54:59 -08:00
Simon Willison
5e0cfa8b30 Initial prototype of row side panel, refs #2589 2025-11-08 16:43:47 -08:00
38 changed files with 1674 additions and 1589 deletions

View file

@ -2,10 +2,10 @@ name: Deploy latest.datasette.io
on:
workflow_dispatch:
push:
branches:
- main
# - 1.0-dev
# push:
# branches:
# - main
# - 1.0-dev
permissions:
contents: read
@ -15,12 +15,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v5
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v6
# Using Python 3.10 for gcloud compatibility:
with:
python-version: "3.13"
cache: pip
python-version: "3.10"
- uses: actions/cache@v4
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
@ -97,7 +104,7 @@ jobs:
# cat metadata.json
- id: auth
name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK

View file

@ -27,6 +27,14 @@ jobs:
run: |
pip install -e '.[test]'
pip freeze
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers
- name: Install Playwright dependencies
run: |
playwright install
- name: Run tests
run: |
pytest -n auto -m "not serial"

View file

@ -29,7 +29,7 @@ export DATASETTE_SECRET := "not_a_secret"
# Serve live docs on localhost:8000
@docs: cog blacken-docs
uv run --extra docs make -C docs livehtml
uv sync --extra docs && cd docs && uv run make livehtml
# Build docs as static HTML
@docs-build: cog blacken-docs

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from asgi_csrf import Errors
import asyncio
import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
if TYPE_CHECKING:
@ -131,22 +130,6 @@ from .resources import DatabaseResource, TableResource
app_root = Path(__file__).parent.parent
# Context variable to track when code is executing within a datasette.client request
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
class _DatasetteClientContext:
"""Context manager to mark code as executing within a datasette.client request."""
def __enter__(self):
self.token = _in_datasette_client.set(True)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
_in_datasette_client.reset(self.token)
return False
@dataclasses.dataclass
class PermissionCheck:
"""Represents a logged permission check for debugging purposes."""
@ -321,7 +304,6 @@ class Datasette:
crossdb=False,
nolock=False,
internal=None,
default_deny=False,
):
self._startup_invoked = False
assert config_dir is None or isinstance(
@ -530,7 +512,6 @@ class Datasette:
self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32)
self.root_enabled = False
self.default_deny = default_deny
self.client = DatasetteClient(self)
async def apply_metadata_json(self):
@ -606,15 +587,6 @@ class Datasette:
"select database_name, schema_version from catalog_databases"
)
}
# Delete stale entries for databases that are no longer attached
stale_databases = set(current_schema_versions.keys()) - set(
self.databases.keys()
)
for stale_db_name in stale_databases:
await internal_db.execute_write(
"DELETE FROM catalog_databases WHERE database_name = ?",
[stale_db_name],
)
for database_name, db in self.databases.items():
schema_version = (await db.execute("PRAGMA schema_version")).first()[0]
# Compare schema versions to see if we should skip it
@ -640,17 +612,6 @@ class Datasette:
def urls(self):
return Urls(self)
@property
def pm(self):
"""
Return the global plugin manager instance.
This provides access to the pluggy PluginManager that manages all
Datasette plugins and hooks. Use datasette.pm.hook.hook_name() to
call plugin hooks.
"""
return pm
async def invoke_startup(self):
# This must be called for Datasette to be in a usable state
if self._startup_invoked:
@ -703,14 +664,6 @@ class Datasette:
def unsign(self, signed, namespace="default"):
return URLSafeSerializer(self._secret, namespace).loads(signed)
def in_client(self) -> bool:
"""Check if the current code is executing within a datasette.client request.
Returns:
bool: True if currently executing within a datasette.client request, False otherwise.
"""
return _in_datasette_client.get()
def create_token(
self,
actor_id: str,
@ -2435,10 +2388,7 @@ class DatasetteClient:
def __init__(self, ds):
self.ds = ds
@property
def app(self):
return self.ds.app()
self.app = ds.app()
def actor_cookie(self, actor):
# Utility method, mainly for tests
@ -2454,20 +2404,19 @@ class DatasetteClient:
async def _request(self, method, path, skip_permission_checks=False, **kwargs):
from datasette.permissions import SkipPermissions
with _DatasetteClientContext():
if skip_permission_checks:
with SkipPermissions():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
) as client:
return await getattr(client, method)(self._fix(path), **kwargs)
else:
if skip_permission_checks:
with SkipPermissions():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
) as client:
return await getattr(client, method)(self._fix(path), **kwargs)
else:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
) as client:
return await getattr(client, method)(self._fix(path), **kwargs)
async def get(self, path, skip_permission_checks=False, **kwargs):
return await self._request(
@ -2519,17 +2468,8 @@ class DatasetteClient:
from datasette.permissions import SkipPermissions
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
with _DatasetteClientContext():
if skip_permission_checks:
with SkipPermissions():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
) as client:
return await client.request(
method, self._fix(path, avoid_path_rewrites), **kwargs
)
else:
if skip_permission_checks:
with SkipPermissions():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
@ -2537,3 +2477,11 @@ class DatasetteClient:
return await client.request(
method, self._fix(path, avoid_path_rewrites), **kwargs
)
else:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
cookies=kwargs.pop("cookies", None),
) as client:
return await client.request(
method, self._fix(path, avoid_path_rewrites), **kwargs
)

View file

@ -438,11 +438,6 @@ def uninstall(packages, yes):
help="Output URL that sets a cookie authenticating the root user",
is_flag=True,
)
@click.option(
"--default-deny",
help="Deny all permissions by default",
is_flag=True,
)
@click.option(
"--get",
help="Run an HTTP GET request against this path, print results and exit",
@ -519,7 +514,6 @@ def serve(
settings,
secret,
root,
default_deny,
get,
headers,
token,
@ -600,7 +594,6 @@ def serve(
crossdb=crossdb,
nolock=nolock,
internal=internal,
default_deny=default_deny,
)
# Separate directories from files

View file

@ -0,0 +1,490 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from datasette.utils import actor_matches_allow
import itsdangerous
import time
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(datasette, actor, action):
"""Handle actor restriction-based permission rules (_r key)."""
if not actor:
return None
restrictions = actor.get("_r") if isinstance(actor, dict) else None
if restrictions is None:
return []
# Check if this action appears in restrictions (with abbreviations)
action_obj = datasette.actions.get(action)
action_checks = {action}
if action_obj and action_obj.abbr:
action_checks.add(action_obj.abbr)
# Check if globally allowed in restrictions
global_actions = restrictions.get("a", [])
is_globally_allowed = action_checks.intersection(global_actions)
if is_globally_allowed:
# Globally allowed - no restriction filtering needed
return []
# Not globally allowed - build restriction_sql that lists allowlisted resources
restriction_selects = []
restriction_params = {}
param_counter = 0
# Add database-level allowlisted resources
db_restrictions = restrictions.get("d", {})
for db_name, db_actions in db_restrictions.items():
if action_checks.intersection(db_actions):
prefix = f"restr_{param_counter}"
param_counter += 1
restriction_selects.append(
f"SELECT :{prefix}_parent AS parent, NULL AS child"
)
restriction_params[f"{prefix}_parent"] = db_name
# Add table-level allowlisted resources
resource_restrictions = restrictions.get("r", {})
for db_name, tables in resource_restrictions.items():
for table_name, table_actions in tables.items():
if action_checks.intersection(table_actions):
prefix = f"restr_{param_counter}"
param_counter += 1
restriction_selects.append(
f"SELECT :{prefix}_parent AS parent, :{prefix}_child AS child"
)
restriction_params[f"{prefix}_parent"] = db_name
restriction_params[f"{prefix}_child"] = table_name
if not restriction_selects:
# Action not in allowlist - return empty restriction (INTERSECT will return no results)
return [
PermissionSQL(
params={"deny": f"actor restrictions: {action} not in allowlist"},
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0", # Empty set
)
]
# Build restriction SQL that returns allowed (parent, child) pairs
restriction_sql = "\nUNION ALL\n".join(restriction_selects)
# Return restriction-only PermissionSQL (sql=None means no permission rules)
# The restriction_sql does the actual filtering via INTERSECT
return [
PermissionSQL(
params=restriction_params,
restriction_sql=restriction_sql,
)
]
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(datasette, actor, action):
"""Grant root user full permissions when enabled."""
if datasette.root_enabled and actor and actor.get("id") == "root":
# Add a single global-level allow rule (NULL, NULL) for root
# This allows root to access everything by default, but database-level
# and table-level deny rules in config can still block specific resources
return PermissionSQL.allow(reason="root user")
return None
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(datasette, actor, action):
"""Apply config-based permission rules from datasette.yaml."""
config = datasette.config or {}
def evaluate(allow_block):
if allow_block is None:
return None
return actor_matches_allow(actor, allow_block)
has_restrictions = actor and "_r" in actor if actor else False
restrictions = actor.get("_r", {}) if actor else {}
action_obj = datasette.actions.get(action)
action_checks = {action}
if action_obj and action_obj.abbr:
action_checks.add(action_obj.abbr)
restricted_databases: set[str] = set()
restricted_tables: set[tuple[str, str]] = set()
if has_restrictions:
restricted_databases = {
db_name
for db_name, db_actions in (restrictions.get("d") or {}).items()
if action_checks.intersection(db_actions)
}
restricted_tables = {
(db_name, table_name)
for db_name, tables in (restrictions.get("r") or {}).items()
for table_name, table_actions in tables.items()
if action_checks.intersection(table_actions)
}
# Tables implicitly reference their parent databases
restricted_databases.update(db for db, _ in restricted_tables)
def is_in_restriction_allowlist(parent, child, action_name):
"""Check if a resource is in the actor's restriction allowlist for this action"""
if not has_restrictions:
return True # No restrictions, all resources allowed
# Check global allowlist
if action_checks.intersection(restrictions.get("a", [])):
return True
# Check database-level allowlist
if parent and action_checks.intersection(
restrictions.get("d", {}).get(parent, [])
):
return True
# Check table-level allowlist
if parent:
table_restrictions = (restrictions.get("r", {}) or {}).get(parent, {})
if child:
table_actions = table_restrictions.get(child, [])
if action_checks.intersection(table_actions):
return True
else:
# Parent query should proceed if any child in this database is allowlisted
for table_actions in table_restrictions.values():
if action_checks.intersection(table_actions):
return True
# Parent/child both None: include if any restrictions exist for this action
if parent is None and child is None:
if action_checks.intersection(restrictions.get("a", [])):
return True
if restricted_databases:
return True
if restricted_tables:
return True
return False
rows = []
def add_row(parent, child, result, scope):
if result is None:
return
rows.append(
(
parent,
child,
bool(result),
f"config {'allow' if result else 'deny'} {scope}",
)
)
def add_row_allow_block(parent, child, allow_block, scope):
"""For 'allow' blocks, always add a row if the block exists - deny if no match"""
if allow_block is None:
return
# If actor has restrictions and this resource is NOT in allowlist, skip this config rule
# Restrictions act as a gating filter - config cannot grant access to restricted-out resources
if not is_in_restriction_allowlist(parent, child, action):
return
result = evaluate(allow_block)
bool_result = bool(result)
# If result is None (no match) or False, treat as deny
rows.append(
(
parent,
child,
bool_result, # None becomes False, False stays False, True stays True
f"config {'allow' if result else 'deny'} {scope}",
)
)
if has_restrictions and not bool_result and child is None:
reason = f"config deny {scope} (restriction gate)"
if parent is None:
# Root-level deny: add more specific denies for restricted resources
if action_obj and action_obj.takes_parent:
for db_name in restricted_databases:
rows.append((db_name, None, 0, reason))
if action_obj and action_obj.takes_child:
for db_name, table_name in restricted_tables:
rows.append((db_name, table_name, 0, reason))
else:
# Database-level deny: add child-level denies for restricted tables
if action_obj and action_obj.takes_child:
for db_name, table_name in restricted_tables:
if db_name == parent:
rows.append((db_name, table_name, 0, reason))
root_perm = (config.get("permissions") or {}).get(action)
add_row(None, None, evaluate(root_perm), f"permissions for {action}")
for db_name, db_config in (config.get("databases") or {}).items():
db_perm = (db_config.get("permissions") or {}).get(action)
add_row(
db_name, None, evaluate(db_perm), f"permissions for {action} on {db_name}"
)
for table_name, table_config in (db_config.get("tables") or {}).items():
table_perm = (table_config.get("permissions") or {}).get(action)
add_row(
db_name,
table_name,
evaluate(table_perm),
f"permissions for {action} on {db_name}/{table_name}",
)
if action == "view-table":
table_allow = (table_config or {}).get("allow")
add_row_allow_block(
db_name,
table_name,
table_allow,
f"allow for {action} on {db_name}/{table_name}",
)
for query_name, query_config in (db_config.get("queries") or {}).items():
# query_config can be a string (just SQL) or a dict (with SQL and options)
if isinstance(query_config, dict):
query_perm = (query_config.get("permissions") or {}).get(action)
add_row(
db_name,
query_name,
evaluate(query_perm),
f"permissions for {action} on {db_name}/{query_name}",
)
if action == "view-query":
query_allow = query_config.get("allow")
add_row_allow_block(
db_name,
query_name,
query_allow,
f"allow for {action} on {db_name}/{query_name}",
)
if action == "view-database":
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
if action == "execute-sql":
db_allow_sql = db_config.get("allow_sql")
add_row_allow_block(db_name, None, db_allow_sql, f"allow_sql for {db_name}")
if action == "view-table":
# Database-level allow block affects all tables in that database
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
if action == "view-query":
# Database-level allow block affects all queries in that database
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
# Root-level allow block applies to all view-* actions
if action == "view-instance":
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-instance")
if action == "view-database":
# Root-level allow block also applies to view-database
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-database")
if action == "view-table":
# Root-level allow block also applies to view-table
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-table")
if action == "view-query":
# Root-level allow block also applies to view-query
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-query")
if action == "execute-sql":
allow_sql = config.get("allow_sql")
add_row_allow_block(None, None, allow_sql, "allow_sql")
if not rows:
return []
parts = []
params = {}
for idx, (parent, child, allow, reason) in enumerate(rows):
key = f"cfg_{idx}"
parts.append(
f"SELECT :{key}_parent AS parent, :{key}_child AS child, :{key}_allow AS allow, :{key}_reason AS reason"
)
params[f"{key}_parent"] = parent
params[f"{key}_child"] = child
params[f"{key}_allow"] = 1 if allow else 0
params[f"{key}_reason"] = reason
sql = "\nUNION ALL\n".join(parts)
return [PermissionSQL(sql=sql, params=params)]
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(datasette, actor, action):
"""Enforce default_allow_sql setting for execute-sql action."""
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(datasette, actor, action):
"""Apply default allow rules for standard view/execute actions.
With the INTERSECT-based restriction approach, these defaults are always generated
and then filtered by restriction_sql if the actor has restrictions.
"""
default_allow_actions = {
"view-instance",
"view-database",
"view-database-download",
"view-table",
"view-query",
"execute-sql",
}
if action in default_allow_actions:
reason = f"default allow for {action}".replace("'", "''")
return PermissionSQL.allow(reason=reason)
return None
def restrictions_allow_action(
datasette: "Datasette",
restrictions: dict,
action: str,
resource: str | tuple[str, str],
):
"""
Check if actor restrictions allow the requested action against the requested resource.
Restrictions work on an exact-match basis: if an actor has view-table permission,
they can view tables, but NOT automatically view-instance or view-database.
Each permission is checked independently without implication logic.
"""
# Does this action have an abbreviation?
to_check = {action}
action_obj = datasette.actions.get(action)
if action_obj and action_obj.abbr:
to_check.add(action_obj.abbr)
# Check if restrictions explicitly allow this action
# Restrictions can be at three levels:
# - "a": global (any resource)
# - "d": per-database
# - "r": per-table/resource
# Check global level (any resource)
all_allowed = restrictions.get("a")
if all_allowed is not None:
assert isinstance(all_allowed, list)
if to_check.intersection(all_allowed):
return True
# Check database level
if resource:
if isinstance(resource, str):
database_name = resource
else:
database_name = resource[0]
database_allowed = restrictions.get("d", {}).get(database_name)
if database_allowed is not None:
assert isinstance(database_allowed, list)
if to_check.intersection(database_allowed):
return True
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False
@hookimpl
def actor_from_request(datasette, request):
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
return None
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
authorization = request.headers.get("authorization")
if not authorization:
return None
if not authorization.startswith("Bearer "):
return None
token = authorization[len("Bearer ") :]
if not token.startswith(prefix):
return None
token = token[len(prefix) :]
try:
decoded = datasette.unsign(token, namespace="token")
except itsdangerous.BadSignature:
return None
if "t" not in decoded:
# Missing timestamp
return None
created = decoded["t"]
if not isinstance(created, int):
# Invalid timestamp
return None
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
# Invalid duration
return None
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
and max_signed_tokens_ttl
and duration > max_signed_tokens_ttl
):
duration = max_signed_tokens_ttl
if duration:
if time.time() - created > duration:
# Expired
return None
actor = {"id": decoded["a"], "token": "dstok"}
if "_r" in decoded:
actor["_r"] = decoded["_r"]
if duration:
actor["token_expires"] = created + duration
return actor
@hookimpl
def skip_csrf(scope):
# Skip CSRF check for requests with content-type: application/json
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
@hookimpl
def canned_queries(datasette, database, actor):
"""Return canned queries from datasette configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -1,59 +0,0 @@
"""
Default permission implementations for Datasette.
This module provides the built-in permission checking logic through implementations
of the permission_resources_sql hook. The hooks are organized by their purpose:
1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens
2. Root User - Grants full access when --root flag is used
3. Config Rules - Applies permissions from datasette.yaml
4. Default Settings - Enforces default_allow_sql and default view permissions
IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL
UNION/INTERSECT operations. The order of evaluation is:
- restriction_sql fields are INTERSECTed (all must match)
- Regular sql fields are UNIONed and evaluated with cascading priority
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql,
restrictions_allow_action,
ActorRestrictions,
)
from .root import root_user_permissions_sql
from .config import config_permissions_sql
from .defaults import (
default_allow_sql_check,
default_action_permissions_sql,
DEFAULT_ALLOW_ACTIONS,
)
from .tokens import actor_from_signed_api_token
@hookimpl
def skip_csrf(scope) -> Optional[bool]:
"""Skip CSRF check for JSON content-type requests."""
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
return None
@hookimpl
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
"""Return canned queries defined in datasette.yaml configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -1,442 +0,0 @@
"""
Config-based permission handling for Datasette.
Applies permission rules from datasette.yaml configuration.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from datasette.utils import actor_matches_allow
from .helpers import PermissionRowCollector, get_action_name_variants
class ConfigPermissionProcessor:
"""
Processes permission rules from datasette.yaml configuration.
Configuration structure:
permissions: # Root-level permissions block
view-instance:
id: admin
databases:
mydb:
permissions: # Database-level permissions
view-database:
id: admin
allow: # Database-level allow block (for view-*)
id: viewer
allow_sql: # execute-sql allow block
id: analyst
tables:
users:
permissions: # Table-level permissions
view-table:
id: admin
allow: # Table-level allow block
id: viewer
queries:
my_query:
permissions: # Query-level permissions
view-query:
id: admin
allow: # Query-level allow block
id: viewer
"""
def __init__(
self,
datasette: "Datasette",
actor: Optional[dict],
action: str,
):
self.datasette = datasette
self.actor = actor
self.action = action
self.config = datasette.config or {}
self.collector = PermissionRowCollector(prefix="cfg")
# Pre-compute action variants
self.action_checks = get_action_name_variants(datasette, action)
self.action_obj = datasette.actions.get(action)
# Parse restrictions if present
self.has_restrictions = actor and "_r" in actor if actor else False
self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency
self.restricted_databases: Set[str] = set()
self.restricted_tables: Set[Tuple[str, str]] = set()
if self.has_restrictions:
self.restricted_databases = {
db_name
for db_name, db_actions in (self.restrictions.get("d") or {}).items()
if self.action_checks.intersection(db_actions)
}
self.restricted_tables = {
(db_name, table_name)
for db_name, tables in (self.restrictions.get("r") or {}).items()
for table_name, table_actions in tables.items()
if self.action_checks.intersection(table_actions)
}
# Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables)
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(
self,
parent: Optional[str],
child: Optional[str],
) -> bool:
"""Check if resource is allowed by actor restrictions."""
if not self.has_restrictions:
return True # No restrictions, all resources allowed
# Check global allowlist
if self.action_checks.intersection(self.restrictions.get("a", [])):
return True
# Check database-level allowlist
if parent and self.action_checks.intersection(
self.restrictions.get("d", {}).get(parent, [])
):
return True
# Check table-level allowlist
if parent:
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
if child:
table_actions = table_restrictions.get(child, [])
if self.action_checks.intersection(table_actions):
return True
else:
# Parent query should proceed if any child in this database is allowlisted
for table_actions in table_restrictions.values():
if self.action_checks.intersection(table_actions):
return True
# Parent/child both None: include if any restrictions exist for this action
if parent is None and child is None:
if self.action_checks.intersection(self.restrictions.get("a", [])):
return True
if self.restricted_databases:
return True
if self.restricted_tables:
return True
return False
def add_permissions_rule(
self,
parent: Optional[str],
child: Optional[str],
permissions_block: Optional[dict],
scope_desc: str,
) -> None:
"""Add a rule from a permissions:{action} block."""
if permissions_block is None:
return
action_allow_block = permissions_block.get(self.action)
result = self.evaluate_allow_block(action_allow_block)
self.collector.add(
parent=parent,
child=child,
allow=result,
reason=f"config {'allow' if result else 'deny'} {scope_desc}",
if_not_none=True,
)
def add_allow_block_rule(
self,
parent: Optional[str],
child: Optional[str],
allow_block: Any,
scope_desc: str,
) -> None:
"""
Add rules from an allow:{} block.
For allow blocks, if the block exists but doesn't match the actor,
this is treated as a deny. We also handle the restriction-gate logic.
"""
if allow_block is None:
return
# Skip if resource is not in restriction allowlist
if not self.is_in_restriction_allowlist(parent, child):
return
result = self.evaluate_allow_block(allow_block)
bool_result = bool(result)
self.collector.add(
parent,
child,
bool_result,
f"config {'allow' if result else 'deny'} {scope_desc}",
)
# Handle restriction-gate: add explicit denies for restricted resources
self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)
def _add_restriction_gate_denies(
self,
parent: Optional[str],
child: Optional[str],
is_allowed: bool,
scope_desc: str,
) -> None:
"""
When a config rule denies at a higher level, add explicit denies
for restricted resources to prevent child-level allows from
incorrectly granting access.
"""
if is_allowed or child is not None or not self.has_restrictions:
return
if not self.action_obj:
return
reason = f"config deny {scope_desc} (restriction gate)"
if parent is None:
# Root-level deny: add denies for all restricted resources
if self.action_obj.takes_parent:
for db_name in self.restricted_databases:
self.collector.add(db_name, None, False, reason)
if self.action_obj.takes_child:
for db_name, table_name in self.restricted_tables:
self.collector.add(db_name, table_name, False, reason)
else:
# Database-level deny: add denies for tables in that database
if self.action_obj.takes_child:
for db_name, table_name in self.restricted_tables:
if db_name == parent:
self.collector.add(db_name, table_name, False, reason)
def process(self) -> Optional[PermissionSQL]:
"""Process all config rules and return combined PermissionSQL."""
self._process_root_permissions()
self._process_databases()
self._process_root_allow_blocks()
return self.collector.to_permission_sql()
def _process_root_permissions(self) -> None:
"""Process root-level permissions block."""
root_perms = self.config.get("permissions") or {}
self.add_permissions_rule(
None,
None,
root_perms,
f"permissions for {self.action}",
)
def _process_databases(self) -> None:
"""Process database-level and nested configurations."""
databases = self.config.get("databases") or {}
for db_name, db_config in databases.items():
self._process_database(db_name, db_config or {})
def _process_database(self, db_name: str, db_config: dict) -> None:
"""Process a single database's configuration."""
# Database-level permissions block
db_perms = db_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
None,
db_perms,
f"permissions for {self.action} on {db_name}",
)
# Process tables
for table_name, table_config in (db_config.get("tables") or {}).items():
self._process_table(db_name, table_name, table_config or {})
# Process queries
for query_name, query_config in (db_config.get("queries") or {}).items():
self._process_query(db_name, query_name, query_config)
# Database-level allow blocks
self._process_database_allow_blocks(db_name, db_config)
def _process_table(
self,
db_name: str,
table_name: str,
table_config: dict,
) -> None:
"""Process a single table's configuration."""
# Table-level permissions block
table_perms = table_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
table_name,
table_perms,
f"permissions for {self.action} on {db_name}/{table_name}",
)
# Table-level allow block (for view-table)
if self.action == "view-table":
self.add_allow_block_rule(
db_name,
table_name,
table_config.get("allow"),
f"allow for {self.action} on {db_name}/{table_name}",
)
def _process_query(
self,
db_name: str,
query_name: str,
query_config: Any,
) -> None:
"""Process a single query's configuration."""
# Query config can be a string (just SQL) or dict
if not isinstance(query_config, dict):
return
# Query-level permissions block
query_perms = query_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
query_name,
query_perms,
f"permissions for {self.action} on {db_name}/{query_name}",
)
# Query-level allow block (for view-query)
if self.action == "view-query":
self.add_allow_block_rule(
db_name,
query_name,
query_config.get("allow"),
f"allow for {self.action} on {db_name}/{query_name}",
)
def _process_database_allow_blocks(
self,
db_name: str,
db_config: dict,
) -> None:
"""Process database-level allow/allow_sql blocks."""
# view-database allow block
if self.action == "view-database":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
# execute-sql allow_sql block
if self.action == "execute-sql":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow_sql"),
f"allow_sql for {db_name}",
)
# view-table uses database-level allow for inheritance
if self.action == "view-table":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
# view-query uses database-level allow for inheritance
if self.action == "view-query":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
def _process_root_allow_blocks(self) -> None:
"""Process root-level allow/allow_sql blocks."""
root_allow = self.config.get("allow")
if self.action == "view-instance":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-instance",
)
if self.action == "view-database":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-database",
)
if self.action == "view-table":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-table",
)
if self.action == "view-query":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-query",
)
if self.action == "execute-sql":
self.add_allow_block_rule(
None,
None,
self.config.get("allow_sql"),
"allow_sql",
)
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[List[PermissionSQL]]:
"""
Apply permission rules from datasette.yaml configuration.
This processes:
- permissions: blocks at root, database, table, and query levels
- allow: blocks for view-* actions
- allow_sql: blocks for execute-sql action
"""
processor = ConfigPermissionProcessor(datasette, actor, action)
result = processor.process()
if result is None:
return []
return [result]

View file

@ -1,70 +0,0 @@
"""
Default permission settings for Datasette.
Provides default allow rules for standard view/execute actions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
# Actions that are allowed by default (unless --default-deny is used)
DEFAULT_ALLOW_ACTIONS = frozenset(
{
"view-instance",
"view-database",
"view-database-download",
"view-table",
"view-query",
"execute-sql",
}
)
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
"""
Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules.
"""
if action == "execute-sql":
if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
"""
Provide default allow rules for standard view/execute actions.
These defaults are skipped when datasette is started with --default-deny.
The restriction_sql mechanism (from actor_restrictions_sql) will still
filter these results if the actor has restrictions.
"""
if datasette.default_deny:
return None
if action in DEFAULT_ALLOW_ACTIONS:
reason = f"default allow for {action}".replace("'", "''")
return PermissionSQL.allow(reason=reason)
return None

View file

@ -1,85 +0,0 @@
"""
Shared helper utilities for default permission implementations.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
"""
Get all name variants for an action (full name and abbreviation).
Example:
get_action_name_variants(ds, "view-table") -> {"view-table", "vt"}
"""
variants = {action}
action_obj = datasette.actions.get(action)
if action_obj and action_obj.abbr:
variants.add(action_obj.abbr)
return variants
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list))
@dataclass
class PermissionRow:
"""A single permission rule row."""
parent: Optional[str]
child: Optional[str]
allow: bool
reason: str
class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"):
self.rows: List[PermissionRow] = []
self.prefix = prefix
def add(
self,
parent: Optional[str],
child: Optional[str],
allow: Optional[bool],
reason: str,
if_not_none: bool = False,
) -> None:
"""Add a permission row. If if_not_none=True, only add if allow is not None."""
if if_not_none and allow is None:
return
self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> Optional[PermissionSQL]:
"""Convert collected rows to a PermissionSQL object."""
if not self.rows:
return None
parts = []
params = {}
for idx, row in enumerate(self.rows):
key = f"{self.prefix}_{idx}"
parts.append(
f"SELECT :{key}_parent AS parent, :{key}_child AS child, "
f":{key}_allow AS allow, :{key}_reason AS reason"
)
params[f"{key}_parent"] = row.parent
params[f"{key}_child"] = row.child
params[f"{key}_allow"] = 1 if row.allow else 0
params[f"{key}_reason"] = row.reason
sql = "\nUNION ALL\n".join(parts)
return PermissionSQL(sql=sql, params=params)

View file

@ -1,195 +0,0 @@
"""
Actor restriction handling for Datasette permissions.
This module handles the _r (restrictions) key in actor dictionaries, which
contains allowlists of resources the actor can access.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from .helpers import action_in_list, get_action_name_variants
@dataclass
class ActorRestrictions:
"""Parsed actor restrictions from the _r key."""
global_actions: List[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
"""Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor:
return None
assert isinstance(actor, dict), "actor must be a dictionary"
restrictions = actor.get("_r")
if restrictions is None:
return None
return cls(
global_actions=restrictions.get("a", []),
database_actions=restrictions.get("d", {}),
table_actions=restrictions.get("r", {}),
)
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
"""Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
"""Get database names where this action is allowed."""
allowed = set()
for db_name, db_actions in self.database_actions.items():
if action_in_list(datasette, action, db_actions):
allowed.add(db_name)
return allowed
def get_allowed_tables(
self, datasette: "Datasette", action: str
) -> Set[Tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed."""
allowed = set()
for db_name, tables in self.table_actions.items():
for table_name, table_actions in tables.items():
if action_in_list(datasette, action, table_actions):
allowed.add((db_name, table_name))
return allowed
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[List[PermissionSQL]]:
"""
Handle actor restriction-based permission rules.
When an actor has an "_r" key, it contains an allowlist of resources they
can access. This function returns restriction_sql that filters the final
results to only include resources in that allowlist.
The _r structure:
{
"a": ["vi", "pd"], # Global actions allowed
"d": {"mydb": ["vt", "es"]}, # Database-level actions
"r": {"mydb": {"users": ["vt"]}} # Table-level actions
}
"""
if not actor:
return None
restrictions = ActorRestrictions.from_actor(actor)
if restrictions is None:
# No restrictions - all resources allowed
return []
# If globally allowed, no filtering needed
if restrictions.is_action_globally_allowed(datasette, action):
return []
# Build restriction SQL
allowed_dbs = restrictions.get_allowed_databases(datasette, action)
allowed_tables = restrictions.get_allowed_tables(datasette, action)
# If nothing is allowed for this action, return empty-set restriction
if not allowed_dbs and not allowed_tables:
return [
PermissionSQL(
params={"deny": f"actor restrictions: {action} not in allowlist"},
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0",
)
]
# Build UNION of allowed resources
selects = []
params = {}
counter = 0
# Database-level entries (parent, NULL) - allows all children
for db_name in allowed_dbs:
key = f"restr_{counter}"
counter += 1
selects.append(f"SELECT :{key}_parent AS parent, NULL AS child")
params[f"{key}_parent"] = db_name
# Table-level entries (parent, child)
for db_name, table_name in allowed_tables:
key = f"restr_{counter}"
counter += 1
selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child")
params[f"{key}_parent"] = db_name
params[f"{key}_child"] = table_name
restriction_sql = "\nUNION ALL\n".join(selects)
return [PermissionSQL(params=params, restriction_sql=restriction_sql)]
def restrictions_allow_action(
datasette: "Datasette",
restrictions: dict,
action: str,
resource: Optional[str | Tuple[str, str]],
) -> bool:
"""
Check if restrictions allow the requested action on the requested resource.
This is a synchronous utility function for use by other code that needs
to quickly check restriction allowlists.
Args:
datasette: The Datasette instance
restrictions: The _r dict from an actor
action: The action name to check
resource: None for global, str for database, (db, table) tuple for table
Returns:
True if allowed, False if denied
"""
# Does this action have an abbreviation?
to_check = get_action_name_variants(datasette, action)
# Check global level (any resource)
all_allowed = restrictions.get("a")
if all_allowed is not None:
assert isinstance(all_allowed, list)
if to_check.intersection(all_allowed):
return True
# Check database level
if resource:
if isinstance(resource, str):
database_name = resource
else:
database_name = resource[0]
database_allowed = restrictions.get("d", {}).get(database_name)
if database_allowed is not None:
assert isinstance(database_allowed, list)
if to_check.intersection(database_allowed):
return True
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False

View file

@ -1,29 +0,0 @@
"""
Root user permission handling for Datasette.
Grants full permissions to the root user when --root flag is used.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
) -> Optional[PermissionSQL]:
"""
Grant root user full permissions when --root flag is used.
"""
if not datasette.root_enabled:
return None
if actor is not None and actor.get("id") == "root":
return PermissionSQL.allow(reason="root user")

View file

@ -1,95 +0,0 @@
"""
Token authentication for Datasette.
Handles signed API tokens (dstok_ prefix).
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
import itsdangerous
from datasette import hookimpl
@hookimpl(specname="actor_from_request")
def actor_from_signed_api_token(datasette: "Datasette", request) -> Optional[dict]:
"""
Authenticate requests using signed API tokens (dstok_ prefix).
Token structure (signed JSON):
{
"a": "actor_id", # Actor ID
"t": 1234567890, # Timestamp (Unix epoch)
"d": 3600, # Optional: Duration in seconds
"_r": {...} # Optional: Restrictions
}
"""
prefix = "dstok_"
# Check if tokens are enabled
if not datasette.setting("allow_signed_tokens"):
return None
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
# Get authorization header
authorization = request.headers.get("authorization")
if not authorization:
return None
if not authorization.startswith("Bearer "):
return None
token = authorization[len("Bearer ") :]
if not token.startswith(prefix):
return None
# Remove prefix and verify signature
token = token[len(prefix) :]
try:
decoded = datasette.unsign(token, namespace="token")
except itsdangerous.BadSignature:
return None
# Validate timestamp
if "t" not in decoded:
return None
created = decoded["t"]
if not isinstance(created, int):
return None
# Handle duration/expiry
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
return None
# Apply max TTL if configured
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
and max_signed_tokens_ttl
and duration > max_signed_tokens_ttl
):
duration = max_signed_tokens_ttl
# Check expiry
if duration:
if time.time() - created > duration:
return None
# Build actor dict
actor = {"id": decoded["a"], "token": "dstok"}
# Copy restrictions if present
if "_r" in decoded:
actor["_r"] = decoded["_r"]
# Add expiry timestamp if applicable
if duration:
actor["token_expires"] = created + duration
return actor

View file

@ -94,24 +94,21 @@ def get_plugins():
for plugin in pm.get_plugins():
static_path = None
templates_path = None
plugin_name = (
plugin.__name__
if hasattr(plugin, "__name__")
else plugin.__class__.__name__
)
if plugin_name not in DEFAULT_PLUGINS:
if plugin.__name__ not in DEFAULT_PLUGINS:
try:
if (importlib_resources.files(plugin_name) / "static").is_dir():
static_path = str(importlib_resources.files(plugin_name) / "static")
if (importlib_resources.files(plugin_name) / "templates").is_dir():
if (importlib_resources.files(plugin.__name__) / "static").is_dir():
static_path = str(
importlib_resources.files(plugin.__name__) / "static"
)
if (importlib_resources.files(plugin.__name__) / "templates").is_dir():
templates_path = str(
importlib_resources.files(plugin_name) / "templates"
importlib_resources.files(plugin.__name__) / "templates"
)
except (TypeError, ModuleNotFoundError):
# Caused by --plugins_dir= plugins
pass
plugin_info = {
"name": plugin_name,
"name": plugin.__name__,
"static_path": static_path,
"templates_path": templates_path,
"hooks": [h.name for h in pm.get_hookcallers(plugin)],

View file

@ -330,6 +330,316 @@ function initAutocompleteForFilterValues(manager) {
});
}
/** Initialize row detail side panel functionality */
function initRowDetailPanel() {
const dialog = document.getElementById('rowDetailPanel');
const closeButton = document.getElementById('closeRowDetail');
const contentDiv = document.getElementById('rowDetailContent');
const prevButton = document.getElementById('prevRowButton');
const nextButton = document.getElementById('nextRowButton');
const positionSpan = document.getElementById('rowPosition');
if (!dialog || !closeButton || !contentDiv || !prevButton || !nextButton) {
// Not on a table page with the panel
return;
}
// State for navigation
let currentRowIndex = 0;
let allRows = []; // Array of objects: { element: DOMElement, pkValues: [...] }
let nextPageUrl = null;
let isLoadingMore = false;
let hasMoreRows = true;
// Get primary key column names
function getPrimaryKeyNames() {
const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]');
return Array.from(headers).map(th => th.getAttribute('data-column'));
}
const primaryKeyNames = getPrimaryKeyNames();
// Initialize the row list
function initializeRows() {
const domRows = document.querySelectorAll('.table-row-clickable');
allRows = Array.from(domRows).map(row => ({
element: row,
pkValues: extractPkValues(row)
}));
// Check if there's a next page link
const nextLink = document.querySelector('a[href*="_next="]');
nextPageUrl = nextLink ? nextLink.getAttribute('href') : null;
hasMoreRows = !!nextPageUrl;
}
// Extract primary key values from a DOM row
function extractPkValues(row) {
const pkColumns = getPrimaryKeyColumns();
const cells = row.querySelectorAll('td');
return pkColumns.map(pk => {
const cell = cells[pk.index];
if (!cell) return null;
return cell.getAttribute('data-value') || cell.textContent.trim();
});
}
initializeRows();
// Prevent default cancel behavior (ESC key) to handle animation
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
animateCloseDialog();
});
function animateCloseDialog() {
dialog.style.transform = 'translateX(100%)';
setTimeout(() => {
dialog.close();
}, 100);
}
closeButton.addEventListener('click', () => {
animateCloseDialog();
});
// Close on backdrop click
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
animateCloseDialog();
}
});
// Get primary key column indices
function getPrimaryKeyColumns() {
const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]');
return Array.from(headers).map(th => {
const columnName = th.getAttribute('data-column');
const index = Array.from(th.parentElement.children).indexOf(th);
return { name: columnName, index: index };
});
}
// Construct row URL from row object (which has pkValues)
function getRowUrl(rowObj) {
if (!rowObj || !rowObj.pkValues || rowObj.pkValues.length === 0) {
return null;
}
const pkValues = rowObj.pkValues;
if (pkValues.some(v => v === null || v === '')) {
return null;
}
// Construct the row path by joining PK values
const rowPath = pkValues.map(v => encodeURIComponent(v)).join(',');
// Get current path and construct row URL
const currentPath = window.location.pathname;
return currentPath + '/' + rowPath + '.json';
}
// Fetch more rows from the next page using JSON API
async function fetchMoreRows() {
if (!nextPageUrl || isLoadingMore) {
return false;
}
isLoadingMore = true;
try {
// Convert URL to JSON by adding .json before query params
let jsonUrl = nextPageUrl;
const urlParts = nextPageUrl.split('?');
if (urlParts.length === 2) {
jsonUrl = urlParts[0] + '.json?' + urlParts[1];
} else {
jsonUrl = nextPageUrl + '.json';
}
const response = await fetch(jsonUrl);
if (!response.ok) {
throw new Error(`Failed to fetch next page: ${response.status}`);
}
const data = await response.json();
// Extract new rows from JSON
if (data.rows && data.rows.length > 0) {
const newRowObjects = data.rows.map(rowData => {
// Extract primary key values from the row data
const pkValues = primaryKeyNames.map(pkName => {
const value = rowData[pkName];
return value !== null && value !== undefined ? String(value) : null;
});
return {
element: null, // No DOM element for paginated rows
pkValues: pkValues
};
});
allRows.push(...newRowObjects);
}
// Update next page URL from the response
nextPageUrl = data.next_url || null;
hasMoreRows = !!nextPageUrl;
isLoadingMore = false;
return data.rows && data.rows.length > 0;
} catch (error) {
console.error('Error fetching more rows:', error);
isLoadingMore = false;
hasMoreRows = false;
return false;
}
}
// Update navigation button states
function updateNavigationState() {
prevButton.disabled = currentRowIndex === 0;
// Disable next if we're at the end and there are no more pages
const isAtEnd = currentRowIndex >= allRows.length - 1;
nextButton.disabled = isAtEnd && !hasMoreRows;
// Update position display
if (allRows.length > 0) {
const displayIndex = currentRowIndex + 1;
positionSpan.textContent = `Row ${displayIndex}`;
} else {
positionSpan.textContent = '';
}
}
// Fetch and display row details
async function showRowDetails(rowIndex) {
if (rowIndex < 0 || rowIndex >= allRows.length) {
return;
}
currentRowIndex = rowIndex;
const rowObj = allRows[rowIndex];
const rowUrl = getRowUrl(rowObj);
if (!rowUrl) {
contentDiv.innerHTML = '<p class="error">Cannot display row: No primary key found</p>';
showDialog();
updateNavigationState();
return;
}
// Show loading state
contentDiv.innerHTML = '<p class="loading">Loading...</p>';
updateNavigationState();
try {
const response = await fetch(rowUrl);
if (!response.ok) {
throw new Error(`Failed to fetch row: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Display the row data
if (data.rows && data.rows.length > 0) {
const rowData = data.rows[0];
let html = '<dl>';
for (const [key, value] of Object.entries(rowData)) {
html += `<dt>${escapeHtml(key)}</dt>`;
if (value === null) {
html += '<dd class="null-value">null</dd>';
} else if (typeof value === 'object') {
html += `<dd><pre>${escapeHtml(JSON.stringify(value, null, 2))}</pre></dd>`;
} else {
html += `<dd>${escapeHtml(String(value))}</dd>`;
}
}
html += '</dl>';
contentDiv.innerHTML = html;
} else {
contentDiv.innerHTML = '<p class="error">No row data found</p>';
}
} catch (error) {
console.error('Error fetching row details:', error);
contentDiv.innerHTML = `<p class="error">Error loading row details: ${escapeHtml(error.message)}</p>`;
}
updateNavigationState();
}
// Handle previous button click
prevButton.addEventListener('click', () => {
if (currentRowIndex > 0) {
showRowDetails(currentRowIndex - 1);
}
});
// Handle next button click
nextButton.addEventListener('click', async () => {
const nextIndex = currentRowIndex + 1;
// If we're at the end of current rows, try to fetch more
if (nextIndex >= allRows.length && hasMoreRows && !isLoadingMore) {
nextButton.disabled = true;
nextButton.textContent = 'Loading...';
const fetched = await fetchMoreRows();
nextButton.textContent = 'Next →';
if (fetched && nextIndex < allRows.length) {
showRowDetails(nextIndex);
} else {
updateNavigationState();
}
} else if (nextIndex < allRows.length) {
showRowDetails(nextIndex);
}
});
function showDialog() {
// Reset transform before opening
dialog.style.transition = 'none';
dialog.style.transform = 'translateX(100%)';
// Open the dialog
dialog.showModal();
// Trigger animation
void dialog.offsetWidth;
dialog.style.transition = 'transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9)';
dialog.style.transform = 'translateX(0)';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Add click handlers to all table rows (only for rows with DOM elements)
allRows.forEach((rowObj, index) => {
if (rowObj.element) {
rowObj.element.addEventListener('click', (event) => {
// Don't trigger if clicking on a link or button within the row
if (event.target.tagName === 'A' || event.target.tagName === 'BUTTON') {
return;
}
showDialog();
showRowDetails(index);
});
}
});
}
// Ensures Table UI is initialized only after the Manager is ready.
document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt;
@ -340,4 +650,7 @@ document.addEventListener("datasette_init", function (evt) {
// Other UI functions with interactive JS needs
addButtonsToFilterRows(manager);
initAutocompleteForFilterValues(manager);
// Row detail panel
initRowDetailPanel();
});

View file

@ -22,7 +22,7 @@
</thead>
<tbody>
{% for row in display_rows %}
<tr>
<tr class="table-row-clickable" data-row-index="{{ loop.index0 }}">
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %}
@ -34,3 +34,191 @@
{% else %}
<p class="zero-results">0 records</p>
{% endif %}
<!-- Row detail side panel dialog -->
<dialog id="rowDetailPanel" class="row-detail-panel">
<div class="dialog-content">
<div class="dialog-header">
<h2>Row details</h2>
<button class="close-button" id="closeRowDetail" aria-label="Close">×</button>
</div>
<div class="dialog-navigation">
<button class="nav-button" id="prevRowButton" aria-label="Previous row">← Previous</button>
<span class="row-position" id="rowPosition"></span>
<button class="nav-button" id="nextRowButton" aria-label="Next row">Next →</button>
</div>
<div id="rowDetailContent" class="row-detail-content">
<p class="loading">Loading...</p>
</div>
</div>
</dialog>
<style>
/* Row detail side panel styles */
.table-row-clickable {
cursor: pointer;
}
.table-row-clickable:hover {
background-color: #f5f5f5;
}
.row-detail-panel {
position: fixed;
margin: 0;
padding: 0;
border: none;
width: 90%;
max-width: 600px;
height: 100vh;
top: 0;
right: 0;
left: auto;
background-color: white;
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
transform: translateX(100%);
transition: transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
max-height: none;
}
@media only screen and (min-width: 768px) {
.row-detail-panel {
width: 50%;
max-width: 800px;
}
}
.row-detail-panel::backdrop {
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
}
.row-detail-panel[open]::backdrop {
opacity: 1;
}
.dialog-content {
padding: 24px;
height: 100%;
overflow-y: auto;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h2 {
margin: 0;
font-size: 1.5em;
}
.close-button {
background: none;
border: none;
font-size: 32px;
cursor: pointer;
padding: 8px;
margin: -8px;
color: #555;
line-height: 1;
}
.close-button:hover {
color: #000;
}
.dialog-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
gap: 12px;
}
.nav-button {
background-color: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.nav-button:hover:not(:disabled) {
background-color: #3a5ce6;
}
.nav-button:disabled {
background-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.row-position {
font-size: 14px;
color: #666;
text-align: center;
flex: 1;
}
.row-detail-content {
font-size: 14px;
}
.row-detail-content .loading {
text-align: center;
color: #666;
padding: 40px;
}
.row-detail-content dl {
margin: 0;
}
.row-detail-content dt {
font-weight: bold;
margin-top: 16px;
margin-bottom: 4px;
color: #333;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.row-detail-content dt:first-child {
margin-top: 0;
}
.row-detail-content dd {
margin: 0;
margin-bottom: 12px;
padding: 12px;
background-color: #f9f9f9;
border-radius: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.row-detail-content dd.null-value {
color: #999;
font-style: italic;
}
.row-detail-content .error {
color: #c00;
padding: 20px;
background-color: #fee;
border-radius: 4px;
}
</style>

View file

@ -31,7 +31,7 @@
<td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
<td>{{ action.description or "" }}</td>
<td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td>
<td><code>{{ action.resource_class }}</code></td>
<td>{% if action.takes_parent %}✓{% endif %}</td>
<td>{% if action.takes_child %}✓{% endif %}</td>
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>

View file

@ -1,2 +1,2 @@
__version__ = "1.0a23"
__version__ = "1.0a21"
__version_info__ = tuple(__version__.split("."))

View file

@ -83,39 +83,6 @@ Datasette's built-in view actions (``view-database``, ``view-table`` etc) are al
Other actions, including those introduced by plugins, will default to *deny*.
.. _authentication_default_deny:
Denying all permissions by default
----------------------------------
By default, Datasette allows unauthenticated access to view databases, tables, and execute SQL queries.
You may want to run Datasette in a mode where **all** access is denied by default, and you explicitly grant permissions only to authenticated users, either using the :ref:`--root mechanism <authentication_root>` or through :ref:`configuration file rules <authentication_permissions_config>` or plugins.
Use the ``--default-deny`` command-line option to run Datasette in this mode::
datasette --default-deny data.db --root
With ``--default-deny`` enabled:
* Anonymous users are denied access to view the instance, databases, tables, and queries
* Authenticated users are also denied access unless they're explicitly granted permissions
* The root user (when using ``--root``) still has access to everything
* You can grant permissions using :ref:`configuration file rules <authentication_permissions_config>` or plugins
For example, to allow only a specific user to access your instance::
datasette --default-deny data.db --config datasette.yaml
Where ``datasette.yaml`` contains:
.. code-block:: yaml
allow:
id: alice
This configuration will deny access to everyone except the user with ``id`` of ``alice``.
.. _authentication_permissions_explained:
How permissions are resolved

View file

@ -4,23 +4,6 @@
Changelog
=========
.. _v1_0_a23:
1.0a23 (2025-12-02)
-------------------
- Fix for bug where a stale database entry in ``internal.db`` could cause a 500 error on the homepage. (:issue:`2605`)
- Cosmetic improvement to ``/-/actions`` page. (:issue:`2599`)
.. _v1_0_a22:
1.0a22 (2025-11-13)
-------------------
- ``datasette serve --default-deny`` option for running Datasette configured to :ref:`deny all permissions by default <authentication_default_deny>`. (:issue:`2592`)
- ``datasette.is_client()`` method for detecting if code is :ref:`executing inside a datasette.client request <internals_datasette_is_client>`. (:issue:`2594`)
- ``datasette.pm`` property can now be used to :ref:`register and unregister plugins in tests <testing_plugins_register_in_test>`. (:issue:`2595`)
.. _v1_0_a21:
1.0a21 (2025-11-05)
@ -295,7 +278,7 @@ To avoid similar mistakes in the future the ``datasette.permission_allowed()`` m
Permission checks now consider opinions from every plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``datasette.permission_allowed()`` method previously consulted every plugin that implemented the ``permission_allowed()`` plugin hook and obeyed the opinion of the last plugin to return a value. (:issue:`2275`)
The ``datasette.permission_allowed()`` method previously consulted every plugin that implemented the :ref:`permission_allowed() <plugin_hook_permission_allowed>` plugin hook and obeyed the opinion of the last plugin to return a value. (:issue:`2275`)
Datasette now consults every plugin and checks to see if any of them returned ``False`` (the veto rule), and if none of them did, it then checks to see if any of them returned ``True``.
@ -1414,7 +1397,7 @@ You can use the new ``"allow"`` block syntax in ``metadata.json`` (or ``metadata
See :ref:`authentication_permissions_allow` for more details.
Plugins can implement their own custom permission checks using the new ``plugin_hook_permission_allowed()`` plugin hook.
Plugins can implement their own custom permission checks using the new :ref:`plugin_hook_permission_allowed` hook.
A new debug page at ``/-/permissions`` shows recent permission checks, to help administrators and plugin authors understand exactly what checks are being performed. This tool defaults to only being available to the root user, but can be exposed to other users by plugins that respond to the ``permissions-debug`` permission. (:issue:`788`)

View file

@ -119,7 +119,6 @@ Once started you can access it at ``http://localhost:8001``
signed cookies
--root Output URL that sets a cookie authenticating
the root user
--default-deny Deny all permissions by default
--get TEXT Run an HTTP GET request against this path,
print results and exit
--headers Include HTTP headers in --get output

View file

@ -1077,30 +1077,6 @@ This parameter works with all HTTP methods (``get``, ``post``, ``put``, ``patch`
Use ``skip_permission_checks=True`` with caution. It completely bypasses Datasette's permission system and should only be used in trusted plugin code or internal operations where you need guaranteed access to resources.
.. _internals_datasette_is_client:
Detecting internal client requests
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette.in_client()`` - returns bool
Returns ``True`` if the current code is executing within a ``datasette.client`` request, ``False`` otherwise.
This method is useful for plugins that need to behave differently when called through ``datasette.client`` versus when handling external HTTP requests.
Example usage:
.. code-block:: python
async def fetch_documents(datasette):
if not datasette.in_client():
return Response.text(
"Only available via internal client requests",
status=403,
)
...
Note that ``datasette.in_client()`` is independent of ``skip_permission_checks``. A request made through ``datasette.client`` will always have ``in_client()`` return ``True``, regardless of whether ``skip_permission_checks`` is set.
.. _internals_datasette_urls:
datasette.urls

View file

@ -283,12 +283,13 @@ Here's a test for that plugin that mocks the HTTPX outbound request:
Registering a plugin for the duration of a test
-----------------------------------------------
When writing tests for plugins you may find it useful to register a test plugin just for the duration of a single test. You can do this using ``datasette.pm.register()`` and ``datasette.pm.unregister()`` like this:
When writing tests for plugins you may find it useful to register a test plugin just for the duration of a single test. You can do this using ``pm.register()`` and ``pm.unregister()`` like this:
.. code-block:: python
from datasette import hookimpl
from datasette.app import Datasette
from datasette.plugins import pm
import pytest
@ -304,14 +305,14 @@ When writing tests for plugins you may find it useful to register a test plugin
(r"^/error$", lambda: 1 / 0),
]
datasette = Datasette()
pm.register(TestPlugin(), name="undo")
try:
# The test implementation goes here
datasette.pm.register(TestPlugin(), name="undo")
datasette = Datasette()
response = await datasette.client.get("/error")
assert response.status_code == 500
finally:
datasette.pm.unregister(name="undo")
pm.unregister(name="undo")
To reuse the same temporary plugin in multiple tests, you can register it inside a fixture in your ``conftest.py`` file like this:

View file

@ -69,15 +69,16 @@ docs = [
"ruamel.yaml",
]
test = [
"pytest>=9",
"pytest>=5.2.2",
"pytest-xdist>=2.2.1",
"pytest-asyncio>=1.2.0",
"beautifulsoup4>=4.8.1",
"black==25.11.0",
"black==25.9.0",
"blacken-docs==1.20.0",
"pytest-timeout>=1.4.2",
"trustme>=0.7",
"cogapp>=3.3.0",
"pytest-playwright>=0.7.1"
]
rich = ["rich"]
@ -93,6 +94,3 @@ datasette = ["templates/*.html"]
[tool.setuptools.dynamic]
version = {attr = "datasette.version.__version__"}
[tool.uv]
package = true

View file

@ -11,6 +11,7 @@ These tests verify:
import pytest
import pytest_asyncio
from datasette.app import Datasette
from datasette.plugins import pm
from datasette.permissions import PermissionSQL
from datasette.resources import TableResource
from datasette import hookimpl
@ -66,7 +67,7 @@ async def test_allowed_resources_global_allow(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
# Use the new allowed_resources() method
@ -86,7 +87,7 @@ async def test_allowed_resources_global_allow(test_ds):
assert ("production", "orders") in table_set
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -105,7 +106,7 @@ async def test_allowed_specific_resource(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
actor = {"id": "bob", "role": "analyst"}
@ -129,7 +130,7 @@ async def test_allowed_specific_resource(test_ds):
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -147,7 +148,7 @@ async def test_allowed_resources_include_reasons(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
# Use allowed_resources with include_reasons to get debugging info
@ -169,7 +170,7 @@ async def test_allowed_resources_include_reasons(test_ds):
assert "analyst access" in reasons_text
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -189,7 +190,7 @@ async def test_child_deny_overrides_parent_allow(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
actor = {"id": "bob", "role": "analyst"}
@ -218,7 +219,7 @@ async def test_child_deny_overrides_parent_allow(test_ds):
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -238,7 +239,7 @@ async def test_child_allow_overrides_parent_deny(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
actor = {"id": "carol"}
@ -263,7 +264,7 @@ async def test_child_allow_overrides_parent_deny(test_ds):
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -287,7 +288,7 @@ async def test_sql_does_filtering_not_python(test_ds):
return PermissionSQL(sql=sql)
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
actor = {"id": "dave"}
@ -313,4 +314,4 @@ async def test_sql_does_filtering_not_python(test_ds):
assert tables[0].child == "users"
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")

View file

@ -8,6 +8,7 @@ based on permission rules from plugins and configuration.
import pytest
import pytest_asyncio
from datasette.app import Datasette
from datasette.plugins import pm
from datasette.permissions import PermissionSQL
from datasette import hookimpl
@ -61,7 +62,7 @@ async def test_tables_endpoint_global_access(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
# Use the allowed_resources API directly
@ -86,7 +87,7 @@ async def test_tables_endpoint_global_access(test_ds):
assert "production/orders" in table_names
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -101,7 +102,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
page = await test_ds.allowed_resources(
@ -129,7 +130,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
# Note: default_permissions.py provides default allows, so we just check analytics are present
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -148,7 +149,7 @@ async def test_tables_endpoint_table_exception(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
page = await test_ds.allowed_resources("view-table", {"id": "carol"})
@ -171,7 +172,7 @@ async def test_tables_endpoint_table_exception(test_ds):
assert "analytics/sensitive" not in table_names
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -190,7 +191,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
page = await test_ds.allowed_resources(
@ -213,7 +214,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
assert "analytics/sensitive" not in table_names
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -256,7 +257,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
page = await test_ds.allowed_resources("view-table", {"id": "dave"})
@ -279,7 +280,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
assert "production/orders" in table_names
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
@ -294,7 +295,7 @@ async def test_tables_endpoint_empty_result(test_ds):
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
page = await test_ds.allowed_resources("view-table", {"id": "blocked"})
@ -310,7 +311,7 @@ async def test_tables_endpoint_empty_result(test_ds):
assert len(result) == 0
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio

View file

@ -142,7 +142,6 @@ def test_metadata_yaml():
settings=[],
secret=None,
root=False,
default_deny=False,
token=None,
actor=None,
version_note=None,

View file

@ -1,129 +0,0 @@
import pytest
from datasette.app import Datasette
from datasette.resources import DatabaseResource, TableResource
@pytest.mark.asyncio
async def test_default_deny_denies_default_permissions():
"""Test that default_deny=True denies default permissions"""
# Without default_deny, anonymous users can view instance/database/tables
ds_normal = Datasette()
await ds_normal.invoke_startup()
# Add a test database
db = ds_normal.add_memory_database("test_db_normal")
await db.execute_write("create table test_table (id integer primary key)")
await ds_normal._refresh_schemas() # Trigger catalog refresh
# Test default behavior - anonymous user should be able to view
response = await ds_normal.client.get("/")
assert response.status_code == 200
response = await ds_normal.client.get("/test_db_normal")
assert response.status_code == 200
response = await ds_normal.client.get("/test_db_normal/test_table")
assert response.status_code == 200
# With default_deny=True, anonymous users should be denied
ds_deny = Datasette(default_deny=True)
await ds_deny.invoke_startup()
# Add the same test database
db = ds_deny.add_memory_database("test_db_deny")
await db.execute_write("create table test_table (id integer primary key)")
await ds_deny._refresh_schemas() # Trigger catalog refresh
# Anonymous user should be denied
response = await ds_deny.client.get("/")
assert response.status_code == 403
response = await ds_deny.client.get("/test_db_deny")
assert response.status_code == 403
response = await ds_deny.client.get("/test_db_deny/test_table")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_default_deny_with_root_user():
"""Test that root user still has access when default_deny=True"""
ds = Datasette(default_deny=True)
ds.root_enabled = True
await ds.invoke_startup()
root_actor = {"id": "root"}
# Root user should have all permissions even with default_deny
assert await ds.allowed(action="view-instance", actor=root_actor) is True
assert (
await ds.allowed(
action="view-database",
actor=root_actor,
resource=DatabaseResource("test_db"),
)
is True
)
assert (
await ds.allowed(
action="view-table",
actor=root_actor,
resource=TableResource("test_db", "test_table"),
)
is True
)
assert (
await ds.allowed(
action="execute-sql", actor=root_actor, resource=DatabaseResource("test_db")
)
is True
)
@pytest.mark.asyncio
async def test_default_deny_with_config_allow():
"""Test that config allow rules still work with default_deny=True"""
ds = Datasette(default_deny=True, config={"allow": {"id": "user1"}})
await ds.invoke_startup()
# Anonymous user should be denied
assert await ds.allowed(action="view-instance", actor=None) is False
# Authenticated user with explicit permission should have access
assert await ds.allowed(action="view-instance", actor={"id": "user1"}) is True
# Different user should be denied
assert await ds.allowed(action="view-instance", actor={"id": "user2"}) is False
@pytest.mark.asyncio
async def test_default_deny_basic_permissions():
"""Test that default_deny=True denies basic permissions"""
ds = Datasette(default_deny=True)
await ds.invoke_startup()
# Anonymous user should be denied all default permissions
assert await ds.allowed(action="view-instance", actor=None) is False
assert (
await ds.allowed(
action="view-database", actor=None, resource=DatabaseResource("test_db")
)
is False
)
assert (
await ds.allowed(
action="view-table",
actor=None,
resource=TableResource("test_db", "test_table"),
)
is False
)
assert (
await ds.allowed(
action="execute-sql", actor=None, resource=DatabaseResource("test_db")
)
is False
)
# Authenticated user without explicit permission should also be denied
assert await ds.allowed(action="view-instance", actor={"id": "user"}) is False

View file

@ -28,10 +28,9 @@ def settings_headings():
return get_headings((docs_path / "settings.rst").read_text(), "~")
def test_settings_are_documented(settings_headings, subtests):
for setting in app.SETTINGS:
with subtests.test(setting=setting.name):
assert setting.name in settings_headings
@pytest.mark.parametrize("setting", app.SETTINGS)
def test_settings_are_documented(settings_headings, setting):
assert setting.name in settings_headings
@pytest.fixture(scope="session")
@ -39,21 +38,21 @@ def plugin_hooks_content():
return (docs_path / "plugin_hooks.rst").read_text()
def test_plugin_hooks_are_documented(plugin_hooks_content, subtests):
@pytest.mark.parametrize(
"plugin", [name for name in dir(app.pm.hook) if not name.startswith("_")]
)
def test_plugin_hooks_are_documented(plugin, plugin_hooks_content):
headings = set()
headings.update(get_headings(plugin_hooks_content, "-"))
headings.update(get_headings(plugin_hooks_content, "~"))
plugins = [name for name in dir(app.pm.hook) if not name.startswith("_")]
for plugin in plugins:
with subtests.test(plugin=plugin):
assert plugin in headings
hook_caller = getattr(app.pm.hook, plugin)
arg_names = [a for a in hook_caller.spec.argnames if a != "__multicall__"]
# Check for plugin_name(arg1, arg2, arg3)
expected = f"{plugin}({', '.join(arg_names)})"
assert (
expected in plugin_hooks_content
), f"Missing from plugin hook documentation: {expected}"
assert plugin in headings
hook_caller = getattr(app.pm.hook, plugin)
arg_names = [a for a in hook_caller.spec.argnames if a != "__multicall__"]
# Check for plugin_name(arg1, arg2, arg3)
expected = f"{plugin}({', '.join(arg_names)})"
assert (
expected in plugin_hooks_content
), f"Missing from plugin hook documentation: {expected}"
@pytest.fixture(scope="session")
@ -69,11 +68,9 @@ def documented_views():
return view_labels
def test_view_classes_are_documented(documented_views, subtests):
view_classes = [v for v in dir(app) if v.endswith("View")]
for view_class in view_classes:
with subtests.test(view_class=view_class):
assert view_class in documented_views
@pytest.mark.parametrize("view_class", [v for v in dir(app) if v.endswith("View")])
def test_view_classes_are_documented(documented_views, view_class):
assert view_class in documented_views
@pytest.fixture(scope="session")
@ -88,10 +85,9 @@ def documented_table_filters():
}
def test_table_filters_are_documented(documented_table_filters, subtests):
for f in Filters._filters:
with subtests.test(filter=f.key):
assert f.key in documented_table_filters
@pytest.mark.parametrize("filter", [f.key for f in Filters._filters])
def test_table_filters_are_documented(documented_table_filters, filter):
assert filter in documented_table_filters
@pytest.fixture(scope="session")
@ -105,10 +101,9 @@ def documented_fns():
}
def test_functions_marked_with_documented_are_documented(documented_fns, subtests):
for fn in utils.functions_marked_as_documented:
with subtests.test(fn=fn.__name__):
assert fn.__name__ in documented_fns
@pytest.mark.parametrize("fn", utils.functions_marked_as_documented)
def test_functions_marked_with_documented_are_documented(documented_fns, fn):
assert fn.__name__ in documented_fns
def test_rst_heading_underlines_match_title_length():

View file

@ -2,6 +2,7 @@
# -- start datasette_with_plugin_fixture --
from datasette import hookimpl
from datasette.app import Datasette
from datasette.plugins import pm
import pytest
import pytest_asyncio
@ -17,12 +18,11 @@ async def datasette_with_plugin():
(r"^/error$", lambda: 1 / 0),
]
datasette = Datasette()
datasette.pm.register(TestPlugin(), name="undo")
pm.register(TestPlugin(), name="undo")
try:
yield datasette
yield Datasette()
finally:
datasette.pm.unregister(name="undo")
pm.unregister(name="undo")
# -- end datasette_with_plugin_fixture --

View file

@ -1194,21 +1194,6 @@ async def test_actions_page(ds_client):
ds_client.ds.root_enabled = original_root_enabled
@pytest.mark.asyncio
async def test_actions_page_does_not_display_none_string(ds_client):
"""Ensure the Resource column doesn't display the string 'None' for null values."""
# https://github.com/simonw/datasette/issues/2599
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
cookies = {"ds_actor": ds_client.actor_cookie({"id": "root"})}
response = await ds_client.get("/-/actions", cookies=cookies)
assert response.status_code == 200
assert "<code>None</code>" not in response.text
finally:
ds_client.ds.root_enabled = original_root_enabled
@pytest.mark.asyncio
async def test_permission_debug_tabs_with_query_string(ds_client):
"""Test that navigation tabs persist query strings across Check, Allowed, and Rules pages"""

View file

@ -91,51 +91,3 @@ async def test_internal_foreign_key_references(ds_client):
)
await internal_db.execute_fn(inner)
@pytest.mark.asyncio
async def test_stale_catalog_entry_database_fix(tmp_path):
"""
Test for https://github.com/simonw/datasette/issues/2605
When the internal database persists across restarts and has entries in
catalog_databases for databases that no longer exist, accessing the
index page should not cause a 500 error (KeyError).
"""
from datasette.app import Datasette
internal_db_path = str(tmp_path / "internal.db")
data_db_path = str(tmp_path / "data.db")
# Create a data database file
import sqlite3
conn = sqlite3.connect(data_db_path)
conn.execute("CREATE TABLE test_table (id INTEGER PRIMARY KEY)")
conn.close()
# First Datasette instance: with the data database and persistent internal db
ds1 = Datasette(files=[data_db_path], internal=internal_db_path)
await ds1.invoke_startup()
# Access the index page to populate the internal catalog
response = await ds1.client.get("/")
assert "data" in ds1.databases
assert response.status_code == 200
# Second Datasette instance: reusing internal.db but WITHOUT the data database
# This simulates restarting Datasette after removing a database
ds2 = Datasette(internal=internal_db_path)
await ds2.invoke_startup()
# The database is not in ds2.databases
assert "data" not in ds2.databases
# Accessing the index page should NOT cause a 500 error
# This is the bug: it currently raises KeyError when trying to
# access ds.databases["data"] for the stale catalog entry
response = await ds2.client.get("/")
assert response.status_code == 200, (
f"Index page should return 200, not {response.status_code}. "
"This fails due to stale catalog entries causing KeyError."
)

View file

@ -227,87 +227,3 @@ async def test_skip_permission_checks_shows_denied_tables():
table_names = [match["name"] for match in data["matches"]]
# Should see fixtures tables when permission checks are skipped
assert "fixtures: test_table" in table_names
@pytest.mark.asyncio
async def test_in_client_returns_false_outside_request(datasette):
"""Test that datasette.in_client() returns False outside of a client request"""
assert datasette.in_client() is False
@pytest.mark.asyncio
async def test_in_client_returns_true_inside_request():
"""Test that datasette.in_client() returns True inside a client request"""
from datasette import hookimpl, Response
class TestPlugin:
__name__ = "test_in_client_plugin"
@hookimpl
def register_routes(self):
async def test_view(datasette):
# Assert in_client() returns True within the view
assert datasette.in_client() is True
return Response.json({"in_client": datasette.in_client()})
return [
(r"^/-/test-in-client$", test_view),
]
ds = Datasette()
await ds.invoke_startup()
ds.pm.register(TestPlugin(), name="test_in_client_plugin")
try:
# Outside of a client request, should be False
assert ds.in_client() is False
# Make a request via datasette.client
response = await ds.client.get("/-/test-in-client")
assert response.status_code == 200
assert response.json()["in_client"] is True
# After the request, should be False again
assert ds.in_client() is False
finally:
ds.pm.unregister(name="test_in_client_plugin")
@pytest.mark.asyncio
async def test_in_client_with_skip_permission_checks():
"""Test that in_client() works regardless of skip_permission_checks value"""
from datasette import hookimpl
from datasette.utils.asgi import Response
in_client_values = []
class TestPlugin:
__name__ = "test_in_client_skip_plugin"
@hookimpl
def register_routes(self):
async def test_view(datasette):
in_client_values.append(datasette.in_client())
return Response.json({"in_client": datasette.in_client()})
return [
(r"^/-/test-in-client$", test_view),
]
ds = Datasette(config={"databases": {"test_db": {"allow": {"id": "admin"}}}})
await ds.invoke_startup()
ds.pm.register(TestPlugin(), name="test_in_client_skip_plugin")
try:
# Request without skip_permission_checks
await ds.client.get("/-/test-in-client")
# Request with skip_permission_checks=True
await ds.client.get("/-/test-in-client", skip_permission_checks=True)
# Both should have detected in_client as True
assert (
len(in_client_values) == 2
), f"Expected 2 values, got {len(in_client_values)}"
assert all(in_client_values), f"Expected all True, got {in_client_values}"
finally:
ds.pm.unregister(name="test_in_client_skip_plugin")

View file

@ -439,6 +439,7 @@ async def test_execute_sql_requires_view_database():
be able to execute SQL on that database.
"""
from datasette.permissions import PermissionSQL
from datasette.plugins import pm
from datasette import hookimpl
class TestPermissionPlugin:
@ -463,12 +464,11 @@ async def test_execute_sql_requires_view_database():
return []
plugin = TestPermissionPlugin()
ds = Datasette()
await ds.invoke_startup()
ds.pm.register(plugin, name="test_plugin")
pm.register(plugin, name="test_plugin")
try:
ds = Datasette()
await ds.invoke_startup()
ds.add_memory_database("secret")
await ds.refresh_schemas()
@ -498,4 +498,4 @@ async def test_execute_sql_requires_view_database():
f"but got {response.status_code}"
)
finally:
ds.pm.unregister(plugin)
pm.unregister(plugin)

View file

@ -1323,20 +1323,6 @@ async def test_actor_restrictions(
("dbname2", "tablename"),
False,
),
# Table-level restriction allows access to that specific table
(
{"r": {"dbname": {"tablename": ["view-table"]}}},
"view-table",
("dbname", "tablename"),
True,
),
# But not to a different table in the same database
(
{"r": {"dbname": {"tablename": ["view-table"]}}},
"view-table",
("dbname", "other_table"),
False,
),
),
)
async def test_restrictions_allow_action(restrictions, action, resource, expected):
@ -1667,48 +1653,3 @@ async def test_permission_check_view_requires_debug_permission():
data = response.json()
assert data["action"] == "view-instance"
assert data["allowed"] is True
@pytest.mark.asyncio
async def test_root_allow_block_with_table_restricted_actor():
"""
Test that root-level allow: blocks are processed for actors with
table-level restrictions.
This covers the case in config.py is_in_restriction_allowlist() where
parent=None, child=None and actor has table restrictions but not global.
"""
from datasette.resources import TableResource
# Config with root-level allow block that denies non-admin users
ds = Datasette(
config={
"allow": {"id": "admin"}, # Root-level allow block
}
)
await ds.invoke_startup()
db = ds.add_memory_database("mydb")
await db.execute_write("create table t1 (id integer primary key)")
await ds.client.get("/") # Trigger catalog refresh
# Actor with table-level restrictions only (not global)
actor = {"id": "user", "_r": {"r": {"mydb": {"t1": ["view-table"]}}}}
# The root-level allow: {id: admin} should be processed and deny this user
# because they're not "admin", even though they have table restrictions
result = await ds.allowed(
action="view-table",
resource=TableResource("mydb", "t1"),
actor=actor,
)
# Should be False because root allow: {id: admin} denies non-admin users
assert result is False
# But admin with same restrictions should be allowed
admin_actor = {"id": "admin", "_r": {"r": {"mydb": {"t1": ["view-table"]}}}}
result = await ds.allowed(
action="view-table",
resource=TableResource("mydb", "t1"),
actor=admin_actor,
)
assert result is True

View file

@ -691,7 +691,7 @@ async def test_hook_permission_resources_sql():
await ds.invoke_startup()
collected = []
for block in ds.pm.hook.permission_resources_sql(
for block in pm.hook.permission_resources_sql(
datasette=ds,
actor={"id": "alice"},
action="view-table",
@ -1161,12 +1161,12 @@ async def test_hook_filters_from_request(ds_client):
if request.args.get("_nothing"):
return FilterArguments(["1 = 0"], human_descriptions=["NOTHING"])
ds_client.ds.pm.register(ReturnNothingPlugin(), name="ReturnNothingPlugin")
pm.register(ReturnNothingPlugin(), name="ReturnNothingPlugin")
response = await ds_client.get("/fixtures/facetable?_nothing=1")
assert "0 rows\n where NOTHING" in response.text
json_response = await ds_client.get("/fixtures/facetable.json?_nothing=1")
assert json_response.json()["rows"] == []
ds_client.ds.pm.unregister(name="ReturnNothingPlugin")
pm.unregister(name="ReturnNothingPlugin")
@pytest.mark.asyncio
@ -1327,7 +1327,7 @@ async def test_hook_actors_from_ids():
return inner
try:
ds.pm.register(ActorsFromIdsPlugin(), name="ActorsFromIdsPlugin")
pm.register(ActorsFromIdsPlugin(), name="ActorsFromIdsPlugin")
actors2 = await ds.actors_from_ids(["3", "5", "7"])
assert actors2 == {
"3": {"id": "3", "name": "Cate Blanchett"},
@ -1335,7 +1335,7 @@ async def test_hook_actors_from_ids():
"7": {"id": "7", "name": "Sarah Paulson"},
}
finally:
ds.pm.unregister(name="ReturnNothingPlugin")
pm.unregister(name="ReturnNothingPlugin")
@pytest.mark.asyncio
@ -1350,14 +1350,14 @@ async def test_plugin_is_installed():
return {}
try:
datasette.pm.register(DummyPlugin(), name="DummyPlugin")
pm.register(DummyPlugin(), name="DummyPlugin")
response = await datasette.client.get("/-/plugins.json")
assert response.status_code == 200
installed_plugins = {p["name"] for p in response.json()}
assert "DummyPlugin" in installed_plugins
finally:
datasette.pm.unregister(name="DummyPlugin")
pm.unregister(name="DummyPlugin")
@pytest.mark.asyncio
@ -1384,7 +1384,7 @@ async def test_hook_jinja2_environment_from_request(tmpdir):
datasette = Datasette(memory=True)
try:
datasette.pm.register(EnvironmentPlugin(), name="EnvironmentPlugin")
pm.register(EnvironmentPlugin(), name="EnvironmentPlugin")
response = await datasette.client.get("/")
assert response.status_code == 200
assert "Hello museums!" not in response.text
@ -1395,7 +1395,7 @@ async def test_hook_jinja2_environment_from_request(tmpdir):
assert response2.status_code == 200
assert "Hello museums!" in response2.text
finally:
datasette.pm.unregister(name="EnvironmentPlugin")
pm.unregister(name="EnvironmentPlugin")
class SlotPlugin:
@ -1433,48 +1433,48 @@ class SlotPlugin:
@pytest.mark.asyncio
async def test_hook_top_homepage():
datasette = Datasette(memory=True)
try:
datasette.pm.register(SlotPlugin(), name="SlotPlugin")
pm.register(SlotPlugin(), name="SlotPlugin")
datasette = Datasette(memory=True)
response = await datasette.client.get("/?z=foo")
assert response.status_code == 200
assert "Xtop_homepage:foo" in response.text
finally:
datasette.pm.unregister(name="SlotPlugin")
pm.unregister(name="SlotPlugin")
@pytest.mark.asyncio
async def test_hook_top_database():
datasette = Datasette(memory=True)
try:
datasette.pm.register(SlotPlugin(), name="SlotPlugin")
pm.register(SlotPlugin(), name="SlotPlugin")
datasette = Datasette(memory=True)
response = await datasette.client.get("/_memory?z=bar")
assert response.status_code == 200
assert "Xtop_database:_memory:bar" in response.text
finally:
datasette.pm.unregister(name="SlotPlugin")
pm.unregister(name="SlotPlugin")
@pytest.mark.asyncio
async def test_hook_top_table(ds_client):
try:
ds_client.ds.pm.register(SlotPlugin(), name="SlotPlugin")
pm.register(SlotPlugin(), name="SlotPlugin")
response = await ds_client.get("/fixtures/facetable?z=baz")
assert response.status_code == 200
assert "Xtop_table:fixtures:facetable:baz" in response.text
finally:
ds_client.ds.pm.unregister(name="SlotPlugin")
pm.unregister(name="SlotPlugin")
@pytest.mark.asyncio
async def test_hook_top_row(ds_client):
try:
ds_client.ds.pm.register(SlotPlugin(), name="SlotPlugin")
pm.register(SlotPlugin(), name="SlotPlugin")
response = await ds_client.get("/fixtures/facet_cities/1?z=bax")
assert response.status_code == 200
assert "Xtop_row:fixtures:facet_cities:San Francisco:bax" in response.text
finally:
ds_client.ds.pm.unregister(name="SlotPlugin")
pm.unregister(name="SlotPlugin")
@pytest.mark.asyncio

View file

@ -13,6 +13,7 @@ async def test_multiple_restriction_sources_intersect():
provide restriction_sql - both must pass for access to be granted.
"""
from datasette import hookimpl
from datasette.plugins import pm
class RestrictivePlugin:
__name__ = "RestrictivePlugin"
@ -28,12 +29,11 @@ async def test_multiple_restriction_sources_intersect():
return None
plugin = RestrictivePlugin()
ds = Datasette()
await ds.invoke_startup()
ds.pm.register(plugin, name="restrictive_plugin")
pm.register(plugin, name="restrictive_plugin")
try:
ds = Datasette()
await ds.invoke_startup()
db1 = ds.add_memory_database("db1_multi_intersect")
db2 = ds.add_memory_database("db2_multi_intersect")
await db1.execute_write("CREATE TABLE t1 (id INTEGER)")
@ -55,7 +55,7 @@ async def test_multiple_restriction_sources_intersect():
assert ("db1_multi_intersect", "t1") in resources
assert ("db2_multi_intersect", "t1") not in resources
finally:
ds.pm.unregister(name="restrictive_plugin")
pm.unregister(name="restrictive_plugin")
@pytest.mark.asyncio
@ -265,6 +265,7 @@ async def test_permission_resources_sql_multiple_restriction_sources_intersect()
provide restriction_sql - both must pass for access to be granted.
"""
from datasette import hookimpl
from datasette.plugins import pm
class RestrictivePlugin:
__name__ = "RestrictivePlugin"
@ -280,12 +281,11 @@ async def test_permission_resources_sql_multiple_restriction_sources_intersect()
return None
plugin = RestrictivePlugin()
ds = Datasette()
await ds.invoke_startup()
ds.pm.register(plugin, name="restrictive_plugin")
pm.register(plugin, name="restrictive_plugin")
try:
ds = Datasette()
await ds.invoke_startup()
db1 = ds.add_memory_database("db1_multi_restrictions")
db2 = ds.add_memory_database("db2_multi_restrictions")
await db1.execute_write("CREATE TABLE t1 (id INTEGER)")
@ -312,4 +312,4 @@ async def test_permission_resources_sql_multiple_restriction_sources_intersect()
assert ("db1_multi_restrictions", "t1") in resources
assert ("db2_multi_restrictions", "t1") not in resources
finally:
ds.pm.unregister(name="restrictive_plugin")
pm.unregister(name="restrictive_plugin")

View file

@ -0,0 +1,531 @@
"""
Playwright tests for the row detail side panel feature.
"""
import pytest
import subprocess
import sys
import tempfile
import time
import httpx
from playwright.sync_api import expect
def wait_until_responds(url, timeout=5.0):
"""Wait until a URL responds to HTTP requests"""
start = time.time()
while time.time() - start < timeout:
try:
httpx.get(url)
return
except httpx.ConnectError:
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {url} to respond")
@pytest.fixture(scope="module")
def datasette_server():
"""Start a Datasette server for testing"""
# Create a simple test database
import sqlite3
import os
db_path = os.path.join(tempfile.gettempdir(), "test_products.db")
# Remove if exists
if os.path.exists(db_path):
os.remove(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT,
description TEXT,
price REAL,
category TEXT
)
"""
)
conn.execute(
"""
INSERT INTO products (name, description, price, category) VALUES
('Laptop', 'High-performance laptop', 999.99, 'Electronics'),
('Mouse', 'Wireless mouse', 29.99, 'Electronics'),
('Desk', 'Standing desk', 499.99, 'Furniture'),
('Chair', 'Ergonomic chair', 299.99, 'Furniture'),
('Notebook', 'Spiral notebook', 4.99, 'Stationery')
"""
)
conn.commit()
conn.close()
# Start Datasette server
ds_proc = subprocess.Popen(
[sys.executable, "-m", "datasette", db_path, "-p", "8042"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=tempfile.gettempdir(),
)
wait_until_responds("http://localhost:8042/")
# Check it started successfully
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
yield {"base_url": "http://localhost:8042", "db_name": "test_products"}
# Shut down the server
ds_proc.terminate()
ds_proc.wait()
# Clean up
if os.path.exists(db_path):
os.remove(db_path)
def test_row_detail_panel_elements_exist(page, datasette_server):
"""Test that the row detail panel HTML elements exist"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the page to load
page.wait_for_selector(".rows-and-columns")
# Check that the dialog element exists
dialog = page.locator("#rowDetailPanel")
assert dialog.count() == 1
# Check that the close button exists
close_button = page.locator("#closeRowDetail")
assert close_button.count() == 1
# Check that the content div exists
content_div = page.locator("#rowDetailContent")
assert content_div.count() == 1
def test_row_click_opens_panel(page, datasette_server):
"""Test that clicking a table row opens the side panel"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Get the dialog
dialog = page.locator("#rowDetailPanel")
# Dialog should not be open initially
assert not dialog.evaluate("el => el.hasAttribute('open')")
# Click the first row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]", timeout=2000)
# Dialog should now be open
assert dialog.evaluate("el => el.hasAttribute('open')")
# Content should be loaded (not showing "Loading...")
content = page.locator("#rowDetailContent")
expect(content).not_to_contain_text("Loading...")
def test_row_panel_displays_data(page, datasette_server):
"""Test that the row panel displays the correct data"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the first row (Laptop)
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open and content to load
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Check that the content includes the expected data
content = page.locator("#rowDetailContent")
expect(content).to_contain_text("Laptop")
expect(content).to_contain_text("High-performance laptop")
expect(content).to_contain_text("999.99")
expect(content).to_contain_text("Electronics")
def test_close_button_closes_panel(page, datasette_server):
"""Test that clicking the close button closes the panel"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click a row to open the panel
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Click the close button
close_button = page.locator("#closeRowDetail")
close_button.click()
# Wait for the dialog to close
page.wait_for_timeout(200) # Wait for animation
# Dialog should be closed
dialog = page.locator("#rowDetailPanel")
assert not dialog.evaluate("el => el.hasAttribute('open')")
def test_escape_key_closes_panel(page, datasette_server):
"""Test that pressing Escape closes the panel"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click a row to open the panel
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Press Escape
page.keyboard.press("Escape")
# Wait for the dialog to close
page.wait_for_timeout(200) # Wait for animation
# Dialog should be closed
dialog = page.locator("#rowDetailPanel")
assert not dialog.evaluate("el => el.hasAttribute('open')")
@pytest.mark.skip(
reason="Backdrop click is difficult to test programmatically - works in manual testing"
)
def test_backdrop_click_closes_panel(page, datasette_server):
"""Test that clicking the backdrop closes the panel"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click a row to open the panel
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Click the dialog backdrop (the dialog element itself, not the content)
dialog = page.locator("#rowDetailPanel")
# Get the bounding box and click outside the content area
box = dialog.bounding_box()
if box:
# Click on the left side of the dialog (the backdrop)
page.mouse.click(box["x"] + 10, box["y"] + box["height"] / 2)
# Wait for the dialog to close
page.wait_for_timeout(200) # Wait for animation
# Dialog should be closed
assert not dialog.evaluate("el => el.hasAttribute('open')")
def test_multiple_rows_different_data(page, datasette_server):
"""Test that clicking different rows shows different data"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the first row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Check for first row data
content = page.locator("#rowDetailContent")
expect(content).to_contain_text("Laptop")
# Close the panel
close_button = page.locator("#closeRowDetail")
close_button.click()
page.wait_for_timeout(200)
# Click the second row
second_row = page.locator(".table-row-clickable").nth(1)
second_row.click()
# Wait for the dialog to open again
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Check for second row data
expect(content).to_contain_text("Mouse")
expect(content).to_contain_text("Wireless mouse")
def test_row_hover_state(page, datasette_server):
"""Test that rows have hover state styling"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Get the first row
first_row = page.locator(".table-row-clickable").first
# Check that the row has cursor: pointer
cursor_style = first_row.evaluate("el => window.getComputedStyle(el).cursor")
assert cursor_style == "pointer"
def test_navigation_buttons_exist(page, datasette_server):
"""Test that navigation buttons are present"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click a row to open the panel
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Check that navigation buttons exist
prev_button = page.locator("#prevRowButton")
next_button = page.locator("#nextRowButton")
position = page.locator("#rowPosition")
assert prev_button.count() == 1
assert next_button.count() == 1
assert position.count() == 1
def test_previous_button_disabled_on_first_row(page, datasette_server):
"""Test that previous button is disabled on the first row"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the first row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Previous button should be disabled
prev_button = page.locator("#prevRowButton")
assert prev_button.is_disabled()
# Next button should be enabled
next_button = page.locator("#nextRowButton")
assert not next_button.is_disabled()
def test_next_button_navigation(page, datasette_server):
"""Test that next button navigates to the next row"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the first row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open and content to load
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Should show Laptop data
content = page.locator("#rowDetailContent")
expect(content).to_contain_text("Laptop")
# Click next button
next_button = page.locator("#nextRowButton")
next_button.click()
# Wait for content to update
page.wait_for_timeout(300)
# Should now show Mouse data
expect(content).to_contain_text("Mouse")
expect(content).to_contain_text("29.99")
def test_previous_button_navigation(page, datasette_server):
"""Test that previous button navigates to the previous row"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the second row
second_row = page.locator(".table-row-clickable").nth(1)
second_row.click()
# Wait for the dialog to open and content to load
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Should show Mouse data
content = page.locator("#rowDetailContent")
expect(content).to_contain_text("Mouse")
# Previous button should be enabled now
prev_button = page.locator("#prevRowButton")
assert not prev_button.is_disabled()
# Click previous button
prev_button.click()
# Wait for content to update
page.wait_for_timeout(300)
# Should now show Laptop data
expect(content).to_contain_text("Laptop")
# Previous button should now be disabled (we're at first row)
assert prev_button.is_disabled()
def test_row_position_updates(page, datasette_server):
"""Test that row position indicator updates correctly"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the first row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Check position indicator shows "Row 1"
position = page.locator("#rowPosition")
expect(position).to_have_text("Row 1")
# Click next
next_button = page.locator("#nextRowButton")
next_button.click()
page.wait_for_timeout(300)
# Position should update to "Row 2"
expect(position).to_have_text("Row 2")
def test_pagination_navigation(page, datasette_server):
"""Test that navigation works across pagination boundaries"""
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
# Add page_size parameter to force pagination
page.goto(f"{base_url}/{db_name}/products?_size=2")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click the second (last visible) row
second_row = page.locator(".table-row-clickable").nth(1)
second_row.click()
# Wait for the dialog to open and content to load
page.wait_for_selector("#rowDetailPanel[open]")
page.wait_for_selector("#rowDetailContent dl")
# Should show Mouse data (second row)
content = page.locator("#rowDetailContent")
expect(content).to_contain_text("Mouse")
# Next button should be enabled (there are more rows via pagination)
next_button = page.locator("#nextRowButton")
assert not next_button.is_disabled()
# Click next button - should load the third row from the next page
next_button.click()
# Wait for loading and content update
page.wait_for_timeout(1000) # Give time for pagination fetch
# Should now show Desk data (third row, from next page)
expect(content).to_contain_text("Desk")
# Previous button should work to go back
prev_button = page.locator("#prevRowButton")
assert not prev_button.is_disabled()
prev_button.click()
page.wait_for_timeout(300)
# Should be back to Mouse
expect(content).to_contain_text("Mouse")
@pytest.mark.skip(reason="Mobile viewport test - enable if needed")
def test_panel_responsive_on_mobile(page, datasette_server):
"""Test that the panel is responsive on mobile viewports"""
# Set mobile viewport
page.set_viewport_size({"width": 375, "height": 667})
base_url = datasette_server["base_url"]
db_name = datasette_server["db_name"]
page.goto(f"{base_url}/{db_name}/products")
# Wait for the table to load
page.wait_for_selector(".rows-and-columns tbody tr")
# Click a row
first_row = page.locator(".table-row-clickable").first
first_row.click()
# Wait for the dialog to open
page.wait_for_selector("#rowDetailPanel[open]")
# Check that the panel width is appropriate for mobile
dialog = page.locator("#rowDetailPanel")
width = dialog.evaluate("el => el.offsetWidth")
viewport_width = page.viewport_size["width"]
# Panel should take most of the width on mobile (90%)
assert width > viewport_width * 0.85 # Allow some margin