2025-10-26 10:39:15 -07:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2018-05-24 17:15:37 -07:00
|
|
|
import asyncio
|
2025-11-13 09:56:06 -08:00
|
|
|
import contextvars
|
2025-10-26 15:52:36 -07:00
|
|
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
2026-01-23 20:43:16 -08:00
|
|
|
from datasette.permissions import Resource
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
from datasette.tokens import TokenRestrictions
|
2018-05-20 10:01:49 -07:00
|
|
|
import collections
|
2023-08-07 18:47:39 -07:00
|
|
|
import dataclasses
|
2020-05-31 22:00:36 -07:00
|
|
|
import datetime
|
2021-11-18 19:19:43 -08:00
|
|
|
import functools
|
2020-07-02 20:08:32 -07:00
|
|
|
import glob
|
2018-05-13 09:58:28 -03:00
|
|
|
import hashlib
|
2020-10-09 09:11:24 -07:00
|
|
|
import httpx
|
2023-09-16 09:35:18 -07:00
|
|
|
import importlib.metadata
|
2020-06-27 11:30:34 -07:00
|
|
|
import inspect
|
2020-06-28 17:25:35 -07:00
|
|
|
from itsdangerous import BadSignature
|
2020-02-04 12:26:17 -08:00
|
|
|
import json
|
2018-05-13 09:58:28 -03:00
|
|
|
import os
|
2019-12-04 22:46:39 -08:00
|
|
|
import re
|
2020-06-08 21:37:35 -07:00
|
|
|
import secrets
|
2018-05-02 01:46:54 -07:00
|
|
|
import sys
|
2018-05-24 17:15:37 -07:00
|
|
|
import threading
|
2022-12-13 18:42:01 -08:00
|
|
|
import time
|
2023-05-25 17:18:43 -07:00
|
|
|
import types
|
2018-05-13 09:58:28 -03:00
|
|
|
import urllib.parse
|
|
|
|
|
from concurrent import futures
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2021-05-23 18:41:50 -07:00
|
|
|
from markupsafe import Markup, escape
|
2020-05-31 15:42:08 -07:00
|
|
|
from itsdangerous import URLSafeSerializer
|
2023-03-22 15:49:39 -07:00
|
|
|
from jinja2 import (
|
|
|
|
|
ChoiceLoader,
|
|
|
|
|
Environment,
|
|
|
|
|
FileSystemLoader,
|
|
|
|
|
PrefixLoader,
|
|
|
|
|
)
|
2020-02-04 12:26:17 -08:00
|
|
|
from jinja2.environment import Template
|
2020-04-26 11:46:43 -07:00
|
|
|
from jinja2.exceptions import TemplateNotFound
|
2018-05-13 09:58:28 -03:00
|
|
|
|
2024-01-31 15:21:40 -08:00
|
|
|
from .events import Event
|
2026-03-18 11:37:09 -07:00
|
|
|
from .column_types import SQLiteType
|
2023-08-07 18:47:39 -07:00
|
|
|
from .views import Context
|
2024-07-15 10:33:51 -07:00
|
|
|
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
2018-05-21 09:02:34 +01:00
|
|
|
from .views.index import IndexView
|
2020-05-31 22:00:36 -07:00
|
|
|
from .views.special import (
|
|
|
|
|
JsonDataView,
|
|
|
|
|
PatternPortfolioView,
|
|
|
|
|
AuthTokenView,
|
2022-10-29 23:20:11 -07:00
|
|
|
ApiExplorerView,
|
2022-10-25 17:07:58 -07:00
|
|
|
CreateTokenView,
|
2020-06-28 21:17:30 -07:00
|
|
|
LogoutView,
|
2020-07-24 15:54:41 -07:00
|
|
|
AllowDebugView,
|
2020-05-31 22:00:36 -07:00
|
|
|
PermissionsDebugView,
|
2020-06-02 14:08:12 -07:00
|
|
|
MessagesDebugView,
|
2025-10-08 14:27:51 -07:00
|
|
|
AllowedResourcesView,
|
|
|
|
|
PermissionRulesView,
|
|
|
|
|
PermissionCheckView,
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
TablesView,
|
/-/schema and /db/-/schema and /db/table/-/schema pages (plus .json/.md)
* Add schema endpoints for databases, instances, and tables
Closes: #2586
This commit adds new endpoints to view database schemas in multiple formats:
- /-/schema - View schemas for all databases (HTML, JSON, MD)
- /database/-/schema - View schema for a specific database (HTML, JSON, MD)
- /database/table/-/schema - View schema for a specific table (JSON, MD)
Features:
- Supports HTML, JSON, and Markdown output formats
- Respects view-database and view-table permissions
- Uses group_concat(sql, ';' || CHAR(10)) from sqlite_master to retrieve schemas
- Includes comprehensive tests covering all formats and permission checks
The JSON endpoints return:
- Instance level: {"schemas": [{"database": "name", "schema": "sql"}, ...]}
- Database level: {"database": "name", "schema": "sql"}
- Table level: {"database": "name", "table": "name", "schema": "sql"}
Markdown format provides formatted output with headings and SQL code blocks.
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 12:01:23 -08:00
|
|
|
InstanceSchemaView,
|
|
|
|
|
DatabaseSchemaView,
|
|
|
|
|
TableSchemaView,
|
2020-05-31 22:00:36 -07:00
|
|
|
)
|
2023-03-22 15:49:39 -07:00
|
|
|
from .views.table import (
|
|
|
|
|
TableInsertView,
|
|
|
|
|
TableUpsertView,
|
2026-03-18 12:15:42 -07:00
|
|
|
TableSetColumnTypeView,
|
2023-03-22 15:49:39 -07:00
|
|
|
TableDropView,
|
|
|
|
|
table_view,
|
|
|
|
|
)
|
2022-11-29 10:06:19 -08:00
|
|
|
from .views.row import RowView, RowDeleteView, RowUpdateView
|
2019-05-02 00:01:56 +01:00
|
|
|
from .renderer import json_renderer
|
2020-10-31 11:43:36 -07:00
|
|
|
from .url_builder import Urls
|
2020-05-08 09:05:46 -07:00
|
|
|
from .database import Database, QueryInterrupted
|
2018-05-13 09:58:28 -03:00
|
|
|
|
2017-11-10 11:25:54 -08:00
|
|
|
from .utils import (
|
2025-10-31 14:50:46 -07:00
|
|
|
PaginatedResources,
|
2020-10-31 12:29:42 -07:00
|
|
|
PrefixedUrlString,
|
2022-02-08 22:32:19 -08:00
|
|
|
SPATIALITE_FUNCTIONS,
|
2020-11-24 12:37:29 -08:00
|
|
|
StartupError,
|
2020-06-08 20:12:06 -07:00
|
|
|
async_call_with_supported_arguments,
|
2020-09-02 15:21:12 -07:00
|
|
|
await_me_maybe,
|
2025-01-15 17:37:25 -08:00
|
|
|
baseconv,
|
2020-06-27 11:30:34 -07:00
|
|
|
call_with_supported_arguments,
|
2024-08-15 21:20:26 +01:00
|
|
|
detect_json1,
|
2020-06-29 11:40:40 -07:00
|
|
|
display_actor,
|
2017-11-10 21:55:50 -08:00
|
|
|
escape_css_string,
|
2018-04-03 06:39:50 -07:00
|
|
|
escape_sqlite,
|
2020-10-19 15:37:31 -07:00
|
|
|
find_spatialite,
|
2020-02-04 12:26:17 -08:00
|
|
|
format_bytes,
|
2018-04-15 22:22:01 -07:00
|
|
|
module_from_path,
|
2024-02-06 22:18:38 -08:00
|
|
|
move_plugins_and_allow,
|
2024-02-06 21:57:09 -08:00
|
|
|
move_table_config,
|
2020-04-30 11:47:21 -07:00
|
|
|
parse_metadata,
|
2020-06-11 17:21:48 -07:00
|
|
|
resolve_env_secrets,
|
2022-03-18 21:03:08 -07:00
|
|
|
resolve_routes,
|
2022-11-18 14:46:25 -08:00
|
|
|
tilde_decode,
|
2025-10-31 14:50:46 -07:00
|
|
|
tilde_encode,
|
2018-05-13 09:58:28 -03:00
|
|
|
to_css_class,
|
2022-11-18 14:46:25 -08:00
|
|
|
urlsafe_components,
|
2024-02-06 12:33:46 -08:00
|
|
|
redact_keys,
|
2022-11-18 14:46:25 -08:00
|
|
|
row_sql_params_pks,
|
2017-11-10 11:25:54 -08:00
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
from .utils.asgi import (
|
2022-12-17 17:22:00 -08:00
|
|
|
AsgiLifespan,
|
2020-06-06 22:30:36 -07:00
|
|
|
Forbidden,
|
2019-06-23 20:13:09 -07:00
|
|
|
NotFound,
|
2022-11-18 14:46:25 -08:00
|
|
|
DatabaseNotFound,
|
|
|
|
|
TableNotFound,
|
|
|
|
|
RowNotFound,
|
2020-04-26 12:01:46 -07:00
|
|
|
Request,
|
2020-11-24 12:19:14 -08:00
|
|
|
Response,
|
2022-12-15 09:34:07 -08:00
|
|
|
AsgiRunOnFirstRequest,
|
2019-06-23 20:13:09 -07:00
|
|
|
asgi_static,
|
|
|
|
|
asgi_send,
|
2022-01-19 21:46:03 -08:00
|
|
|
asgi_send_file,
|
2019-06-23 20:13:09 -07:00
|
|
|
asgi_send_redirect,
|
|
|
|
|
)
|
2026-04-14 17:11:36 -07:00
|
|
|
from .csrf import CrossOriginProtectionMiddleware
|
2020-12-21 11:48:06 -08:00
|
|
|
from .utils.internal_db import init_internal_db, populate_schema_tables
|
2020-12-03 14:08:50 -08:00
|
|
|
from .utils.sqlite import (
|
|
|
|
|
sqlite3,
|
|
|
|
|
using_pysqlite3,
|
|
|
|
|
)
|
2019-11-15 14:49:45 -08:00
|
|
|
from .tracer import AsgiTracer
|
2020-03-08 16:09:31 -07:00
|
|
|
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
2017-11-16 07:20:54 -08:00
|
|
|
from .version import __version__
|
2017-10-23 09:02:40 -07:00
|
|
|
|
2025-10-26 15:52:36 -07:00
|
|
|
from .resources import DatabaseResource, TableResource
|
2025-10-08 14:27:51 -07:00
|
|
|
|
2017-10-27 00:08:24 -07:00
|
|
|
app_root = Path(__file__).parent.parent
|
2017-10-23 09:02:40 -07:00
|
|
|
|
2025-10-25 09:59:21 -07:00
|
|
|
|
2025-11-13 09:56:06 -08:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2025-10-25 09:59:21 -07:00
|
|
|
@dataclasses.dataclass
|
|
|
|
|
class PermissionCheck:
|
|
|
|
|
"""Represents a logged permission check for debugging purposes."""
|
2025-10-25 10:08:24 -07:00
|
|
|
|
2025-10-25 09:59:21 -07:00
|
|
|
when: str
|
2025-10-26 10:39:15 -07:00
|
|
|
actor: Dict[str, Any] | None
|
2025-10-25 09:59:21 -07:00
|
|
|
action: str
|
2025-10-26 10:39:15 -07:00
|
|
|
parent: str | None
|
|
|
|
|
child: str | None
|
2025-10-25 09:59:21 -07:00
|
|
|
result: bool
|
|
|
|
|
|
2025-10-25 10:08:24 -07:00
|
|
|
|
2021-02-18 14:09:12 -08:00
|
|
|
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
|
|
|
|
|
SQLITE_LIMIT_ATTACHED = 10
|
|
|
|
|
|
2025-02-18 10:23:23 -08:00
|
|
|
INTERNAL_DB_NAME = "__INTERNAL__"
|
|
|
|
|
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting = collections.namedtuple("Setting", ("name", "default", "help"))
|
|
|
|
|
SETTINGS = (
|
|
|
|
|
Setting("default_page_size", 100, "Default page size for the table view"),
|
|
|
|
|
Setting(
|
2018-05-20 10:01:49 -07:00
|
|
|
"max_returned_rows",
|
|
|
|
|
1000,
|
|
|
|
|
"Maximum rows that can be returned from a table or custom query",
|
|
|
|
|
),
|
2022-10-29 23:03:45 -07:00
|
|
|
Setting(
|
|
|
|
|
"max_insert_rows",
|
|
|
|
|
100,
|
|
|
|
|
"Maximum rows that can be inserted at a time using the bulk insert API",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-05-26 17:43:22 -07:00
|
|
|
"num_sql_threads",
|
|
|
|
|
3,
|
|
|
|
|
"Number of threads in the thread pool for executing SQLite queries",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting("sql_time_limit_ms", 1000, "Time limit for a SQL query in milliseconds"),
|
|
|
|
|
Setting(
|
2018-05-20 10:01:49 -07:00
|
|
|
"default_facet_size", 30, "Number of values to return for requested facets"
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting("facet_time_limit_ms", 200, "Time limit for calculating a requested facet"),
|
|
|
|
|
Setting(
|
2018-05-20 10:01:49 -07:00
|
|
|
"facet_suggest_time_limit_ms",
|
|
|
|
|
50,
|
|
|
|
|
"Time limit for calculating a suggested facet",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-05-24 18:12:27 -07:00
|
|
|
"allow_facet",
|
|
|
|
|
True,
|
|
|
|
|
"Allow users to specify columns to facet using ?_facet= parameter",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-05-24 18:12:27 -07:00
|
|
|
"allow_download",
|
|
|
|
|
True,
|
|
|
|
|
"Allow users to download the original SQLite database files",
|
|
|
|
|
),
|
2022-10-25 19:55:47 -07:00
|
|
|
Setting(
|
|
|
|
|
"allow_signed_tokens",
|
|
|
|
|
True,
|
|
|
|
|
"Allow users to create and use signed API tokens",
|
|
|
|
|
),
|
2023-01-04 16:51:11 -08:00
|
|
|
Setting(
|
|
|
|
|
"default_allow_sql",
|
|
|
|
|
True,
|
|
|
|
|
"Allow anyone to run arbitrary SQL queries",
|
|
|
|
|
),
|
2022-10-26 14:13:31 -07:00
|
|
|
Setting(
|
|
|
|
|
"max_signed_tokens_ttl",
|
|
|
|
|
0,
|
|
|
|
|
"Maximum allowed expiry time for signed API tokens",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting("suggest_facets", True, "Calculate and display suggested facets"),
|
|
|
|
|
Setting(
|
2019-03-17 15:55:04 -07:00
|
|
|
"default_cache_ttl",
|
|
|
|
|
5,
|
2018-05-26 15:17:33 -07:00
|
|
|
"Default HTTP cache TTL (used in Cache-Control: max-age= header)",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting("cache_size_kb", 0, "SQLite cache size in KB (0 == use SQLite default)"),
|
|
|
|
|
Setting(
|
2018-06-17 20:21:02 -07:00
|
|
|
"allow_csv_stream",
|
|
|
|
|
True,
|
|
|
|
|
"Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-06-17 20:21:02 -07:00
|
|
|
"max_csv_mb",
|
|
|
|
|
100,
|
2018-07-23 08:58:29 -07:00
|
|
|
"Maximum size allowed for CSV export in MB - set 0 to disable this limit",
|
2018-06-17 20:21:02 -07:00
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-07-10 09:20:41 -07:00
|
|
|
"truncate_cells_html",
|
|
|
|
|
2048,
|
2018-07-23 08:58:29 -07:00
|
|
|
"Truncate cells longer than this in HTML table view - set 0 to disable",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2018-07-23 08:58:29 -07:00
|
|
|
"force_https_urls",
|
|
|
|
|
False,
|
|
|
|
|
"Force URLs in API output to always use https:// protocol",
|
2018-07-10 09:20:41 -07:00
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting(
|
2019-12-22 16:04:45 +00:00
|
|
|
"template_debug",
|
|
|
|
|
False,
|
|
|
|
|
"Allow display of template debug information with ?_context=1",
|
|
|
|
|
),
|
2021-06-05 13:15:58 -07:00
|
|
|
Setting(
|
|
|
|
|
"trace_debug",
|
|
|
|
|
False,
|
|
|
|
|
"Allow display of SQL trace debug information with ?_trace=1",
|
|
|
|
|
),
|
2020-11-24 13:22:33 -08:00
|
|
|
Setting("base_url", "/", "Datasette URLs should use this base path"),
|
2018-05-20 10:01:49 -07:00
|
|
|
)
|
2022-03-18 17:25:14 -07:00
|
|
|
_HASH_URLS_REMOVED = "The hash_urls setting has been removed, try the datasette-hashed-urls plugin instead"
|
2022-03-18 17:19:31 -07:00
|
|
|
OBSOLETE_SETTINGS = {
|
2022-03-18 17:25:14 -07:00
|
|
|
"hash_urls": _HASH_URLS_REMOVED,
|
|
|
|
|
"default_cache_ttl_hashed": _HASH_URLS_REMOVED,
|
2022-03-18 17:19:31 -07:00
|
|
|
}
|
2020-11-24 13:22:33 -08:00
|
|
|
DEFAULT_SETTINGS = {option.name: option.default for option in SETTINGS}
|
2018-05-17 22:08:26 -07:00
|
|
|
|
2022-01-19 21:46:03 -08:00
|
|
|
FAVICON_PATH = app_root / "datasette" / "static" / "favicon.png"
|
|
|
|
|
|
2022-12-12 18:05:54 -08:00
|
|
|
DEFAULT_NOT_SET = object()
|
|
|
|
|
|
2018-05-17 22:08:26 -07:00
|
|
|
|
2025-10-31 15:07:37 -07:00
|
|
|
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
|
|
|
|
|
|
|
|
|
|
|
2020-06-28 21:27:11 -07:00
|
|
|
async def favicon(request, send):
|
2022-01-19 21:46:03 -08:00
|
|
|
await asgi_send_file(
|
|
|
|
|
send,
|
|
|
|
|
str(FAVICON_PATH),
|
|
|
|
|
content_type="image/png",
|
|
|
|
|
headers={"Cache-Control": "max-age=3600, immutable, public"},
|
|
|
|
|
)
|
2017-10-23 19:00:37 -07:00
|
|
|
|
|
|
|
|
|
2022-11-18 14:46:25 -08:00
|
|
|
ResolvedTable = collections.namedtuple("ResolvedTable", ("db", "table", "is_view"))
|
|
|
|
|
ResolvedRow = collections.namedtuple(
|
|
|
|
|
"ResolvedRow", ("db", "table", "sql", "params", "pks", "pk_values", "row")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-01-09 09:54:06 -08:00
|
|
|
def _to_string(value):
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return value
|
|
|
|
|
else:
|
|
|
|
|
return json.dumps(value, default=str)
|
|
|
|
|
|
|
|
|
|
|
2017-11-10 11:05:57 -08:00
|
|
|
class Datasette:
|
2020-06-02 14:08:12 -07:00
|
|
|
# Message constants:
|
|
|
|
|
INFO = 1
|
|
|
|
|
WARNING = 2
|
|
|
|
|
ERROR = 3
|
|
|
|
|
|
2017-11-13 11:33:01 -08:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
2021-12-17 18:09:00 -08:00
|
|
|
files=None,
|
2019-03-17 16:25:15 -07:00
|
|
|
immutables=None,
|
2017-11-13 11:33:01 -08:00
|
|
|
cache_headers=True,
|
2017-11-13 13:58:34 -08:00
|
|
|
cors=False,
|
2017-12-03 08:33:36 -08:00
|
|
|
inspect_data=None,
|
2023-08-22 18:26:11 -07:00
|
|
|
config=None,
|
2017-12-03 08:33:36 -08:00
|
|
|
metadata=None,
|
|
|
|
|
sqlite_extensions=None,
|
2018-04-15 22:22:01 -07:00
|
|
|
template_dir=None,
|
|
|
|
|
plugins_dir=None,
|
|
|
|
|
static_mounts=None,
|
2019-03-14 16:42:38 -07:00
|
|
|
memory=False,
|
2021-08-12 18:10:36 -07:00
|
|
|
settings=None,
|
2020-05-31 15:42:08 -07:00
|
|
|
secret=None,
|
2018-06-17 13:14:55 -07:00
|
|
|
version_note=None,
|
2020-04-27 09:30:24 -07:00
|
|
|
config_dir=None,
|
2020-09-11 11:37:55 -07:00
|
|
|
pdb=False,
|
2021-02-18 14:09:12 -08:00
|
|
|
crossdb=False,
|
2022-05-17 12:40:05 -07:00
|
|
|
nolock=False,
|
2023-08-28 20:24:23 -07:00
|
|
|
internal=None,
|
2025-11-12 16:14:21 -08:00
|
|
|
default_deny=False,
|
2018-04-15 22:22:01 -07:00
|
|
|
):
|
2022-09-16 20:38:15 -07:00
|
|
|
self._startup_invoked = False
|
2026-04-16 20:10:18 -07:00
|
|
|
self._closed = False
|
2020-04-27 09:30:24 -07:00
|
|
|
assert config_dir is None or isinstance(
|
|
|
|
|
config_dir, Path
|
|
|
|
|
), "config_dir= should be a pathlib.Path"
|
2022-07-17 21:12:45 -04:00
|
|
|
self.config_dir = config_dir
|
2020-09-11 11:37:55 -07:00
|
|
|
self.pdb = pdb
|
2020-06-08 21:37:35 -07:00
|
|
|
self._secret = secret or secrets.token_hex(32)
|
2023-01-11 10:12:53 -08:00
|
|
|
if files is not None and isinstance(files, str):
|
|
|
|
|
raise ValueError("files= must be a list of paths, not a string")
|
2021-12-17 18:09:00 -08:00
|
|
|
self.files = tuple(files or []) + tuple(immutables or [])
|
2020-04-27 09:30:24 -07:00
|
|
|
if config_dir:
|
2022-10-07 16:03:09 -07:00
|
|
|
db_files = []
|
|
|
|
|
for ext in ("db", "sqlite", "sqlite3"):
|
|
|
|
|
db_files.extend(config_dir.glob("*.{}".format(ext)))
|
|
|
|
|
self.files += tuple(str(f) for f in db_files)
|
2020-04-27 09:30:24 -07:00
|
|
|
if (
|
|
|
|
|
config_dir
|
|
|
|
|
and (config_dir / "inspect-data.json").exists()
|
|
|
|
|
and not inspect_data
|
|
|
|
|
):
|
2021-03-11 17:15:49 +01:00
|
|
|
inspect_data = json.loads((config_dir / "inspect-data.json").read_text())
|
2021-03-29 01:17:31 +01:00
|
|
|
if not immutables:
|
2020-04-27 09:30:24 -07:00
|
|
|
immutable_filenames = [i["file"] for i in inspect_data.values()]
|
|
|
|
|
immutables = [
|
|
|
|
|
f for f in self.files if Path(f).name in immutable_filenames
|
|
|
|
|
]
|
|
|
|
|
self.inspect_data = inspect_data
|
|
|
|
|
self.immutables = set(immutables or [])
|
2019-10-14 05:52:33 +02:00
|
|
|
self.databases = collections.OrderedDict()
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
self.actions = {} # .invoke_startup() will populate this
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
self._column_types = {} # .invoke_startup() will populate this
|
2022-09-05 17:40:19 -07:00
|
|
|
try:
|
|
|
|
|
self._refresh_schemas_lock = asyncio.Lock()
|
|
|
|
|
except RuntimeError as rex:
|
|
|
|
|
# Workaround for intermittent test failure, see:
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1802
|
|
|
|
|
if "There is no current event loop in thread" in str(rex):
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
self._refresh_schemas_lock = asyncio.Lock()
|
2022-09-05 17:44:44 -07:00
|
|
|
else:
|
|
|
|
|
raise
|
2021-02-18 14:09:12 -08:00
|
|
|
self.crossdb = crossdb
|
2022-05-17 12:40:05 -07:00
|
|
|
self.nolock = nolock
|
2022-12-15 17:38:22 -08:00
|
|
|
if memory or crossdb or not self.files:
|
2022-11-29 21:05:47 -08:00
|
|
|
self.add_database(
|
|
|
|
|
Database(self, is_mutable=False, is_memory=True), name="_memory"
|
|
|
|
|
)
|
2019-03-31 16:51:52 -07:00
|
|
|
for file in self.files:
|
2020-12-22 12:04:18 -08:00
|
|
|
self.add_database(
|
|
|
|
|
Database(self, file, is_mutable=file not in self.immutables)
|
|
|
|
|
)
|
2023-08-28 20:24:23 -07:00
|
|
|
|
|
|
|
|
self.internal_db_created = False
|
|
|
|
|
if internal is None:
|
2026-03-30 21:03:21 -07:00
|
|
|
self._internal_database = Database(self, is_temp_disk=True)
|
2023-08-28 20:24:23 -07:00
|
|
|
else:
|
|
|
|
|
self._internal_database = Database(self, path=internal, mode="rwc")
|
2025-02-18 10:23:23 -08:00
|
|
|
self._internal_database.name = INTERNAL_DB_NAME
|
2023-08-28 20:24:23 -07:00
|
|
|
|
2017-11-10 12:26:37 -08:00
|
|
|
self.cache_headers = cache_headers
|
2017-11-13 10:17:42 -08:00
|
|
|
self.cors = cors
|
2023-08-22 18:26:11 -07:00
|
|
|
config_files = []
|
2020-04-30 11:47:21 -07:00
|
|
|
metadata_files = []
|
|
|
|
|
if config_dir:
|
|
|
|
|
metadata_files = [
|
|
|
|
|
config_dir / filename
|
|
|
|
|
for filename in ("metadata.json", "metadata.yaml", "metadata.yml")
|
|
|
|
|
if (config_dir / filename).exists()
|
|
|
|
|
]
|
2023-08-22 18:26:11 -07:00
|
|
|
config_files = [
|
|
|
|
|
config_dir / filename
|
|
|
|
|
for filename in ("datasette.json", "datasette.yaml", "datasette.yml")
|
|
|
|
|
if (config_dir / filename).exists()
|
|
|
|
|
]
|
2020-04-30 11:47:21 -07:00
|
|
|
if config_dir and metadata_files and not metadata:
|
|
|
|
|
with metadata_files[0].open() as fp:
|
2024-02-01 14:44:16 -08:00
|
|
|
metadata = parse_metadata(fp.read())
|
2023-08-22 18:26:11 -07:00
|
|
|
|
|
|
|
|
if config_dir and config_files and not config:
|
|
|
|
|
with config_files[0].open() as fp:
|
|
|
|
|
config = parse_metadata(fp.read())
|
|
|
|
|
|
2024-02-06 22:18:38 -08:00
|
|
|
# Move any "plugins" and "allow" settings from metadata to config - updates them in place
|
2024-02-01 15:33:33 -08:00
|
|
|
metadata = metadata or {}
|
|
|
|
|
config = config or {}
|
2024-02-06 22:18:38 -08:00
|
|
|
metadata, config = move_plugins_and_allow(metadata, config)
|
2024-02-06 21:57:09 -08:00
|
|
|
# Now migrate any known table configuration settings over as well
|
|
|
|
|
metadata, config = move_table_config(metadata, config)
|
2024-02-01 15:33:33 -08:00
|
|
|
|
2024-02-01 14:44:16 -08:00
|
|
|
self._metadata_local = metadata or {}
|
2020-10-19 15:37:31 -07:00
|
|
|
self.sqlite_extensions = []
|
|
|
|
|
for extension in sqlite_extensions or []:
|
|
|
|
|
# Resolve spatialite, if requested
|
|
|
|
|
if extension == "spatialite":
|
|
|
|
|
# Could raise SpatialiteNotFound
|
|
|
|
|
self.sqlite_extensions.append(find_spatialite())
|
|
|
|
|
else:
|
|
|
|
|
self.sqlite_extensions.append(extension)
|
2020-04-27 09:30:24 -07:00
|
|
|
if config_dir and (config_dir / "templates").is_dir() and not template_dir:
|
|
|
|
|
template_dir = str((config_dir / "templates").resolve())
|
2017-11-30 08:05:01 -08:00
|
|
|
self.template_dir = template_dir
|
2020-04-27 09:30:24 -07:00
|
|
|
if config_dir and (config_dir / "plugins").is_dir() and not plugins_dir:
|
|
|
|
|
plugins_dir = str((config_dir / "plugins").resolve())
|
2018-04-15 22:22:01 -07:00
|
|
|
self.plugins_dir = plugins_dir
|
2020-04-27 09:30:24 -07:00
|
|
|
if config_dir and (config_dir / "static").is_dir() and not static_mounts:
|
|
|
|
|
static_mounts = [("static", str((config_dir / "static").resolve()))]
|
2017-12-03 08:33:36 -08:00
|
|
|
self.static_mounts = static_mounts or []
|
2023-08-22 18:26:11 -07:00
|
|
|
if config_dir and (config_dir / "datasette.json").exists() and not config:
|
|
|
|
|
config = json.loads((config_dir / "datasette.json").read_text())
|
|
|
|
|
|
|
|
|
|
config = config or {}
|
|
|
|
|
config_settings = config.get("settings") or {}
|
|
|
|
|
|
2025-10-24 00:14:28 -07:00
|
|
|
# Validate settings from config file
|
|
|
|
|
for key, value in config_settings.items():
|
2023-08-22 18:26:11 -07:00
|
|
|
if key not in DEFAULT_SETTINGS:
|
2025-10-24 00:14:28 -07:00
|
|
|
raise StartupError(f"Invalid setting '{key}' in config file")
|
|
|
|
|
# Validate type matches expected type from DEFAULT_SETTINGS
|
|
|
|
|
if value is not None: # Allow None/null values
|
|
|
|
|
expected_type = type(DEFAULT_SETTINGS[key])
|
|
|
|
|
actual_type = type(value)
|
|
|
|
|
if actual_type != expected_type:
|
|
|
|
|
raise StartupError(
|
|
|
|
|
f"Setting '{key}' in config file has incorrect type. "
|
|
|
|
|
f"Expected {expected_type.__name__}, got {actual_type.__name__}. "
|
|
|
|
|
f"Value: {value!r}. "
|
|
|
|
|
f"Hint: In YAML/JSON config files, remove quotes from boolean and integer values."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate settings from constructor parameter
|
|
|
|
|
if settings:
|
|
|
|
|
for key, value in settings.items():
|
|
|
|
|
if key not in DEFAULT_SETTINGS:
|
|
|
|
|
raise StartupError(f"Invalid setting '{key}' in settings parameter")
|
|
|
|
|
if value is not None:
|
|
|
|
|
expected_type = type(DEFAULT_SETTINGS[key])
|
|
|
|
|
actual_type = type(value)
|
|
|
|
|
if actual_type != expected_type:
|
|
|
|
|
raise StartupError(
|
|
|
|
|
f"Setting '{key}' in settings parameter has incorrect type. "
|
|
|
|
|
f"Expected {expected_type.__name__}, got {actual_type.__name__}. "
|
|
|
|
|
f"Value: {value!r}"
|
|
|
|
|
)
|
|
|
|
|
|
2023-09-13 14:06:25 -07:00
|
|
|
self.config = config
|
2023-08-22 18:26:11 -07:00
|
|
|
# CLI settings should overwrite datasette.json settings
|
|
|
|
|
self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {}))
|
2020-05-27 22:57:05 -07:00
|
|
|
self.renderers = {} # File extension -> (renderer, can_render) functions
|
2018-06-17 13:14:55 -07:00
|
|
|
self.version_note = version_note
|
2022-05-02 13:15:27 -07:00
|
|
|
if self.setting("num_sql_threads") == 0:
|
|
|
|
|
self.executor = None
|
|
|
|
|
else:
|
|
|
|
|
self.executor = futures.ThreadPoolExecutor(
|
|
|
|
|
max_workers=self.setting("num_sql_threads")
|
|
|
|
|
)
|
2020-11-24 14:06:32 -08:00
|
|
|
self.max_returned_rows = self.setting("max_returned_rows")
|
|
|
|
|
self.sql_time_limit_ms = self.setting("sql_time_limit_ms")
|
|
|
|
|
self.page_size = self.setting("default_page_size")
|
2018-04-15 22:22:01 -07:00
|
|
|
# Execute plugins in constructor, to ensure they are available
|
|
|
|
|
# when the rest of `datasette inspect` executes
|
|
|
|
|
if self.plugins_dir:
|
2020-07-02 20:08:32 -07:00
|
|
|
for filepath in glob.glob(os.path.join(self.plugins_dir, "*.py")):
|
|
|
|
|
if not os.path.isfile(filepath):
|
|
|
|
|
continue
|
|
|
|
|
mod = module_from_path(filepath, name=os.path.basename(filepath))
|
2018-05-13 09:44:22 -03:00
|
|
|
try:
|
|
|
|
|
pm.register(mod)
|
|
|
|
|
except ValueError:
|
|
|
|
|
# Plugin already registered
|
|
|
|
|
pass
|
2019-05-11 15:55:30 -07:00
|
|
|
|
2020-03-26 18:12:43 -07:00
|
|
|
# Configure Jinja
|
|
|
|
|
default_templates = str(app_root / "datasette" / "templates")
|
|
|
|
|
template_paths = []
|
|
|
|
|
if self.template_dir:
|
|
|
|
|
template_paths.append(self.template_dir)
|
|
|
|
|
plugin_template_paths = [
|
|
|
|
|
plugin["templates_path"]
|
|
|
|
|
for plugin in get_plugins()
|
|
|
|
|
if plugin["templates_path"]
|
|
|
|
|
]
|
|
|
|
|
template_paths.extend(plugin_template_paths)
|
|
|
|
|
template_paths.append(default_templates)
|
|
|
|
|
template_loader = ChoiceLoader(
|
|
|
|
|
[
|
|
|
|
|
FileSystemLoader(template_paths),
|
|
|
|
|
# Support {% extends "default:table.html" %}:
|
|
|
|
|
PrefixLoader(
|
|
|
|
|
{"default": FileSystemLoader(default_templates)}, delimiter=":"
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
)
|
2024-01-05 14:33:23 -08:00
|
|
|
environment = Environment(
|
2023-03-22 15:49:39 -07:00
|
|
|
loader=template_loader,
|
|
|
|
|
autoescape=True,
|
|
|
|
|
enable_async=True,
|
|
|
|
|
# undefined=StrictUndefined,
|
2020-03-26 18:12:43 -07:00
|
|
|
)
|
2024-01-05 14:33:23 -08:00
|
|
|
environment.filters["escape_css_string"] = escape_css_string
|
|
|
|
|
environment.filters["quote_plus"] = urllib.parse.quote_plus
|
|
|
|
|
self._jinja_env = environment
|
|
|
|
|
environment.filters["escape_sqlite"] = escape_sqlite
|
|
|
|
|
environment.filters["to_css_class"] = to_css_class
|
2020-05-30 07:38:46 -07:00
|
|
|
self._register_renderers()
|
2020-06-08 07:02:31 -07:00
|
|
|
self._permission_checks = collections.deque(maxlen=200)
|
2020-06-08 21:37:35 -07:00
|
|
|
self._root_token = secrets.token_hex(32)
|
2025-10-23 12:40:50 -07:00
|
|
|
self.root_enabled = False
|
2025-11-12 16:14:21 -08:00
|
|
|
self.default_deny = default_deny
|
2020-10-09 09:11:24 -07:00
|
|
|
self.client = DatasetteClient(self)
|
2020-03-26 18:12:43 -07:00
|
|
|
|
2024-06-11 09:33:23 -07:00
|
|
|
async def apply_metadata_json(self):
|
|
|
|
|
# Apply any metadata entries from metadata.json to the internal tables
|
|
|
|
|
# step 1: top-level metadata
|
|
|
|
|
for key in self._metadata_local or {}:
|
|
|
|
|
if key == "databases":
|
|
|
|
|
continue
|
2024-08-21 09:53:52 -07:00
|
|
|
value = self._metadata_local[key]
|
2025-01-09 09:54:06 -08:00
|
|
|
await self.set_instance_metadata(key, _to_string(value))
|
2024-06-11 09:33:23 -07:00
|
|
|
|
|
|
|
|
# step 2: database-level metadata
|
|
|
|
|
for dbname, db in self._metadata_local.get("databases", {}).items():
|
|
|
|
|
for key, value in db.items():
|
2024-08-15 21:48:07 -07:00
|
|
|
if key in ("tables", "queries"):
|
2024-06-11 09:33:23 -07:00
|
|
|
continue
|
2025-01-09 09:54:06 -08:00
|
|
|
await self.set_database_metadata(dbname, key, _to_string(value))
|
2024-06-11 09:33:23 -07:00
|
|
|
|
|
|
|
|
# step 3: table-level metadata
|
|
|
|
|
for tablename, table in db.get("tables", {}).items():
|
|
|
|
|
for key, value in table.items():
|
|
|
|
|
if key == "columns":
|
|
|
|
|
continue
|
2025-01-09 09:54:06 -08:00
|
|
|
await self.set_resource_metadata(
|
|
|
|
|
dbname, tablename, key, _to_string(value)
|
|
|
|
|
)
|
2024-06-11 09:33:23 -07:00
|
|
|
|
|
|
|
|
# step 4: column-level metadata (only descriptions in metadata.json)
|
|
|
|
|
for columnname, column_description in table.get("columns", {}).items():
|
|
|
|
|
await self.set_column_metadata(
|
|
|
|
|
dbname, tablename, columnname, "description", column_description
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
|
|
|
|
# a warning to user that they should delete their metadata.json file
|
|
|
|
|
|
2024-01-05 14:33:23 -08:00
|
|
|
def get_jinja_environment(self, request: Request = None) -> Environment:
|
|
|
|
|
environment = self._jinja_env
|
|
|
|
|
if request:
|
|
|
|
|
for environment in pm.hook.jinja2_environment_from_request(
|
|
|
|
|
datasette=self, request=request, env=environment
|
|
|
|
|
):
|
|
|
|
|
pass
|
|
|
|
|
return environment
|
|
|
|
|
|
2025-10-24 14:35:23 -07:00
|
|
|
def get_action(self, name_or_abbr: str):
|
|
|
|
|
"""
|
|
|
|
|
Returns an Action object for the given name or abbreviation. Returns None if not found.
|
|
|
|
|
"""
|
|
|
|
|
if name_or_abbr in self.actions:
|
|
|
|
|
return self.actions[name_or_abbr]
|
|
|
|
|
# Try abbreviation
|
|
|
|
|
for action in self.actions.values():
|
|
|
|
|
if action.abbr == name_or_abbr:
|
|
|
|
|
return action
|
|
|
|
|
return None
|
|
|
|
|
|
2020-12-18 14:34:05 -08:00
|
|
|
async def refresh_schemas(self):
|
2026-01-23 21:03:16 -08:00
|
|
|
# Throttle schema refreshes to at most once per second
|
|
|
|
|
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
|
|
|
|
|
return
|
|
|
|
|
self._last_schema_refresh = time.monotonic()
|
2021-07-16 12:44:58 -07:00
|
|
|
if self._refresh_schemas_lock.locked():
|
|
|
|
|
return
|
|
|
|
|
async with self._refresh_schemas_lock:
|
|
|
|
|
await self._refresh_schemas()
|
|
|
|
|
|
|
|
|
|
async def _refresh_schemas(self):
|
2023-08-28 20:24:23 -07:00
|
|
|
internal_db = self.get_internal_database()
|
2020-12-22 12:04:18 -08:00
|
|
|
if not self.internal_db_created:
|
2020-12-21 11:48:06 -08:00
|
|
|
await init_internal_db(internal_db)
|
2024-06-11 09:33:23 -07:00
|
|
|
await self.apply_metadata_json()
|
2020-12-22 12:04:18 -08:00
|
|
|
self.internal_db_created = True
|
2020-12-18 14:34:05 -08:00
|
|
|
current_schema_versions = {
|
|
|
|
|
row["database_name"]: row["schema_version"]
|
2020-12-21 11:48:06 -08:00
|
|
|
for row in await internal_db.execute(
|
2023-08-29 10:01:28 -07:00
|
|
|
"select database_name, schema_version from catalog_databases"
|
2020-12-18 14:34:05 -08:00
|
|
|
)
|
|
|
|
|
}
|
2025-12-02 19:00:13 -08:00
|
|
|
# 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],
|
|
|
|
|
)
|
2020-12-18 14:34:05 -08:00
|
|
|
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
|
|
|
|
|
if schema_version == current_schema_versions.get(database_name):
|
|
|
|
|
continue
|
2022-12-31 10:52:27 -08:00
|
|
|
placeholders = "(?, ?, ?, ?)"
|
|
|
|
|
values = [database_name, str(db.path), db.is_memory, schema_version]
|
|
|
|
|
if db.path is None:
|
|
|
|
|
placeholders = "(?, null, ?, ?)"
|
|
|
|
|
values = [database_name, db.is_memory, schema_version]
|
2020-12-21 11:48:06 -08:00
|
|
|
await internal_db.execute_write(
|
2020-12-18 14:34:05 -08:00
|
|
|
"""
|
2023-08-29 10:01:28 -07:00
|
|
|
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
|
2022-12-31 10:52:27 -08:00
|
|
|
VALUES {}
|
2026-02-17 13:30:24 -08:00
|
|
|
""".format(placeholders),
|
2022-12-31 10:52:27 -08:00
|
|
|
values,
|
2020-12-18 14:34:05 -08:00
|
|
|
)
|
2020-12-21 11:48:06 -08:00
|
|
|
await populate_schema_tables(internal_db, db)
|
2020-12-18 14:34:05 -08:00
|
|
|
|
2020-10-19 17:33:59 -07:00
|
|
|
@property
|
|
|
|
|
def urls(self):
|
|
|
|
|
return Urls(self)
|
|
|
|
|
|
2025-11-13 10:31:03 -08:00
|
|
|
@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
|
|
|
|
|
|
2020-06-13 10:55:41 -07:00
|
|
|
async def invoke_startup(self):
|
2022-09-16 20:38:15 -07:00
|
|
|
# This must be called for Datasette to be in a usable state
|
|
|
|
|
if self._startup_invoked:
|
|
|
|
|
return
|
2024-01-31 15:21:40 -08:00
|
|
|
# Register event classes
|
|
|
|
|
event_classes = []
|
|
|
|
|
for hook in pm.hook.register_events(datasette=self):
|
|
|
|
|
extra_classes = await await_me_maybe(hook)
|
|
|
|
|
if extra_classes:
|
|
|
|
|
event_classes.extend(extra_classes)
|
|
|
|
|
self.event_classes = tuple(event_classes)
|
|
|
|
|
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
# Register actions, but watch out for duplicate name/abbr
|
|
|
|
|
action_names = {}
|
|
|
|
|
action_abbrs = {}
|
|
|
|
|
for hook in pm.hook.register_actions(datasette=self):
|
|
|
|
|
if hook:
|
|
|
|
|
for action in hook:
|
|
|
|
|
if (
|
|
|
|
|
action.name in action_names
|
|
|
|
|
and action != action_names[action.name]
|
|
|
|
|
):
|
|
|
|
|
raise StartupError(
|
|
|
|
|
"Duplicate action name: {}".format(action.name)
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
action.abbr
|
|
|
|
|
and action.abbr in action_abbrs
|
|
|
|
|
and action != action_abbrs[action.abbr]
|
|
|
|
|
):
|
|
|
|
|
raise StartupError(
|
|
|
|
|
"Duplicate action abbr: {}".format(action.abbr)
|
|
|
|
|
)
|
|
|
|
|
action_names[action.name] = action
|
|
|
|
|
if action.abbr:
|
|
|
|
|
action_abbrs[action.abbr] = action
|
|
|
|
|
self.actions[action.name] = action
|
|
|
|
|
|
2026-03-17 05:18:14 +00:00
|
|
|
# Register column types (classes, not instances)
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
self._column_types = {}
|
|
|
|
|
for hook in pm.hook.register_column_types(datasette=self):
|
|
|
|
|
if hook:
|
2026-03-17 05:18:14 +00:00
|
|
|
for ct_cls in hook:
|
|
|
|
|
if ct_cls.name in self._column_types:
|
|
|
|
|
raise StartupError(f"Duplicate column type name: {ct_cls.name}")
|
|
|
|
|
self._column_types[ct_cls.name] = ct_cls
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
|
2022-09-16 20:38:15 -07:00
|
|
|
for hook in pm.hook.prepare_jinja2_environment(
|
2024-01-05 14:33:23 -08:00
|
|
|
env=self._jinja_env, datasette=self
|
2022-09-16 20:38:15 -07:00
|
|
|
):
|
|
|
|
|
await await_me_maybe(hook)
|
2026-03-16 17:56:40 -07:00
|
|
|
# Ensure internal tables and metadata are populated before startup hooks
|
|
|
|
|
await self._refresh_schemas()
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
# Load column_types from config into internal DB
|
|
|
|
|
await self._apply_column_types_config()
|
2020-06-13 10:55:41 -07:00
|
|
|
for hook in pm.hook.startup(datasette=self):
|
2020-09-02 15:21:12 -07:00
|
|
|
await await_me_maybe(hook)
|
2022-09-16 20:38:15 -07:00
|
|
|
self._startup_invoked = True
|
2020-06-13 10:55:41 -07:00
|
|
|
|
2020-05-31 15:42:08 -07:00
|
|
|
def sign(self, value, namespace="default"):
|
|
|
|
|
return URLSafeSerializer(self._secret, namespace).dumps(value)
|
|
|
|
|
|
|
|
|
|
def unsign(self, signed, namespace="default"):
|
|
|
|
|
return URLSafeSerializer(self._secret, namespace).loads(signed)
|
|
|
|
|
|
2025-11-13 09:56:06 -08:00
|
|
|
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()
|
|
|
|
|
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
def _token_handlers(self):
|
|
|
|
|
"""Collect all registered token handlers from plugins."""
|
|
|
|
|
from datasette.tokens import TokenHandler
|
|
|
|
|
|
|
|
|
|
handlers = []
|
|
|
|
|
for result in pm.hook.register_token_handler(datasette=self):
|
|
|
|
|
if isinstance(result, TokenHandler):
|
|
|
|
|
handlers.append(result)
|
|
|
|
|
elif isinstance(result, list):
|
|
|
|
|
handlers.extend(h for h in result if isinstance(h, TokenHandler))
|
|
|
|
|
return handlers
|
|
|
|
|
|
|
|
|
|
async def create_token(
|
2022-12-13 18:42:01 -08:00
|
|
|
self,
|
|
|
|
|
actor_id: str,
|
|
|
|
|
*,
|
2025-10-26 10:39:15 -07:00
|
|
|
expires_after: int | None = None,
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
restrictions: "TokenRestrictions | None" = None,
|
|
|
|
|
handler: str | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Create an API token for the given actor.
|
|
|
|
|
|
|
|
|
|
Uses the first registered token handler by default, or a specific
|
|
|
|
|
handler if ``handler`` is provided (matched by handler name).
|
|
|
|
|
|
|
|
|
|
Pass a :class:`TokenRestrictions` to limit which actions the token
|
|
|
|
|
can perform.
|
|
|
|
|
"""
|
|
|
|
|
handlers = self._token_handlers()
|
|
|
|
|
if not handlers:
|
|
|
|
|
raise RuntimeError("No token handlers are registered")
|
|
|
|
|
|
|
|
|
|
if handler is not None:
|
|
|
|
|
matched = [h for h in handlers if h.name == handler]
|
|
|
|
|
if not matched:
|
|
|
|
|
available = [h.name for h in handlers]
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Token handler {handler!r} not found. "
|
|
|
|
|
f"Available handlers: {available}"
|
|
|
|
|
)
|
|
|
|
|
chosen = matched[0]
|
|
|
|
|
else:
|
|
|
|
|
chosen = handlers[0]
|
|
|
|
|
|
|
|
|
|
return await chosen.create_token(
|
|
|
|
|
self,
|
|
|
|
|
actor_id,
|
|
|
|
|
expires_after=expires_after,
|
|
|
|
|
restrictions=restrictions,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, token: str) -> dict | None:
|
|
|
|
|
"""
|
|
|
|
|
Verify an API token by trying all registered token handlers.
|
|
|
|
|
|
|
|
|
|
Returns an actor dict from the first handler that recognizes the
|
|
|
|
|
token, or None if no handler accepts it.
|
|
|
|
|
"""
|
|
|
|
|
for token_handler in self._token_handlers():
|
|
|
|
|
result = await token_handler.verify_token(self, token)
|
|
|
|
|
if result is not None:
|
|
|
|
|
return result
|
|
|
|
|
return None
|
2022-12-13 18:42:01 -08:00
|
|
|
|
2022-03-19 17:11:17 -07:00
|
|
|
def get_database(self, name=None, route=None):
|
|
|
|
|
if route is not None:
|
|
|
|
|
matches = [db for db in self.databases.values() if db.route == route]
|
|
|
|
|
if not matches:
|
|
|
|
|
raise KeyError
|
|
|
|
|
return matches[0]
|
2020-05-30 07:28:29 -07:00
|
|
|
if name is None:
|
2023-08-28 20:24:23 -07:00
|
|
|
name = [key for key in self.databases.keys()][0]
|
2020-05-30 07:28:29 -07:00
|
|
|
return self.databases[name]
|
|
|
|
|
|
2022-03-19 17:11:17 -07:00
|
|
|
def add_database(self, db, name=None, route=None):
|
2021-06-26 15:24:54 -07:00
|
|
|
new_databases = self.databases.copy()
|
2020-12-22 12:04:18 -08:00
|
|
|
if name is None:
|
|
|
|
|
# Pick a unique name for this database
|
|
|
|
|
suggestion = db.suggest_name()
|
|
|
|
|
name = suggestion
|
|
|
|
|
else:
|
|
|
|
|
suggestion = name
|
|
|
|
|
i = 2
|
|
|
|
|
while name in self.databases:
|
|
|
|
|
name = "{}_{}".format(suggestion, i)
|
|
|
|
|
i += 1
|
|
|
|
|
db.name = name
|
2022-03-19 17:11:17 -07:00
|
|
|
db.route = route or name
|
2021-06-26 15:24:54 -07:00
|
|
|
new_databases[name] = db
|
|
|
|
|
# don't mutate! that causes race conditions with live import
|
|
|
|
|
self.databases = new_databases
|
2020-12-22 12:04:18 -08:00
|
|
|
return db
|
2020-02-13 17:25:27 -08:00
|
|
|
|
2025-11-05 15:18:17 -08:00
|
|
|
def add_memory_database(self, memory_name, name=None, route=None):
|
|
|
|
|
return self.add_database(
|
|
|
|
|
Database(self, memory_name=memory_name), name=name, route=route
|
|
|
|
|
)
|
2021-02-28 20:02:18 -08:00
|
|
|
|
2020-02-13 17:25:27 -08:00
|
|
|
def remove_database(self, name):
|
2025-02-06 10:46:11 -08:00
|
|
|
self.get_database(name).close()
|
2021-06-26 15:24:54 -07:00
|
|
|
new_databases = self.databases.copy()
|
|
|
|
|
new_databases.pop(name)
|
|
|
|
|
self.databases = new_databases
|
2020-02-13 17:25:27 -08:00
|
|
|
|
2026-04-16 20:10:18 -07:00
|
|
|
def close(self):
|
|
|
|
|
"""Release all resources held by this Datasette instance.
|
|
|
|
|
|
|
|
|
|
Closes every attached Database (including the internal database),
|
|
|
|
|
shuts down the executor, and unlinks the temporary file used for
|
|
|
|
|
the internal database if one was created. Idempotent and one-way.
|
|
|
|
|
"""
|
|
|
|
|
if self._closed:
|
|
|
|
|
return
|
|
|
|
|
self._closed = True
|
|
|
|
|
first_exception = None
|
|
|
|
|
dbs = list(self.databases.values()) + [self._internal_database]
|
|
|
|
|
for db in dbs:
|
|
|
|
|
try:
|
|
|
|
|
db.close()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
if first_exception is None:
|
|
|
|
|
first_exception = e
|
|
|
|
|
if self.executor is not None:
|
|
|
|
|
try:
|
|
|
|
|
self.executor.shutdown(wait=True, cancel_futures=True)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
if first_exception is None:
|
|
|
|
|
first_exception = e
|
|
|
|
|
if first_exception is not None:
|
|
|
|
|
raise first_exception
|
|
|
|
|
|
2020-11-24 14:06:32 -08:00
|
|
|
def setting(self, key):
|
|
|
|
|
return self._settings.get(key, None)
|
2018-08-11 13:06:45 -07:00
|
|
|
|
2021-08-12 18:10:36 -07:00
|
|
|
def settings_dict(self):
|
|
|
|
|
# Returns a fully resolved settings dictionary, useful for templates
|
2020-11-24 14:06:32 -08:00
|
|
|
return {option.name: self.setting(option.name) for option in SETTINGS}
|
2018-08-11 13:06:45 -07:00
|
|
|
|
2021-06-26 15:24:54 -07:00
|
|
|
def _metadata_recursive_update(self, orig, updated):
|
|
|
|
|
if not isinstance(orig, dict) or not isinstance(updated, dict):
|
|
|
|
|
return orig
|
|
|
|
|
|
|
|
|
|
for key, upd_value in updated.items():
|
|
|
|
|
if isinstance(upd_value, dict) and isinstance(orig.get(key), dict):
|
|
|
|
|
orig[key] = self._metadata_recursive_update(orig[key], upd_value)
|
|
|
|
|
else:
|
|
|
|
|
orig[key] = upd_value
|
|
|
|
|
return orig
|
|
|
|
|
|
2024-06-11 09:33:23 -07:00
|
|
|
async def get_instance_metadata(self):
|
2026-02-17 13:30:24 -08:00
|
|
|
rows = await self.get_internal_database().execute("""
|
2024-06-11 09:33:23 -07:00
|
|
|
SELECT
|
|
|
|
|
key,
|
|
|
|
|
value
|
2024-08-05 13:53:55 -07:00
|
|
|
FROM metadata_instance
|
2026-02-17 13:30:24 -08:00
|
|
|
""")
|
2024-06-11 09:33:23 -07:00
|
|
|
return dict(rows)
|
|
|
|
|
|
|
|
|
|
async def get_database_metadata(self, database_name: str):
|
|
|
|
|
rows = await self.get_internal_database().execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
key,
|
|
|
|
|
value
|
2024-08-05 13:53:55 -07:00
|
|
|
FROM metadata_databases
|
2024-06-11 09:33:23 -07:00
|
|
|
WHERE database_name = ?
|
|
|
|
|
""",
|
|
|
|
|
[database_name],
|
|
|
|
|
)
|
|
|
|
|
return dict(rows)
|
|
|
|
|
|
|
|
|
|
async def get_resource_metadata(self, database_name: str, resource_name: str):
|
|
|
|
|
rows = await self.get_internal_database().execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
key,
|
|
|
|
|
value
|
2024-08-05 13:53:55 -07:00
|
|
|
FROM metadata_resources
|
2024-06-11 09:33:23 -07:00
|
|
|
WHERE database_name = ?
|
|
|
|
|
AND resource_name = ?
|
|
|
|
|
""",
|
|
|
|
|
[database_name, resource_name],
|
|
|
|
|
)
|
|
|
|
|
return dict(rows)
|
2021-06-26 15:24:54 -07:00
|
|
|
|
2024-06-11 09:33:23 -07:00
|
|
|
async def get_column_metadata(
|
|
|
|
|
self, database_name: str, resource_name: str, column_name: str
|
|
|
|
|
):
|
|
|
|
|
rows = await self.get_internal_database().execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
key,
|
|
|
|
|
value
|
2024-08-05 13:53:55 -07:00
|
|
|
FROM metadata_columns
|
2024-06-11 09:33:23 -07:00
|
|
|
WHERE database_name = ?
|
|
|
|
|
AND resource_name = ?
|
|
|
|
|
AND column_name = ?
|
|
|
|
|
""",
|
|
|
|
|
[database_name, resource_name, column_name],
|
|
|
|
|
)
|
|
|
|
|
return dict(rows)
|
|
|
|
|
|
|
|
|
|
async def set_instance_metadata(self, key: str, value: str):
|
|
|
|
|
# TODO upsert only supported on SQLite 3.24.0 (2018-06-04)
|
|
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"""
|
2024-08-05 13:53:55 -07:00
|
|
|
INSERT INTO metadata_instance(key, value)
|
2024-06-11 09:33:23 -07:00
|
|
|
VALUES(?, ?)
|
|
|
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
|
|
|
|
""",
|
|
|
|
|
[key, value],
|
|
|
|
|
)
|
2018-08-13 07:56:50 -07:00
|
|
|
|
2024-06-11 09:33:23 -07:00
|
|
|
async def set_database_metadata(self, database_name: str, key: str, value: str):
|
|
|
|
|
# TODO upsert only supported on SQLite 3.24.0 (2018-06-04)
|
|
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"""
|
2024-08-05 13:53:55 -07:00
|
|
|
INSERT INTO metadata_databases(database_name, key, value)
|
2024-06-11 09:33:23 -07:00
|
|
|
VALUES(?, ?, ?)
|
|
|
|
|
ON CONFLICT(database_name, key) DO UPDATE SET value = excluded.value;
|
|
|
|
|
""",
|
|
|
|
|
[database_name, key, value],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def set_resource_metadata(
|
|
|
|
|
self, database_name: str, resource_name: str, key: str, value: str
|
|
|
|
|
):
|
|
|
|
|
# TODO upsert only supported on SQLite 3.24.0 (2018-06-04)
|
|
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"""
|
2024-08-05 13:53:55 -07:00
|
|
|
INSERT INTO metadata_resources(database_name, resource_name, key, value)
|
2024-06-11 09:33:23 -07:00
|
|
|
VALUES(?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT(database_name, resource_name, key) DO UPDATE SET value = excluded.value;
|
|
|
|
|
""",
|
|
|
|
|
[database_name, resource_name, key, value],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def set_column_metadata(
|
|
|
|
|
self,
|
|
|
|
|
database_name: str,
|
|
|
|
|
resource_name: str,
|
|
|
|
|
column_name: str,
|
|
|
|
|
key: str,
|
|
|
|
|
value: str,
|
|
|
|
|
):
|
|
|
|
|
# TODO upsert only supported on SQLite 3.24.0 (2018-06-04)
|
|
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"""
|
2024-08-05 13:53:55 -07:00
|
|
|
INSERT INTO metadata_columns(database_name, resource_name, column_name, key, value)
|
2024-06-11 09:33:23 -07:00
|
|
|
VALUES(?, ?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT(database_name, resource_name, column_name, key) DO UPDATE SET value = excluded.value;
|
|
|
|
|
""",
|
|
|
|
|
[database_name, resource_name, column_name, key, value],
|
|
|
|
|
)
|
2021-06-26 15:24:54 -07:00
|
|
|
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
# Column types API
|
|
|
|
|
|
2026-03-18 11:37:09 -07:00
|
|
|
async def _get_resource_column_details(self, database: str, resource: str):
|
|
|
|
|
db = self.databases.get(database)
|
|
|
|
|
if db is None:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
return {
|
|
|
|
|
column.name: column
|
|
|
|
|
for column in await db.table_column_details(resource)
|
|
|
|
|
}
|
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _column_type_is_applicable(ct_cls, column_detail) -> bool:
|
|
|
|
|
sqlite_types = getattr(ct_cls, "sqlite_types", None)
|
|
|
|
|
if sqlite_types is None:
|
|
|
|
|
return True
|
|
|
|
|
if column_detail is None:
|
|
|
|
|
return False
|
|
|
|
|
actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
|
|
|
|
return actual_sqlite_type in sqlite_types
|
|
|
|
|
|
|
|
|
|
async def _validate_column_type_assignment(
|
|
|
|
|
self, database: str, resource: str, column: str, ct_cls
|
|
|
|
|
) -> None:
|
|
|
|
|
sqlite_types = getattr(ct_cls, "sqlite_types", None)
|
|
|
|
|
if sqlite_types is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
column_detail = (
|
|
|
|
|
await self._get_resource_column_details(database, resource)
|
|
|
|
|
).get(column)
|
|
|
|
|
if column_detail is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
|
|
|
|
if actual_sqlite_type in sqlite_types:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
allowed = ", ".join(sqlite_type.value for sqlite_type in sqlite_types)
|
|
|
|
|
actual = (
|
|
|
|
|
actual_sqlite_type.value
|
|
|
|
|
if actual_sqlite_type is not None
|
|
|
|
|
else "unrecognized {!r}".format(column_detail.type)
|
|
|
|
|
)
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"Column type {!r} is only applicable to SQLite types {} but {}.{}.{} "
|
|
|
|
|
"has SQLite type {}".format(
|
|
|
|
|
ct_cls.name,
|
|
|
|
|
allowed,
|
|
|
|
|
database,
|
|
|
|
|
resource,
|
|
|
|
|
column,
|
|
|
|
|
actual,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
async def _apply_column_types_config(self):
|
|
|
|
|
"""Load column_types from datasette.json config into the internal DB."""
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
|
|
|
|
|
for table_name, table_conf in db_conf.get("tables", {}).items():
|
|
|
|
|
for col_name, ct in table_conf.get("column_types", {}).items():
|
|
|
|
|
if isinstance(ct, str):
|
|
|
|
|
col_type, config = ct, None
|
|
|
|
|
else:
|
|
|
|
|
col_type = ct["type"]
|
|
|
|
|
config = ct.get("config")
|
|
|
|
|
if col_type not in self._column_types:
|
|
|
|
|
logging.warning(
|
|
|
|
|
"column_types config references unknown type %r "
|
|
|
|
|
"for %s.%s.%s",
|
2026-03-17 04:50:58 +00:00
|
|
|
col_type,
|
|
|
|
|
db_name,
|
|
|
|
|
table_name,
|
|
|
|
|
col_name,
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
)
|
2026-03-18 11:37:09 -07:00
|
|
|
try:
|
|
|
|
|
await self.set_column_type(
|
|
|
|
|
db_name, table_name, col_name, col_type, config
|
|
|
|
|
)
|
|
|
|
|
except ValueError as ex:
|
|
|
|
|
logging.warning(str(ex))
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
|
2026-03-17 05:18:14 +00:00
|
|
|
async def get_column_type(self, database: str, resource: str, column: str):
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
"""
|
2026-03-17 05:18:14 +00:00
|
|
|
Return a ColumnType instance (with config baked in) for a specific
|
|
|
|
|
column, or None if no column type is assigned.
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
"""
|
|
|
|
|
row = await self.get_internal_database().execute(
|
|
|
|
|
"SELECT column_type, config FROM column_types "
|
|
|
|
|
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
|
|
|
|
|
[database, resource, column],
|
|
|
|
|
)
|
|
|
|
|
rows = row.rows
|
|
|
|
|
if not rows:
|
2026-03-17 05:18:14 +00:00
|
|
|
return None
|
|
|
|
|
ct_name, config = rows[0]
|
|
|
|
|
ct_cls = self._column_types.get(ct_name)
|
|
|
|
|
if ct_cls is None:
|
|
|
|
|
return None
|
2026-03-18 11:37:09 -07:00
|
|
|
column_detail = (
|
|
|
|
|
await self._get_resource_column_details(database, resource)
|
|
|
|
|
).get(column)
|
|
|
|
|
if not self._column_type_is_applicable(ct_cls, column_detail):
|
|
|
|
|
return None
|
2026-03-17 05:18:14 +00:00
|
|
|
return ct_cls(config=json.loads(config) if config else None)
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
|
2026-03-17 04:50:58 +00:00
|
|
|
async def get_column_types(self, database: str, resource: str) -> dict:
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
"""
|
2026-03-17 05:18:14 +00:00
|
|
|
Return {column_name: ColumnType instance (with config)}
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
for all columns with assigned types on the given resource.
|
|
|
|
|
"""
|
|
|
|
|
rows = await self.get_internal_database().execute(
|
|
|
|
|
"SELECT column_name, column_type, config FROM column_types "
|
|
|
|
|
"WHERE database_name = ? AND resource_name = ?",
|
|
|
|
|
[database, resource],
|
|
|
|
|
)
|
2026-03-18 11:37:09 -07:00
|
|
|
column_details = await self._get_resource_column_details(database, resource)
|
2026-03-17 05:18:14 +00:00
|
|
|
result = {}
|
|
|
|
|
for row in rows.rows:
|
|
|
|
|
col_name, ct_name, config = row
|
|
|
|
|
ct_cls = self._column_types.get(ct_name)
|
2026-03-18 11:37:09 -07:00
|
|
|
if ct_cls is not None and self._column_type_is_applicable(
|
|
|
|
|
ct_cls, column_details.get(col_name)
|
|
|
|
|
):
|
2026-03-17 05:18:14 +00:00
|
|
|
result[col_name] = ct_cls(config=json.loads(config) if config else None)
|
|
|
|
|
return result
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
|
|
|
|
|
async def set_column_type(
|
2026-03-17 04:50:58 +00:00
|
|
|
self,
|
|
|
|
|
database: str,
|
|
|
|
|
resource: str,
|
|
|
|
|
column: str,
|
|
|
|
|
column_type: str,
|
|
|
|
|
config: dict = None,
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
) -> None:
|
|
|
|
|
"""Assign a column type. Overwrites any existing assignment."""
|
2026-03-18 11:37:09 -07:00
|
|
|
ct_cls = self._column_types.get(column_type)
|
|
|
|
|
if ct_cls is not None:
|
|
|
|
|
await self._validate_column_type_assignment(
|
|
|
|
|
database, resource, column, ct_cls
|
|
|
|
|
)
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"""INSERT OR REPLACE INTO column_types
|
|
|
|
|
(database_name, resource_name, column_name, column_type, config)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)""",
|
2026-03-17 04:50:58 +00:00
|
|
|
[
|
|
|
|
|
database,
|
|
|
|
|
resource,
|
|
|
|
|
column,
|
|
|
|
|
column_type,
|
|
|
|
|
json.dumps(config) if config else None,
|
|
|
|
|
],
|
Add column types system for semantic column annotations
Implements the column types feature that lets Datasette and plugins annotate
columns with semantic types beyond SQLite storage types (e.g. markdown, email,
url, json, file, point). This enables type-appropriate rendering, validation,
form widgets, and API behavior.
Key changes:
- New `column_types` internal DB table for storing assignments
- `ColumnType` dataclass in datasette/column_types.py with render_cell,
validate, and transform_value methods
- `register_column_types` plugin hook for registering types
- Built-in url, email, and json column types
- Datasette API methods: get/set/remove_column_type(s),
get_column_type_class
- Config loading from datasette.json `column_types` table config key
- `column_types` extra on the table JSON endpoint
- Column type info in display_columns extra
- Column type render_cell gets priority in rendering pipeline
- column_type/column_type_config args added to render_cell hookspec
- Write-path validation on insert and update
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
2026-03-17 02:40:37 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def remove_column_type(
|
|
|
|
|
self, database: str, resource: str, column: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Remove a column type assignment."""
|
|
|
|
|
await self.get_internal_database().execute_write(
|
|
|
|
|
"DELETE FROM column_types "
|
|
|
|
|
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
|
|
|
|
|
[database, resource, column],
|
|
|
|
|
)
|
|
|
|
|
|
2023-08-28 20:24:23 -07:00
|
|
|
def get_internal_database(self):
|
|
|
|
|
return self._internal_database
|
|
|
|
|
|
2018-08-28 01:35:21 -07:00
|
|
|
def plugin_config(self, plugin_name, database=None, table=None, fallback=True):
|
2020-12-23 18:04:32 +01:00
|
|
|
"""Return config for plugin, falling back from specified database/table"""
|
2023-09-13 14:06:25 -07:00
|
|
|
if database is None and table is None:
|
|
|
|
|
config = self._plugin_config_top(plugin_name)
|
|
|
|
|
else:
|
|
|
|
|
config = self._plugin_config_nested(plugin_name, database, table, fallback)
|
|
|
|
|
|
|
|
|
|
return resolve_env_secrets(config, os.environ)
|
|
|
|
|
|
|
|
|
|
def _plugin_config_top(self, plugin_name):
|
|
|
|
|
"""Returns any top-level plugin configuration for the specified plugin."""
|
|
|
|
|
return ((self.config or {}).get("plugins") or {}).get(plugin_name)
|
|
|
|
|
|
|
|
|
|
def _plugin_config_nested(self, plugin_name, database, table=None, fallback=True):
|
|
|
|
|
"""Returns any database or table-level plugin configuration for the specified plugin."""
|
|
|
|
|
db_config = ((self.config or {}).get("databases") or {}).get(database)
|
|
|
|
|
|
|
|
|
|
# if there's no db-level configuration, then return early, falling back to top-level if needed
|
|
|
|
|
if not db_config:
|
|
|
|
|
return self._plugin_config_top(plugin_name) if fallback else None
|
|
|
|
|
|
|
|
|
|
db_plugin_config = (db_config.get("plugins") or {}).get(plugin_name)
|
|
|
|
|
|
|
|
|
|
if table:
|
|
|
|
|
table_plugin_config = (
|
|
|
|
|
((db_config.get("tables") or {}).get(table) or {}).get("plugins") or {}
|
|
|
|
|
).get(plugin_name)
|
|
|
|
|
|
|
|
|
|
# fallback to db_config or top-level config, in that order, if needed
|
|
|
|
|
if table_plugin_config is None and fallback:
|
|
|
|
|
return db_plugin_config or self._plugin_config_top(plugin_name)
|
|
|
|
|
|
|
|
|
|
return table_plugin_config
|
|
|
|
|
|
|
|
|
|
# fallback to top-level if needed
|
|
|
|
|
if db_plugin_config is None and fallback:
|
|
|
|
|
self._plugin_config_top(plugin_name)
|
|
|
|
|
|
|
|
|
|
return db_plugin_config
|
2018-08-28 01:35:21 -07:00
|
|
|
|
2017-12-08 19:10:09 -08:00
|
|
|
def app_css_hash(self):
|
|
|
|
|
if not hasattr(self, "_app_css_hash"):
|
2021-03-11 17:15:49 +01:00
|
|
|
with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp:
|
|
|
|
|
self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[
|
|
|
|
|
:6
|
|
|
|
|
]
|
2017-12-08 19:10:09 -08:00
|
|
|
return self._app_css_hash
|
|
|
|
|
|
2020-06-18 16:22:33 -07:00
|
|
|
async def get_canned_queries(self, database_name, actor):
|
2025-10-25 10:21:50 -07:00
|
|
|
queries = {}
|
2020-06-18 16:22:33 -07:00
|
|
|
for more_queries in pm.hook.canned_queries(
|
2020-09-02 15:24:55 -07:00
|
|
|
datasette=self,
|
|
|
|
|
database=database_name,
|
|
|
|
|
actor=actor,
|
2020-06-18 16:22:33 -07:00
|
|
|
):
|
2020-09-02 15:21:12 -07:00
|
|
|
more_queries = await await_me_maybe(more_queries)
|
2020-06-18 16:22:33 -07:00
|
|
|
queries.update(more_queries or {})
|
|
|
|
|
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
|
|
|
|
|
for key in queries:
|
|
|
|
|
if not isinstance(queries[key], dict):
|
|
|
|
|
queries[key] = {"sql": queries[key]}
|
|
|
|
|
# Also make sure "name" is available:
|
|
|
|
|
queries[key]["name"] = key
|
|
|
|
|
return queries
|
2018-07-15 19:33:30 -07:00
|
|
|
|
2020-06-18 16:22:33 -07:00
|
|
|
async def get_canned_query(self, database_name, query_name, actor):
|
|
|
|
|
queries = await self.get_canned_queries(database_name, actor)
|
2018-08-13 07:56:50 -07:00
|
|
|
query = queries.get(query_name)
|
2017-12-05 08:17:02 -08:00
|
|
|
if query:
|
2018-07-15 19:33:30 -07:00
|
|
|
return query
|
2017-12-05 08:17:02 -08:00
|
|
|
|
2020-05-30 07:38:46 -07:00
|
|
|
def _prepare_connection(self, conn, database):
|
2017-11-26 14:51:42 -08:00
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
conn.text_factory = lambda x: str(x, "utf-8", "replace")
|
2025-02-18 10:23:23 -08:00
|
|
|
if self.sqlite_extensions and database != INTERNAL_DB_NAME:
|
2017-11-26 14:51:42 -08:00
|
|
|
conn.enable_load_extension(True)
|
|
|
|
|
for extension in self.sqlite_extensions:
|
2022-08-23 11:35:41 -07:00
|
|
|
# "extension" is either a string path to the extension
|
|
|
|
|
# or a 2-item tuple that specifies which entrypoint to load.
|
2022-08-23 11:34:30 -07:00
|
|
|
if isinstance(extension, tuple):
|
|
|
|
|
path, entrypoint = extension
|
|
|
|
|
conn.execute("SELECT load_extension(?, ?)", [path, entrypoint])
|
|
|
|
|
else:
|
|
|
|
|
conn.execute("SELECT load_extension(?)", [extension])
|
2020-11-24 14:06:32 -08:00
|
|
|
if self.setting("cache_size_kb"):
|
|
|
|
|
conn.execute(f"PRAGMA cache_size=-{self.setting('cache_size_kb')}")
|
2019-04-13 12:20:10 -07:00
|
|
|
# pylint: disable=no-member
|
2025-02-18 10:23:23 -08:00
|
|
|
if database != INTERNAL_DB_NAME:
|
|
|
|
|
pm.hook.prepare_connection(conn=conn, database=database, datasette=self)
|
2021-02-18 14:09:12 -08:00
|
|
|
# If self.crossdb and this is _memory, connect the first SQLITE_LIMIT_ATTACHED databases
|
|
|
|
|
if self.crossdb and database == "_memory":
|
|
|
|
|
count = 0
|
|
|
|
|
for db_name, db in self.databases.items():
|
|
|
|
|
if count >= SQLITE_LIMIT_ATTACHED or db.is_memory:
|
|
|
|
|
continue
|
|
|
|
|
sql = 'ATTACH DATABASE "file:{path}?{qs}" AS [{name}];'.format(
|
|
|
|
|
path=db.path,
|
|
|
|
|
qs="mode=ro" if db.is_mutable else "immutable=1",
|
|
|
|
|
name=db_name,
|
|
|
|
|
)
|
|
|
|
|
conn.execute(sql)
|
|
|
|
|
count += 1
|
2017-11-26 14:51:42 -08:00
|
|
|
|
2020-06-02 14:08:12 -07:00
|
|
|
def add_message(self, request, message, type=INFO):
|
|
|
|
|
if not hasattr(request, "_messages"):
|
|
|
|
|
request._messages = []
|
|
|
|
|
request._messages_should_clear = False
|
|
|
|
|
request._messages.append((message, type))
|
|
|
|
|
|
|
|
|
|
def _write_messages_to_response(self, request, response):
|
|
|
|
|
if getattr(request, "_messages", None):
|
|
|
|
|
# Set those messages
|
2020-06-09 15:19:37 -07:00
|
|
|
response.set_cookie("ds_messages", self.sign(request._messages, "messages"))
|
2020-06-02 14:08:12 -07:00
|
|
|
elif getattr(request, "_messages_should_clear", False):
|
2020-06-09 15:19:37 -07:00
|
|
|
response.set_cookie("ds_messages", "", expires=0, max_age=0)
|
2020-06-02 14:08:12 -07:00
|
|
|
|
|
|
|
|
def _show_messages(self, request):
|
|
|
|
|
if getattr(request, "_messages", None):
|
|
|
|
|
request._messages_should_clear = True
|
|
|
|
|
messages = request._messages
|
|
|
|
|
request._messages = []
|
|
|
|
|
return messages
|
|
|
|
|
else:
|
|
|
|
|
return []
|
|
|
|
|
|
2022-10-13 14:42:52 -07:00
|
|
|
async def _crumb_items(self, request, table=None, database=None):
|
|
|
|
|
crumbs = []
|
2022-10-26 14:13:31 -07:00
|
|
|
actor = None
|
|
|
|
|
if request:
|
|
|
|
|
actor = request.actor
|
2022-10-13 14:42:52 -07:00
|
|
|
# Top-level link
|
2025-10-24 13:53:43 -07:00
|
|
|
if await self.allowed(action="view-instance", actor=actor):
|
2022-10-13 14:42:52 -07:00
|
|
|
crumbs.append({"href": self.urls.instance(), "label": "home"})
|
|
|
|
|
# Database link
|
|
|
|
|
if database:
|
2025-10-24 13:53:43 -07:00
|
|
|
if await self.allowed(
|
2022-10-13 14:42:52 -07:00
|
|
|
action="view-database",
|
2025-10-24 13:53:43 -07:00
|
|
|
resource=DatabaseResource(database=database),
|
|
|
|
|
actor=actor,
|
2022-10-13 14:42:52 -07:00
|
|
|
):
|
|
|
|
|
crumbs.append(
|
|
|
|
|
{
|
|
|
|
|
"href": self.urls.database(database),
|
|
|
|
|
"label": database,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
# Table link
|
|
|
|
|
if table:
|
|
|
|
|
assert database, "table= requires database="
|
2025-10-24 13:53:43 -07:00
|
|
|
if await self.allowed(
|
2022-10-13 14:42:52 -07:00
|
|
|
action="view-table",
|
2025-10-24 13:53:43 -07:00
|
|
|
resource=TableResource(database=database, table=table),
|
|
|
|
|
actor=actor,
|
2022-10-13 14:42:52 -07:00
|
|
|
):
|
|
|
|
|
crumbs.append(
|
|
|
|
|
{
|
|
|
|
|
"href": self.urls.table(database, table),
|
|
|
|
|
"label": table,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return crumbs
|
|
|
|
|
|
2023-09-07 21:23:59 -07:00
|
|
|
async def actors_from_ids(
|
2025-10-26 10:39:15 -07:00
|
|
|
self, actor_ids: Iterable[str | int]
|
|
|
|
|
) -> Dict[int | str, Dict]:
|
2023-09-07 21:23:59 -07:00
|
|
|
result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
|
|
|
|
|
if result is None:
|
|
|
|
|
# Do the default thing
|
|
|
|
|
return {actor_id: {"id": actor_id} for actor_id in actor_ids}
|
|
|
|
|
result = await await_me_maybe(result)
|
|
|
|
|
return result
|
|
|
|
|
|
2024-01-31 15:21:40 -08:00
|
|
|
async def track_event(self, event: Event):
|
|
|
|
|
assert isinstance(event, self.event_classes), "Invalid event type: {}".format(
|
|
|
|
|
type(event)
|
|
|
|
|
)
|
|
|
|
|
for hook in pm.hook.track_event(datasette=self, event=event):
|
|
|
|
|
await await_me_maybe(hook)
|
|
|
|
|
|
2025-10-26 10:39:15 -07:00
|
|
|
def resource_for_action(self, action: str, parent: str | None, child: str | None):
|
2025-10-25 13:33:23 -07:00
|
|
|
"""
|
|
|
|
|
Create a Resource instance for the given action with parent/child values.
|
|
|
|
|
|
|
|
|
|
Looks up the action's resource_class and instantiates it with the
|
|
|
|
|
provided parent and child identifiers.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
action: The action name (e.g., "view-table", "view-query")
|
|
|
|
|
parent: The parent resource identifier (e.g., database name)
|
|
|
|
|
child: The child resource identifier (e.g., table/query name)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A Resource instance of the appropriate subclass
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If the action is unknown
|
|
|
|
|
"""
|
|
|
|
|
from datasette.permissions import Resource
|
|
|
|
|
|
|
|
|
|
action_obj = self.actions.get(action)
|
|
|
|
|
if not action_obj:
|
|
|
|
|
raise ValueError(f"Unknown action: {action}")
|
|
|
|
|
|
|
|
|
|
resource_class = action_obj.resource_class
|
|
|
|
|
instance = object.__new__(resource_class)
|
|
|
|
|
Resource.__init__(instance, parent=parent, child=child)
|
|
|
|
|
return instance
|
|
|
|
|
|
2025-10-24 15:24:10 -07:00
|
|
|
async def check_visibility(
|
2022-03-21 10:13:16 -07:00
|
|
|
self,
|
|
|
|
|
actor: dict,
|
2025-10-24 15:24:10 -07:00
|
|
|
action: str,
|
2025-10-26 10:39:15 -07:00
|
|
|
resource: "Resource" | None = None,
|
2022-03-21 10:13:16 -07:00
|
|
|
):
|
|
|
|
|
"""
|
2025-10-24 15:24:10 -07:00
|
|
|
Check if actor can see a resource and if it's private.
|
2022-03-21 10:13:16 -07:00
|
|
|
|
2025-10-24 15:24:10 -07:00
|
|
|
Returns (visible, private) tuple:
|
|
|
|
|
- visible: bool - can the actor see it?
|
|
|
|
|
- private: bool - if visible, can anonymous users NOT see it?
|
2022-03-21 10:13:16 -07:00
|
|
|
"""
|
2025-10-26 09:39:51 -07:00
|
|
|
from datasette.permissions import Resource
|
|
|
|
|
|
|
|
|
|
# Validate that resource is a Resource object or None
|
|
|
|
|
if resource is not None and not isinstance(resource, Resource):
|
2026-01-23 20:43:16 -08:00
|
|
|
raise TypeError("resource must be a Resource subclass instance or None.")
|
2022-03-21 10:13:16 -07:00
|
|
|
|
2025-10-24 15:24:10 -07:00
|
|
|
# Check if actor can see it
|
2025-10-26 09:39:51 -07:00
|
|
|
if not await self.allowed(action=action, resource=resource, actor=actor):
|
2022-03-21 12:01:37 -07:00
|
|
|
return False, False
|
2025-10-24 15:24:10 -07:00
|
|
|
|
|
|
|
|
# Check if anonymous user can see it (for "private" flag)
|
2025-10-26 09:39:51 -07:00
|
|
|
if not await self.allowed(action=action, resource=resource, actor=None):
|
2025-10-24 15:24:10 -07:00
|
|
|
# Actor can see it but anonymous cannot - it's private
|
2022-10-23 19:11:33 -07:00
|
|
|
return True, True
|
2025-10-24 15:24:10 -07:00
|
|
|
|
|
|
|
|
# Both actor and anonymous can see it - it's public
|
2022-10-23 19:11:33 -07:00
|
|
|
return True, False
|
2022-03-21 12:01:37 -07:00
|
|
|
|
2025-10-20 16:26:54 -07:00
|
|
|
async def allowed_resources_sql(
|
|
|
|
|
self,
|
2025-10-23 14:53:07 -07:00
|
|
|
*,
|
2025-10-20 16:26:54 -07:00
|
|
|
action: str,
|
|
|
|
|
actor: dict | None = None,
|
2025-10-24 09:30:37 -07:00
|
|
|
parent: str | None = None,
|
|
|
|
|
include_is_private: bool = False,
|
2025-10-31 15:07:37 -07:00
|
|
|
) -> ResourcesSQL:
|
2025-10-20 16:26:54 -07:00
|
|
|
"""
|
|
|
|
|
Build SQL query to get all resources the actor can access for the given action.
|
|
|
|
|
|
2025-10-24 09:30:37 -07:00
|
|
|
Args:
|
|
|
|
|
action: The action name (e.g., "view-table")
|
|
|
|
|
actor: The actor dict (or None for unauthenticated)
|
|
|
|
|
parent: Optional parent filter (e.g., database name) to limit results
|
|
|
|
|
include_is_private: If True, include is_private column showing if anonymous cannot access
|
|
|
|
|
|
2025-10-31 15:07:37 -07:00
|
|
|
Returns a namedtuple of (query: str, params: dict) that can be executed against the internal database.
|
2025-10-24 09:30:37 -07:00
|
|
|
The query returns rows with (parent, child, reason) columns, plus is_private if requested.
|
2025-10-20 16:26:54 -07:00
|
|
|
|
|
|
|
|
Example:
|
2025-10-24 09:30:37 -07:00
|
|
|
query, params = await datasette.allowed_resources_sql(
|
|
|
|
|
action="view-table",
|
|
|
|
|
actor=actor,
|
|
|
|
|
parent="mydb",
|
|
|
|
|
include_is_private=True
|
|
|
|
|
)
|
2025-10-20 16:26:54 -07:00
|
|
|
result = await datasette.get_internal_database().execute(query, params)
|
|
|
|
|
"""
|
|
|
|
|
from datasette.utils.actions_sql import build_allowed_resources_sql
|
|
|
|
|
|
|
|
|
|
action_obj = self.actions.get(action)
|
|
|
|
|
if not action_obj:
|
|
|
|
|
raise ValueError(f"Unknown action: {action}")
|
|
|
|
|
|
2025-10-31 15:07:37 -07:00
|
|
|
sql, params = await build_allowed_resources_sql(
|
2025-10-24 09:30:37 -07:00
|
|
|
self, actor, action, parent=parent, include_is_private=include_is_private
|
|
|
|
|
)
|
2025-10-31 15:07:37 -07:00
|
|
|
return ResourcesSQL(sql, params)
|
2025-10-20 16:26:54 -07:00
|
|
|
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
async def allowed_resources(
|
|
|
|
|
self,
|
|
|
|
|
action: str,
|
|
|
|
|
actor: dict | None = None,
|
2025-10-24 09:30:37 -07:00
|
|
|
*,
|
|
|
|
|
parent: str | None = None,
|
|
|
|
|
include_is_private: bool = False,
|
2025-10-31 14:50:46 -07:00
|
|
|
include_reasons: bool = False,
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
next: str | None = None,
|
|
|
|
|
) -> PaginatedResources:
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
"""
|
2025-10-31 14:50:46 -07:00
|
|
|
Return paginated resources the actor can access for the given action.
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
Uses SQL with keyset pagination to efficiently filter resources.
|
|
|
|
|
Returns PaginatedResources with list of Resource instances and pagination metadata.
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-24 09:30:37 -07:00
|
|
|
Args:
|
|
|
|
|
action: The action name (e.g., "view-table")
|
|
|
|
|
actor: The actor dict (or None for unauthenticated)
|
|
|
|
|
parent: Optional parent filter (e.g., database name) to limit results
|
|
|
|
|
include_is_private: If True, adds a .private attribute to each Resource
|
2025-10-31 14:50:46 -07:00
|
|
|
include_reasons: If True, adds a .reasons attribute with List[str] of permission reasons
|
|
|
|
|
limit: Maximum number of results to return (1-1000, default 100)
|
|
|
|
|
next: Keyset token from previous page for pagination
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
PaginatedResources with:
|
|
|
|
|
- resources: List of Resource objects for this page
|
|
|
|
|
- next: Token for next page (None if no more results)
|
2025-10-24 09:30:37 -07:00
|
|
|
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
Example:
|
2025-10-31 14:50:46 -07:00
|
|
|
# Get first page of tables
|
|
|
|
|
page = await datasette.allowed_resources("view-table", actor, limit=50)
|
|
|
|
|
for table in page.resources:
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
print(f"{table.parent}/{table.child}")
|
2025-10-24 09:30:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Get next page
|
|
|
|
|
if page.next:
|
|
|
|
|
next_page = await datasette.allowed_resources(
|
|
|
|
|
"view-table", actor, limit=50, next=page.next
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# With reasons for debugging
|
|
|
|
|
page = await datasette.allowed_resources(
|
|
|
|
|
"view-table", actor, include_reasons=True
|
2025-10-24 09:30:37 -07:00
|
|
|
)
|
2025-10-31 14:50:46 -07:00
|
|
|
for table in page.resources:
|
|
|
|
|
print(f"{table.child}: {table.reasons}")
|
|
|
|
|
|
|
|
|
|
# Iterate through all results with async generator
|
|
|
|
|
page = await datasette.allowed_resources("view-table", actor)
|
|
|
|
|
async for table in page.all():
|
|
|
|
|
print(table.child)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
action_obj = self.actions.get(action)
|
|
|
|
|
if not action_obj:
|
|
|
|
|
raise ValueError(f"Unknown action: {action}")
|
|
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Validate and cap limit
|
|
|
|
|
limit = min(max(1, limit), 1000)
|
|
|
|
|
|
|
|
|
|
# Get base SQL query
|
2025-10-24 09:30:37 -07:00
|
|
|
query, params = await self.allowed_resources_sql(
|
|
|
|
|
action=action,
|
|
|
|
|
actor=actor,
|
|
|
|
|
parent=parent,
|
|
|
|
|
include_is_private=include_is_private,
|
|
|
|
|
)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Add keyset pagination WHERE clause if next token provided
|
|
|
|
|
if next:
|
|
|
|
|
try:
|
|
|
|
|
components = urlsafe_components(next)
|
|
|
|
|
if len(components) >= 2:
|
|
|
|
|
last_parent, last_child = components[0], components[1]
|
|
|
|
|
# Keyset condition: (parent > last) OR (parent = last AND child > last)
|
|
|
|
|
keyset_where = """
|
|
|
|
|
(parent > :keyset_parent OR
|
|
|
|
|
(parent = :keyset_parent AND child > :keyset_child))
|
|
|
|
|
"""
|
|
|
|
|
# Wrap original query and add keyset filter
|
|
|
|
|
query = f"SELECT * FROM ({query}) WHERE {keyset_where}"
|
|
|
|
|
params["keyset_parent"] = last_parent
|
|
|
|
|
params["keyset_child"] = last_child
|
|
|
|
|
except (ValueError, KeyError):
|
|
|
|
|
# Invalid token - ignore and start from beginning
|
|
|
|
|
pass
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Add LIMIT (fetch limit+1 to detect if there are more results)
|
|
|
|
|
# Note: query from allowed_resources_sql() already includes ORDER BY parent, child
|
|
|
|
|
query = f"{query} LIMIT :limit"
|
|
|
|
|
params["limit"] = limit + 1
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Execute query
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
result = await self.get_internal_database().execute(query, params)
|
2025-10-31 14:50:46 -07:00
|
|
|
rows = list(result.rows)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Check if truncated (got more than limit rows)
|
|
|
|
|
truncated = len(rows) > limit
|
|
|
|
|
if truncated:
|
|
|
|
|
rows = rows[:limit] # Remove the extra row
|
|
|
|
|
|
|
|
|
|
# Build Resource objects with optional attributes
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
resources = []
|
2025-10-31 14:50:46 -07:00
|
|
|
for row in rows:
|
|
|
|
|
# row[0]=parent, row[1]=child, row[2]=reason, row[3]=is_private (if requested)
|
2025-10-25 13:33:23 -07:00
|
|
|
resource = self.resource_for_action(action, parent=row[0], child=row[1])
|
2025-10-25 21:24:05 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Add reasons if requested
|
|
|
|
|
if include_reasons:
|
|
|
|
|
reason_json = row[2]
|
|
|
|
|
try:
|
|
|
|
|
reasons_array = (
|
|
|
|
|
json.loads(reason_json) if isinstance(reason_json, str) else []
|
|
|
|
|
)
|
|
|
|
|
resource.reasons = [r for r in reasons_array if r is not None]
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
resource.reasons = [reason_json] if reason_json else []
|
2025-10-25 21:24:05 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
# Add private flag if requested
|
|
|
|
|
if include_is_private:
|
|
|
|
|
resource.private = bool(row[3])
|
2025-10-25 21:24:05 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
resources.append(resource)
|
|
|
|
|
|
|
|
|
|
# Generate next token if there are more results
|
|
|
|
|
next_token = None
|
|
|
|
|
if truncated and resources:
|
|
|
|
|
last_resource = resources[-1]
|
|
|
|
|
# Use tilde-encoding like table pagination
|
|
|
|
|
next_token = "{},{}".format(
|
|
|
|
|
tilde_encode(str(last_resource.parent)),
|
|
|
|
|
tilde_encode(str(last_resource.child)),
|
|
|
|
|
)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-31 14:50:46 -07:00
|
|
|
return PaginatedResources(
|
|
|
|
|
resources=resources,
|
|
|
|
|
next=next_token,
|
|
|
|
|
_datasette=self,
|
|
|
|
|
_action=action,
|
|
|
|
|
_actor=actor,
|
|
|
|
|
_parent=parent,
|
|
|
|
|
_include_is_private=include_is_private,
|
|
|
|
|
_include_reasons=include_reasons,
|
|
|
|
|
_limit=limit,
|
|
|
|
|
)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
|
|
|
|
async def allowed(
|
|
|
|
|
self,
|
2025-10-23 09:25:33 -07:00
|
|
|
*,
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
action: str,
|
2025-10-24 13:53:43 -07:00
|
|
|
resource: "Resource" = None,
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
actor: dict | None = None,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Check if actor can perform action on specific resource.
|
|
|
|
|
|
|
|
|
|
Uses SQL to check permission for a single resource without fetching all resources.
|
|
|
|
|
This is efficient - it does NOT call allowed_resources() and check membership.
|
|
|
|
|
|
2025-11-01 11:35:08 -07:00
|
|
|
For global actions, resource should be None (or omitted).
|
2025-10-24 13:53:43 -07:00
|
|
|
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
Example:
|
2025-10-20 16:23:14 -07:00
|
|
|
from datasette.resources import TableResource
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
can_view = await datasette.allowed(
|
2025-10-23 09:25:33 -07:00
|
|
|
action="view-table",
|
|
|
|
|
resource=TableResource(database="analytics", table="users"),
|
|
|
|
|
actor=actor
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
)
|
2025-10-24 13:53:43 -07:00
|
|
|
|
2025-11-01 11:35:08 -07:00
|
|
|
# For global actions, resource can be omitted:
|
2025-10-24 13:53:43 -07:00
|
|
|
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
"""
|
|
|
|
|
from datasette.utils.actions_sql import check_permission_for_resource
|
2025-10-24 13:53:43 -07:00
|
|
|
|
2025-11-01 11:35:08 -07:00
|
|
|
# For global actions, resource remains None
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
|
2025-10-24 16:54:06 -07:00
|
|
|
# Check if this action has also_requires - if so, check that action first
|
|
|
|
|
action_obj = self.actions.get(action)
|
|
|
|
|
if action_obj and action_obj.also_requires:
|
|
|
|
|
# Must have the required action first
|
|
|
|
|
if not await self.allowed(
|
|
|
|
|
action=action_obj.also_requires,
|
|
|
|
|
resource=resource,
|
|
|
|
|
actor=actor,
|
|
|
|
|
):
|
|
|
|
|
return False
|
|
|
|
|
|
2025-11-01 11:35:08 -07:00
|
|
|
# For global actions, resource is None
|
|
|
|
|
parent = resource.parent if resource else None
|
|
|
|
|
child = resource.child if resource else None
|
|
|
|
|
|
2025-10-24 13:53:43 -07:00
|
|
|
result = await check_permission_for_resource(
|
|
|
|
|
datasette=self,
|
|
|
|
|
actor=actor,
|
|
|
|
|
action=action,
|
2025-11-01 11:35:08 -07:00
|
|
|
parent=parent,
|
|
|
|
|
child=child,
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
)
|
|
|
|
|
|
2025-10-24 13:53:43 -07:00
|
|
|
# Log the permission check for debugging
|
|
|
|
|
self._permission_checks.append(
|
2025-10-25 09:59:21 -07:00
|
|
|
PermissionCheck(
|
|
|
|
|
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
|
|
|
actor=actor,
|
|
|
|
|
action=action,
|
2025-11-01 11:35:08 -07:00
|
|
|
parent=parent,
|
|
|
|
|
child=child,
|
2025-10-25 09:59:21 -07:00
|
|
|
result=result,
|
|
|
|
|
)
|
2025-10-24 13:53:43 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
2025-10-25 09:28:33 -07:00
|
|
|
async def ensure_permission(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
action: str,
|
|
|
|
|
resource: "Resource" = None,
|
|
|
|
|
actor: dict | None = None,
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Check if actor can perform action on resource, raising Forbidden if not.
|
|
|
|
|
|
|
|
|
|
This is a convenience wrapper around allowed() that raises Forbidden
|
|
|
|
|
instead of returning False. Use this when you want to enforce a permission
|
|
|
|
|
check and halt execution if it fails.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
from datasette.resources import TableResource
|
|
|
|
|
|
|
|
|
|
# Will raise Forbidden if actor cannot view the table
|
|
|
|
|
await datasette.ensure_permission(
|
|
|
|
|
action="view-table",
|
|
|
|
|
resource=TableResource(database="analytics", table="users"),
|
|
|
|
|
actor=request.actor
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# For instance-level actions, resource can be omitted:
|
|
|
|
|
await datasette.ensure_permission(
|
|
|
|
|
action="permissions-debug",
|
|
|
|
|
actor=request.actor
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
if not await self.allowed(action=action, resource=resource, actor=actor):
|
|
|
|
|
raise Forbidden(action)
|
|
|
|
|
|
2019-11-15 14:49:45 -08:00
|
|
|
async def execute(
|
|
|
|
|
self,
|
|
|
|
|
db_name,
|
|
|
|
|
sql,
|
|
|
|
|
params=None,
|
|
|
|
|
truncate=False,
|
|
|
|
|
custom_time_limit=None,
|
|
|
|
|
page_size=None,
|
|
|
|
|
log_sql_errors=True,
|
|
|
|
|
):
|
|
|
|
|
return await self.databases[db_name].execute(
|
|
|
|
|
sql,
|
|
|
|
|
params=params,
|
|
|
|
|
truncate=truncate,
|
|
|
|
|
custom_time_limit=custom_time_limit,
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
log_sql_errors=log_sql_errors,
|
|
|
|
|
)
|
|
|
|
|
|
2023-09-07 15:51:09 -07:00
|
|
|
async def expand_foreign_keys(self, actor, database, table, column, values):
|
2020-12-23 18:04:32 +01:00
|
|
|
"""Returns dict mapping (column, value) -> label"""
|
2019-04-13 11:48:00 -07:00
|
|
|
labeled_fks = {}
|
2019-05-26 21:56:43 -07:00
|
|
|
db = self.databases[database]
|
|
|
|
|
foreign_keys = await db.foreign_keys_for_table(table)
|
2019-04-13 11:48:00 -07:00
|
|
|
# Find the foreign_key for this column
|
|
|
|
|
try:
|
|
|
|
|
fk = [
|
|
|
|
|
foreign_key
|
|
|
|
|
for foreign_key in foreign_keys
|
|
|
|
|
if foreign_key["column"] == column
|
|
|
|
|
][0]
|
|
|
|
|
except IndexError:
|
|
|
|
|
return {}
|
2023-09-07 15:51:09 -07:00
|
|
|
# Ensure user has permission to view the referenced table
|
2025-10-26 09:39:51 -07:00
|
|
|
from datasette.resources import TableResource
|
|
|
|
|
|
2023-09-07 16:28:30 -07:00
|
|
|
other_table = fk["other_table"]
|
|
|
|
|
other_column = fk["other_column"]
|
|
|
|
|
visible, _ = await self.check_visibility(
|
|
|
|
|
actor,
|
2025-10-24 15:34:20 -07:00
|
|
|
action="view-table",
|
2025-10-26 09:39:51 -07:00
|
|
|
resource=TableResource(database=database, table=other_table),
|
2023-09-07 16:28:30 -07:00
|
|
|
)
|
|
|
|
|
if not visible:
|
2023-09-07 15:51:09 -07:00
|
|
|
return {}
|
2023-09-07 16:28:30 -07:00
|
|
|
label_column = await db.label_column_for_table(other_table)
|
2019-04-13 11:48:00 -07:00
|
|
|
if not label_column:
|
|
|
|
|
return {(fk["column"], value): str(value) for value in values}
|
|
|
|
|
labeled_fks = {}
|
|
|
|
|
sql = """
|
|
|
|
|
select {other_column}, {label_column}
|
|
|
|
|
from {other_table}
|
|
|
|
|
where {other_column} in ({placeholders})
|
|
|
|
|
""".format(
|
2023-09-07 16:28:30 -07:00
|
|
|
other_column=escape_sqlite(other_column),
|
2019-04-13 11:48:00 -07:00
|
|
|
label_column=escape_sqlite(label_column),
|
2023-09-07 16:28:30 -07:00
|
|
|
other_table=escape_sqlite(other_table),
|
2019-04-13 11:48:00 -07:00
|
|
|
placeholders=", ".join(["?"] * len(set(values))),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
results = await self.execute(database, sql, list(set(values)))
|
2019-05-27 17:16:36 -07:00
|
|
|
except QueryInterrupted:
|
2019-04-13 11:48:00 -07:00
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
for id, value in results:
|
|
|
|
|
labeled_fks[(fk["column"], id)] = value
|
|
|
|
|
return labeled_fks
|
|
|
|
|
|
2019-04-13 12:16:05 -07:00
|
|
|
def absolute_url(self, request, path):
|
|
|
|
|
url = urllib.parse.urljoin(request.url, path)
|
2020-11-24 14:06:32 -08:00
|
|
|
if url.startswith("http://") and self.setting("force_https_urls"):
|
2019-04-13 12:16:05 -07:00
|
|
|
url = "https://" + url[len("http://") :]
|
|
|
|
|
return url
|
|
|
|
|
|
2020-05-30 07:38:46 -07:00
|
|
|
def _connected_databases(self):
|
2019-05-16 07:49:34 -07:00
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"name": d.name,
|
2022-03-19 17:11:17 -07:00
|
|
|
"route": d.route,
|
2019-05-16 07:49:34 -07:00
|
|
|
"path": d.path,
|
|
|
|
|
"size": d.size,
|
|
|
|
|
"is_mutable": d.is_mutable,
|
|
|
|
|
"is_memory": d.is_memory,
|
|
|
|
|
"hash": d.hash,
|
|
|
|
|
}
|
2021-06-01 20:03:07 -07:00
|
|
|
for name, d in self.databases.items()
|
2019-05-16 07:49:34 -07:00
|
|
|
]
|
|
|
|
|
|
2020-05-30 07:38:46 -07:00
|
|
|
def _versions(self):
|
2018-05-02 01:46:54 -07:00
|
|
|
conn = sqlite3.connect(":memory:")
|
2021-01-28 14:48:56 -08:00
|
|
|
self._prepare_connection(conn, "_memory")
|
2018-05-02 01:46:54 -07:00
|
|
|
sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
|
2024-08-15 21:20:26 +01:00
|
|
|
sqlite_extensions = {"json1": detect_json1(conn)}
|
2018-05-02 01:46:54 -07:00
|
|
|
for extension, testsql, hasversion in (
|
|
|
|
|
("spatialite", "SELECT spatialite_version()", True),
|
|
|
|
|
):
|
|
|
|
|
try:
|
|
|
|
|
result = conn.execute(testsql)
|
|
|
|
|
if hasversion:
|
|
|
|
|
sqlite_extensions[extension] = result.fetchone()[0]
|
|
|
|
|
else:
|
|
|
|
|
sqlite_extensions[extension] = None
|
2019-04-13 12:20:10 -07:00
|
|
|
except Exception:
|
2018-05-02 01:46:54 -07:00
|
|
|
pass
|
2022-02-08 22:32:19 -08:00
|
|
|
# More details on SpatiaLite
|
|
|
|
|
if "spatialite" in sqlite_extensions:
|
|
|
|
|
spatialite_details = {}
|
|
|
|
|
for fn in SPATIALITE_FUNCTIONS:
|
|
|
|
|
try:
|
|
|
|
|
result = conn.execute("select {}()".format(fn))
|
|
|
|
|
spatialite_details[fn] = result.fetchone()[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
spatialite_details[fn] = {"error": str(e)}
|
|
|
|
|
sqlite_extensions["spatialite"] = spatialite_details
|
|
|
|
|
|
2018-05-11 10:19:25 -03:00
|
|
|
# Figure out supported FTS versions
|
|
|
|
|
fts_versions = []
|
|
|
|
|
for fts in ("FTS5", "FTS4", "FTS3"):
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
2018-05-23 18:43:34 +01:00
|
|
|
"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
|
2018-05-11 10:19:25 -03:00
|
|
|
)
|
|
|
|
|
fts_versions.append(fts)
|
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
continue
|
2018-06-17 13:14:55 -07:00
|
|
|
datasette_version = {"version": __version__}
|
|
|
|
|
if self.version_note:
|
|
|
|
|
datasette_version["note"] = self.version_note
|
2022-05-02 12:20:14 -07:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Optional import to avoid breaking Pyodide
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1733#issuecomment-1115268245
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
uvicorn_version = uvicorn.__version__
|
|
|
|
|
except ImportError:
|
|
|
|
|
uvicorn_version = None
|
2020-12-03 14:08:50 -08:00
|
|
|
info = {
|
2018-05-02 01:46:54 -07:00
|
|
|
"python": {
|
|
|
|
|
"version": ".".join(map(str, sys.version_info[:3])),
|
|
|
|
|
"full": sys.version,
|
|
|
|
|
},
|
2018-06-17 13:14:55 -07:00
|
|
|
"datasette": datasette_version,
|
2019-06-23 20:13:09 -07:00
|
|
|
"asgi": "3.0",
|
2022-05-02 12:20:14 -07:00
|
|
|
"uvicorn": uvicorn_version,
|
2018-05-02 01:46:54 -07:00
|
|
|
"sqlite": {
|
|
|
|
|
"version": sqlite_version,
|
|
|
|
|
"fts_versions": fts_versions,
|
|
|
|
|
"extensions": sqlite_extensions,
|
2019-01-10 16:44:37 -08:00
|
|
|
"compile_options": [
|
|
|
|
|
r[0] for r in conn.execute("pragma compile_options;").fetchall()
|
|
|
|
|
],
|
2018-05-02 01:46:54 -07:00
|
|
|
},
|
|
|
|
|
}
|
2020-12-03 14:08:50 -08:00
|
|
|
if using_pysqlite3:
|
2020-12-03 20:07:10 -08:00
|
|
|
for package in ("pysqlite3", "pysqlite3-binary"):
|
|
|
|
|
try:
|
2023-09-16 09:35:18 -07:00
|
|
|
info["pysqlite3"] = importlib.metadata.version(package)
|
2020-12-03 20:07:10 -08:00
|
|
|
break
|
2023-09-16 09:35:18 -07:00
|
|
|
except importlib.metadata.PackageNotFoundError:
|
2020-12-03 20:07:10 -08:00
|
|
|
pass
|
2025-12-12 22:38:04 -08:00
|
|
|
conn.close()
|
2020-12-03 14:08:50 -08:00
|
|
|
return info
|
2018-05-02 01:46:54 -07:00
|
|
|
|
2020-06-05 16:46:37 -07:00
|
|
|
def _plugins(self, request=None, all=False):
|
2020-03-08 16:09:31 -07:00
|
|
|
ps = list(get_plugins())
|
2020-06-05 16:55:08 -07:00
|
|
|
should_show_all = False
|
|
|
|
|
if request is not None:
|
|
|
|
|
should_show_all = request.args.get("all")
|
|
|
|
|
else:
|
|
|
|
|
should_show_all = all
|
|
|
|
|
if not should_show_all:
|
2019-01-26 12:01:16 -08:00
|
|
|
ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
|
2022-01-19 21:14:04 -08:00
|
|
|
ps.sort(key=lambda p: p["name"])
|
2018-05-13 10:06:02 -03:00
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"name": p["name"],
|
|
|
|
|
"static": p["static_path"] is not None,
|
|
|
|
|
"templates": p["templates_path"] is not None,
|
|
|
|
|
"version": p.get("version"),
|
2022-01-19 21:14:04 -08:00
|
|
|
"hooks": list(sorted(set(p["hooks"]))),
|
2018-05-13 10:06:02 -03:00
|
|
|
}
|
2019-01-26 12:01:16 -08:00
|
|
|
for p in ps
|
2018-05-13 10:06:02 -03:00
|
|
|
]
|
|
|
|
|
|
2020-05-30 07:38:46 -07:00
|
|
|
def _threads(self):
|
2022-05-02 13:15:27 -07:00
|
|
|
if self.setting("num_sql_threads") == 0:
|
|
|
|
|
return {"num_threads": 0, "threads": []}
|
2019-10-02 08:32:47 -07:00
|
|
|
threads = list(threading.enumerate())
|
2019-12-04 22:46:39 -08:00
|
|
|
d = {
|
2019-10-02 08:32:47 -07:00
|
|
|
"num_threads": len(threads),
|
|
|
|
|
"threads": [
|
|
|
|
|
{"name": t.name, "ident": t.ident, "daemon": t.daemon} for t in threads
|
|
|
|
|
],
|
|
|
|
|
}
|
2023-07-08 11:40:19 -07:00
|
|
|
tasks = asyncio.all_tasks()
|
|
|
|
|
d.update(
|
|
|
|
|
{
|
|
|
|
|
"num_tasks": len(tasks),
|
|
|
|
|
"tasks": [_cleaner_task_str(t) for t in tasks],
|
|
|
|
|
}
|
|
|
|
|
)
|
2019-12-04 22:46:39 -08:00
|
|
|
return d
|
2019-10-02 08:32:47 -07:00
|
|
|
|
2020-05-30 18:51:00 -07:00
|
|
|
def _actor(self, request):
|
2020-06-08 10:05:32 -07:00
|
|
|
return {"actor": request.actor}
|
2020-05-30 18:51:00 -07:00
|
|
|
|
2025-10-26 16:10:58 -07:00
|
|
|
def _actions(self):
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"name": action.name,
|
|
|
|
|
"abbr": action.abbr,
|
|
|
|
|
"description": action.description,
|
|
|
|
|
"takes_parent": action.takes_parent,
|
|
|
|
|
"takes_child": action.takes_child,
|
2025-11-01 11:35:08 -07:00
|
|
|
"resource_class": (
|
|
|
|
|
action.resource_class.__name__ if action.resource_class else None
|
|
|
|
|
),
|
2025-10-26 16:10:58 -07:00
|
|
|
"also_requires": action.also_requires,
|
|
|
|
|
}
|
|
|
|
|
for action in sorted(self.actions.values(), key=lambda a: a.name)
|
|
|
|
|
]
|
|
|
|
|
|
2024-02-06 21:57:09 -08:00
|
|
|
async def table_config(self, database: str, table: str) -> dict:
|
|
|
|
|
"""Return dictionary of configuration for specified table"""
|
2019-04-06 19:56:07 -07:00
|
|
|
return (
|
2024-02-06 21:57:09 -08:00
|
|
|
(self.config or {})
|
|
|
|
|
.get("databases", {})
|
2019-04-06 19:56:07 -07:00
|
|
|
.get(database, {})
|
|
|
|
|
.get("tables", {})
|
|
|
|
|
.get(table, {})
|
|
|
|
|
)
|
|
|
|
|
|
2020-05-30 07:38:46 -07:00
|
|
|
def _register_renderers(self):
|
2020-12-23 18:04:32 +01:00
|
|
|
"""Register output renderers which output data in custom formats."""
|
2019-05-02 00:01:56 +01:00
|
|
|
# Built-in renderers
|
2020-05-27 22:57:05 -07:00
|
|
|
self.renderers["json"] = (json_renderer, lambda: True)
|
2019-05-02 00:01:56 +01:00
|
|
|
|
|
|
|
|
# Hooks
|
|
|
|
|
hook_renderers = []
|
2019-05-26 22:07:27 -07:00
|
|
|
# pylint: disable=no-member
|
2019-05-02 00:01:56 +01:00
|
|
|
for hook in pm.hook.register_output_renderer(datasette=self):
|
2021-03-28 17:20:55 -07:00
|
|
|
if type(hook) is list:
|
2019-05-02 00:01:56 +01:00
|
|
|
hook_renderers += hook
|
|
|
|
|
else:
|
|
|
|
|
hook_renderers.append(hook)
|
|
|
|
|
|
|
|
|
|
for renderer in hook_renderers:
|
2020-05-27 19:21:41 -07:00
|
|
|
self.renderers[renderer["extension"]] = (
|
|
|
|
|
# It used to be called "callback" - remove this in Datasette 1.0
|
2020-05-27 22:57:05 -07:00
|
|
|
renderer.get("render") or renderer["callback"],
|
|
|
|
|
renderer.get("can_render") or (lambda: True),
|
2020-05-27 19:21:41 -07:00
|
|
|
)
|
2019-05-02 00:01:56 +01:00
|
|
|
|
2020-02-04 12:26:17 -08:00
|
|
|
async def render_template(
|
2023-08-07 18:47:39 -07:00
|
|
|
self,
|
2025-10-26 10:39:15 -07:00
|
|
|
templates: List[str] | str | Template,
|
|
|
|
|
context: Dict[str, Any] | Context | None = None,
|
|
|
|
|
request: Request | None = None,
|
|
|
|
|
view_name: str | None = None,
|
2020-02-04 12:26:17 -08:00
|
|
|
):
|
2022-09-16 20:38:15 -07:00
|
|
|
if not self._startup_invoked:
|
|
|
|
|
raise Exception("render_template() called before await ds.invoke_startup()")
|
2020-02-04 12:26:17 -08:00
|
|
|
context = context or {}
|
|
|
|
|
if isinstance(templates, Template):
|
|
|
|
|
template = templates
|
|
|
|
|
else:
|
|
|
|
|
if isinstance(templates, str):
|
|
|
|
|
templates = [templates]
|
2024-01-05 14:33:23 -08:00
|
|
|
template = self.get_jinja_environment(request).select_template(templates)
|
2023-08-07 18:47:39 -07:00
|
|
|
if dataclasses.is_dataclass(context):
|
|
|
|
|
context = dataclasses.asdict(context)
|
2020-02-04 12:26:17 -08:00
|
|
|
body_scripts = []
|
|
|
|
|
# pylint: disable=no-member
|
2020-08-16 09:50:23 -07:00
|
|
|
for extra_script in pm.hook.extra_body_script(
|
2020-02-04 12:26:17 -08:00
|
|
|
template=template.name,
|
|
|
|
|
database=context.get("database"),
|
|
|
|
|
table=context.get("table"),
|
2020-08-16 11:09:53 -07:00
|
|
|
columns=context.get("columns"),
|
2020-02-04 12:26:17 -08:00
|
|
|
view_name=view_name,
|
2020-08-16 09:50:23 -07:00
|
|
|
request=request,
|
2020-02-04 12:26:17 -08:00
|
|
|
datasette=self,
|
|
|
|
|
):
|
2020-09-02 15:21:12 -07:00
|
|
|
extra_script = await await_me_maybe(extra_script)
|
2021-01-13 18:14:33 -08:00
|
|
|
if isinstance(extra_script, dict):
|
|
|
|
|
script = extra_script["script"]
|
|
|
|
|
module = bool(extra_script.get("module"))
|
|
|
|
|
else:
|
|
|
|
|
script = extra_script
|
|
|
|
|
module = False
|
|
|
|
|
body_scripts.append({"script": Markup(script), "module": module})
|
2020-02-04 12:26:17 -08:00
|
|
|
|
|
|
|
|
extra_template_vars = {}
|
|
|
|
|
# pylint: disable=no-member
|
|
|
|
|
for extra_vars in pm.hook.extra_template_vars(
|
|
|
|
|
template=template.name,
|
|
|
|
|
database=context.get("database"),
|
|
|
|
|
table=context.get("table"),
|
2020-08-16 11:09:53 -07:00
|
|
|
columns=context.get("columns"),
|
2020-02-04 12:26:17 -08:00
|
|
|
view_name=view_name,
|
|
|
|
|
request=request,
|
|
|
|
|
datasette=self,
|
|
|
|
|
):
|
2020-09-02 15:21:12 -07:00
|
|
|
extra_vars = await await_me_maybe(extra_vars)
|
2020-02-04 12:26:17 -08:00
|
|
|
assert isinstance(extra_vars, dict), "extra_vars is of type {}".format(
|
|
|
|
|
type(extra_vars)
|
|
|
|
|
)
|
|
|
|
|
extra_template_vars.update(extra_vars)
|
|
|
|
|
|
2020-10-29 20:45:15 -07:00
|
|
|
async def menu_links():
|
|
|
|
|
links = []
|
|
|
|
|
for hook in pm.hook.menu_links(
|
2021-06-09 21:45:24 -07:00
|
|
|
datasette=self,
|
|
|
|
|
actor=request.actor if request else None,
|
|
|
|
|
request=request or None,
|
2020-10-29 20:45:15 -07:00
|
|
|
):
|
|
|
|
|
extra_links = await await_me_maybe(hook)
|
|
|
|
|
if extra_links:
|
|
|
|
|
links.extend(extra_links)
|
|
|
|
|
return links
|
|
|
|
|
|
2020-02-04 12:26:17 -08:00
|
|
|
template_context = {
|
|
|
|
|
**context,
|
|
|
|
|
**{
|
2022-10-13 14:42:52 -07:00
|
|
|
"request": request,
|
|
|
|
|
"crumb_items": self._crumb_items,
|
2020-10-19 17:33:59 -07:00
|
|
|
"urls": self.urls,
|
2020-06-29 11:40:40 -07:00
|
|
|
"actor": request.actor if request else None,
|
2020-10-29 20:45:15 -07:00
|
|
|
"menu_links": menu_links,
|
2020-06-29 11:40:40 -07:00
|
|
|
"display_actor": display_actor,
|
2020-10-29 22:14:33 -07:00
|
|
|
"show_logout": request is not None
|
|
|
|
|
and "ds_actor" in request.cookies
|
|
|
|
|
and request.actor,
|
2020-02-04 12:26:17 -08:00
|
|
|
"app_css_hash": self.app_css_hash(),
|
|
|
|
|
"zip": zip,
|
|
|
|
|
"body_scripts": body_scripts,
|
|
|
|
|
"format_bytes": format_bytes,
|
2020-06-28 17:50:47 -07:00
|
|
|
"show_messages": lambda: self._show_messages(request),
|
2020-08-16 09:50:23 -07:00
|
|
|
"extra_css_urls": await self._asset_urls(
|
|
|
|
|
"extra_css_urls", template, context, request, view_name
|
|
|
|
|
),
|
|
|
|
|
"extra_js_urls": await self._asset_urls(
|
|
|
|
|
"extra_js_urls", template, context, request, view_name
|
|
|
|
|
),
|
2020-11-24 14:06:32 -08:00
|
|
|
"base_url": self.setting("base_url"),
|
2026-04-14 17:11:36 -07:00
|
|
|
"csrftoken": (
|
|
|
|
|
request.scope["csrftoken"]
|
|
|
|
|
if request and "csrftoken" in request.scope
|
|
|
|
|
else lambda: ""
|
|
|
|
|
),
|
2022-11-01 22:45:05 -07:00
|
|
|
"datasette_version": __version__,
|
2020-02-04 12:26:17 -08:00
|
|
|
},
|
|
|
|
|
**extra_template_vars,
|
|
|
|
|
}
|
2020-11-24 14:06:32 -08:00
|
|
|
if request and request.args.get("_context") and self.setting("template_debug"):
|
2020-04-05 11:49:15 -07:00
|
|
|
return "<pre>{}</pre>".format(
|
2021-05-23 18:41:50 -07:00
|
|
|
escape(json.dumps(template_context, default=repr, indent=4))
|
2020-04-05 11:49:15 -07:00
|
|
|
)
|
|
|
|
|
|
2020-02-04 12:26:17 -08:00
|
|
|
return await template.render_async(template_context)
|
|
|
|
|
|
2025-01-15 17:37:25 -08:00
|
|
|
def set_actor_cookie(
|
2025-10-26 10:39:15 -07:00
|
|
|
self, response: Response, actor: dict, expire_after: int | None = None
|
2025-01-15 17:37:25 -08:00
|
|
|
):
|
|
|
|
|
data = {"a": actor}
|
|
|
|
|
if expire_after:
|
|
|
|
|
expires_at = int(time.time()) + (24 * 60 * 60)
|
|
|
|
|
data["e"] = baseconv.base62.encode(expires_at)
|
|
|
|
|
response.set_cookie("ds_actor", self.sign(data, "actor"))
|
|
|
|
|
|
|
|
|
|
def delete_actor_cookie(self, response: Response):
|
|
|
|
|
response.set_cookie("ds_actor", "", expires=0, max_age=0)
|
|
|
|
|
|
2020-08-16 09:50:23 -07:00
|
|
|
async def _asset_urls(self, key, template, context, request, view_name):
|
2020-02-04 12:26:17 -08:00
|
|
|
# Flatten list-of-lists from plugins:
|
|
|
|
|
seen_urls = set()
|
2020-08-16 09:50:23 -07:00
|
|
|
collected = []
|
|
|
|
|
for hook in getattr(pm.hook, key)(
|
|
|
|
|
template=template.name,
|
|
|
|
|
database=context.get("database"),
|
|
|
|
|
table=context.get("table"),
|
2020-08-16 11:09:53 -07:00
|
|
|
columns=context.get("columns"),
|
2020-08-16 09:50:23 -07:00
|
|
|
view_name=view_name,
|
|
|
|
|
request=request,
|
2020-08-16 11:09:53 -07:00
|
|
|
datasette=self,
|
2020-02-04 12:26:17 -08:00
|
|
|
):
|
2020-09-02 15:21:12 -07:00
|
|
|
hook = await await_me_maybe(hook)
|
2020-08-16 09:50:23 -07:00
|
|
|
collected.extend(hook)
|
2023-10-12 09:16:37 -07:00
|
|
|
collected.extend((self.config or {}).get(key) or [])
|
2020-08-16 09:50:23 -07:00
|
|
|
output = []
|
|
|
|
|
for url_or_dict in collected:
|
2020-02-04 12:26:17 -08:00
|
|
|
if isinstance(url_or_dict, dict):
|
|
|
|
|
url = url_or_dict["url"]
|
|
|
|
|
sri = url_or_dict.get("sri")
|
2021-01-13 17:50:52 -08:00
|
|
|
module = bool(url_or_dict.get("module"))
|
2020-02-04 12:26:17 -08:00
|
|
|
else:
|
|
|
|
|
url = url_or_dict
|
|
|
|
|
sri = None
|
2021-01-13 17:50:52 -08:00
|
|
|
module = False
|
2020-02-04 12:26:17 -08:00
|
|
|
if url in seen_urls:
|
|
|
|
|
continue
|
|
|
|
|
seen_urls.add(url)
|
2020-10-31 13:35:47 -07:00
|
|
|
if url.startswith("/"):
|
|
|
|
|
# Take base_url into account:
|
|
|
|
|
url = self.urls.path(url)
|
2021-01-13 17:50:52 -08:00
|
|
|
script = {"url": url}
|
2020-02-04 12:26:17 -08:00
|
|
|
if sri:
|
2021-01-13 17:50:52 -08:00
|
|
|
script["sri"] = sri
|
|
|
|
|
if module:
|
|
|
|
|
script["module"] = True
|
|
|
|
|
output.append(script)
|
2020-08-16 09:50:23 -07:00
|
|
|
return output
|
2020-02-04 12:26:17 -08:00
|
|
|
|
2024-02-06 12:33:46 -08:00
|
|
|
def _config(self):
|
|
|
|
|
return redact_keys(
|
|
|
|
|
self.config, ("secret", "key", "password", "token", "hash", "dsn")
|
|
|
|
|
)
|
|
|
|
|
|
2022-03-18 21:03:08 -07:00
|
|
|
def _routes(self):
|
2019-06-23 20:13:09 -07:00
|
|
|
routes = []
|
|
|
|
|
|
2021-07-26 16:16:46 -07:00
|
|
|
for routes_to_add in pm.hook.register_routes(datasette=self):
|
2020-06-08 20:12:06 -07:00
|
|
|
for regex, view_fn in routes_to_add:
|
|
|
|
|
routes.append((regex, wrap_view(view_fn, self)))
|
|
|
|
|
|
2019-06-23 20:13:09 -07:00
|
|
|
def add_route(view, regex):
|
|
|
|
|
routes.append((regex, view))
|
|
|
|
|
|
2022-03-19 13:29:10 -07:00
|
|
|
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
|
2024-08-14 17:57:13 -07:00
|
|
|
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
|
|
|
|
|
add_route(permanent_redirect("/-/"), r"/-$")
|
2017-11-10 11:05:57 -08:00
|
|
|
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(favicon, "/favicon.ico")
|
|
|
|
|
|
|
|
|
|
add_route(
|
|
|
|
|
asgi_static(app_root / "datasette" / "static"), r"/-/static/(?P<path>.*)$"
|
|
|
|
|
)
|
2017-12-03 08:33:36 -08:00
|
|
|
for path, dirname in self.static_mounts:
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(asgi_static(dirname), r"/" + path + "/(?P<path>.*)$")
|
|
|
|
|
|
2018-04-17 19:32:48 -07:00
|
|
|
# Mount any plugin static/ directories
|
2020-03-08 16:09:31 -07:00
|
|
|
for plugin in get_plugins():
|
2018-04-18 22:24:48 -07:00
|
|
|
if plugin["static_path"]:
|
2019-11-01 15:15:10 -07:00
|
|
|
add_route(
|
|
|
|
|
asgi_static(plugin["static_path"]),
|
2020-11-15 15:24:22 -08:00
|
|
|
f"/-/static-plugins/{plugin['name']}/(?P<path>.*)$",
|
2019-11-01 15:15:10 -07:00
|
|
|
)
|
|
|
|
|
# Support underscores in name in addition to hyphens, see https://github.com/simonw/datasette/issues/611
|
|
|
|
|
add_route(
|
|
|
|
|
asgi_static(plugin["static_path"]),
|
|
|
|
|
"/-/static-plugins/{}/(?P<path>.*)$".format(
|
|
|
|
|
plugin["name"].replace("-", "_")
|
|
|
|
|
),
|
|
|
|
|
)
|
2021-01-28 14:48:56 -08:00
|
|
|
add_route(
|
|
|
|
|
permanent_redirect(
|
|
|
|
|
"/_memory", forward_query_string=True, forward_rest=True
|
|
|
|
|
),
|
|
|
|
|
r"/:memory:(?P<rest>.*)$",
|
|
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2020-06-28 16:47:40 -07:00
|
|
|
JsonDataView.as_view(self, "versions.json", self._versions),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/versions(\.(?P<format>json))?$",
|
2018-05-02 01:46:54 -07:00
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2020-06-28 16:47:40 -07:00
|
|
|
JsonDataView.as_view(
|
2020-06-02 14:49:28 -07:00
|
|
|
self, "plugins.json", self._plugins, needs_request=True
|
|
|
|
|
),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/plugins(\.(?P<format>json))?$",
|
2018-04-18 22:24:48 -07:00
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2020-11-24 14:06:32 -08:00
|
|
|
JsonDataView.as_view(self, "settings.json", lambda: self._settings),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/settings(\.(?P<format>json))?$",
|
2020-11-24 12:19:14 -08:00
|
|
|
)
|
|
|
|
|
add_route(
|
2024-02-06 12:33:46 -08:00
|
|
|
JsonDataView.as_view(self, "config.json", lambda: self._config()),
|
|
|
|
|
r"/-/config(\.(?P<format>json))?$",
|
2018-05-17 23:16:28 -07:00
|
|
|
)
|
2019-10-02 08:32:47 -07:00
|
|
|
add_route(
|
2020-06-28 16:47:40 -07:00
|
|
|
JsonDataView.as_view(self, "threads.json", self._threads),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/threads(\.(?P<format>json))?$",
|
2019-10-02 08:32:47 -07:00
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2020-06-28 16:47:40 -07:00
|
|
|
JsonDataView.as_view(self, "databases.json", self._connected_databases),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/databases(\.(?P<format>json))?$",
|
2019-05-16 07:49:34 -07:00
|
|
|
)
|
2020-05-30 18:51:00 -07:00
|
|
|
add_route(
|
2022-12-12 20:11:51 -08:00
|
|
|
JsonDataView.as_view(
|
|
|
|
|
self, "actor.json", self._actor, needs_request=True, permission=None
|
|
|
|
|
),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/-/actor(\.(?P<format>json))?$",
|
2020-05-30 18:51:00 -07:00
|
|
|
)
|
2025-10-26 16:10:58 -07:00
|
|
|
add_route(
|
|
|
|
|
JsonDataView.as_view(
|
2025-10-26 16:53:49 -07:00
|
|
|
self,
|
|
|
|
|
"actions.json",
|
|
|
|
|
self._actions,
|
|
|
|
|
template="debug_actions.html",
|
|
|
|
|
permission="permissions-debug",
|
2025-10-26 16:10:58 -07:00
|
|
|
),
|
|
|
|
|
r"/-/actions(\.(?P<format>json))?$",
|
|
|
|
|
)
|
2020-05-31 18:03:17 -07:00
|
|
|
add_route(
|
2020-09-02 15:24:55 -07:00
|
|
|
AuthTokenView.as_view(self),
|
|
|
|
|
r"/-/auth-token$",
|
2020-05-31 18:03:17 -07:00
|
|
|
)
|
2022-10-25 17:07:58 -07:00
|
|
|
add_route(
|
|
|
|
|
CreateTokenView.as_view(self),
|
|
|
|
|
r"/-/create-token$",
|
|
|
|
|
)
|
2022-10-29 23:20:11 -07:00
|
|
|
add_route(
|
|
|
|
|
ApiExplorerView.as_view(self),
|
|
|
|
|
r"/-/api$",
|
|
|
|
|
)
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
add_route(
|
|
|
|
|
TablesView.as_view(self),
|
2025-10-23 15:28:37 -07:00
|
|
|
r"/-/tables(\.(?P<format>json))?$",
|
Implement resource-based permission system with SQL-driven access control
This introduces a new hierarchical permission system that uses SQL queries
for efficient permission checking across resources. The system replaces the
older permission_allowed() pattern with a more flexible resource-based
approach.
Core changes:
- New Resource ABC and Action dataclass in datasette/permissions.py
* Resources represent hierarchical entities (instance, database, table)
* Each resource type implements resources_sql() to list all instances
* Actions define operations on resources with cascading rules
- New plugin hook: register_actions(datasette)
* Plugins register actions with their associated resource types
* Replaces register_permissions() and register_resource_types()
* See docs/plugin_hooks.rst for full documentation
- Three new Datasette methods for permission checks:
* allowed_resources(action, actor) - returns list[Resource]
* allowed_resources_with_reasons(action, actor) - for debugging
* allowed(action, resource, actor) - checks single resource
* All use SQL for filtering, never Python iteration
- New /-/tables endpoint (TablesView)
* Returns JSON list of tables user can view
* Supports ?q= parameter for regex filtering
* Format: {"matches": [{"name": "db/table", "url": "/db/table"}]}
* Respects all permission rules from configuration and plugins
- SQL-based permission evaluation (datasette/utils/actions_sql.py)
* Cascading rules: child-level → parent-level → global-level
* DENY beats ALLOW at same specificity
* Uses CTEs for efficient SQL-only filtering
* Combines permission_resources_sql() hook results
- Default actions in datasette/default_actions.py
* InstanceResource, DatabaseResource, TableResource, QueryResource
* Core actions: view-instance, view-database, view-table, etc.
- Fixed default_permissions.py to handle database-level allow blocks
* Now creates parent-level rules for view-table action
* Fixes: datasette ... -s databases.fixtures.allow.id root
Documentation:
- Comprehensive register_actions() hook documentation
- Detailed resources_sql() method explanation
- /-/tables endpoint documentation in docs/introspection.rst
- Deprecated register_permissions() with migration guide
Tests:
- tests/test_actions_sql.py: 7 tests for core permission API
- tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint
- All 118 documentation tests pass
- Tests verify SQL does filtering (not Python)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
|
|
|
)
|
/-/schema and /db/-/schema and /db/table/-/schema pages (plus .json/.md)
* Add schema endpoints for databases, instances, and tables
Closes: #2586
This commit adds new endpoints to view database schemas in multiple formats:
- /-/schema - View schemas for all databases (HTML, JSON, MD)
- /database/-/schema - View schema for a specific database (HTML, JSON, MD)
- /database/table/-/schema - View schema for a specific table (JSON, MD)
Features:
- Supports HTML, JSON, and Markdown output formats
- Respects view-database and view-table permissions
- Uses group_concat(sql, ';' || CHAR(10)) from sqlite_master to retrieve schemas
- Includes comprehensive tests covering all formats and permission checks
The JSON endpoints return:
- Instance level: {"schemas": [{"database": "name", "schema": "sql"}, ...]}
- Database level: {"database": "name", "schema": "sql"}
- Table level: {"database": "name", "table": "name", "schema": "sql"}
Markdown format provides formatted output with headings and SQL code blocks.
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 12:01:23 -08:00
|
|
|
add_route(
|
|
|
|
|
InstanceSchemaView.as_view(self),
|
|
|
|
|
r"/-/schema(\.(?P<format>json|md))?$",
|
|
|
|
|
)
|
2020-06-28 21:17:30 -07:00
|
|
|
add_route(
|
2020-09-02 15:24:55 -07:00
|
|
|
LogoutView.as_view(self),
|
|
|
|
|
r"/-/logout$",
|
2020-06-28 21:17:30 -07:00
|
|
|
)
|
2020-05-31 22:00:36 -07:00
|
|
|
add_route(
|
2020-09-02 15:24:55 -07:00
|
|
|
PermissionsDebugView.as_view(self),
|
|
|
|
|
r"/-/permissions$",
|
2020-05-31 22:00:36 -07:00
|
|
|
)
|
2025-10-08 14:27:51 -07:00
|
|
|
add_route(
|
|
|
|
|
AllowedResourcesView.as_view(self),
|
|
|
|
|
r"/-/allowed(\.(?P<format>json))?$",
|
|
|
|
|
)
|
|
|
|
|
add_route(
|
|
|
|
|
PermissionRulesView.as_view(self),
|
|
|
|
|
r"/-/rules(\.(?P<format>json))?$",
|
|
|
|
|
)
|
|
|
|
|
add_route(
|
|
|
|
|
PermissionCheckView.as_view(self),
|
|
|
|
|
r"/-/check(\.(?P<format>json))?$",
|
|
|
|
|
)
|
2020-06-02 14:08:12 -07:00
|
|
|
add_route(
|
2020-09-02 15:24:55 -07:00
|
|
|
MessagesDebugView.as_view(self),
|
|
|
|
|
r"/-/messages$",
|
2020-06-02 14:08:12 -07:00
|
|
|
)
|
2020-07-24 15:54:41 -07:00
|
|
|
add_route(
|
2020-09-02 15:24:55 -07:00
|
|
|
AllowDebugView.as_view(self),
|
|
|
|
|
r"/-/allow-debug$",
|
2020-07-24 15:54:41 -07:00
|
|
|
)
|
2020-05-02 20:01:21 -07:00
|
|
|
add_route(
|
2023-05-25 17:18:43 -07:00
|
|
|
wrap_view(PatternPortfolioView, self),
|
2020-09-02 15:24:55 -07:00
|
|
|
r"/-/patterns$",
|
2020-05-02 20:01:21 -07:00
|
|
|
)
|
2023-07-26 11:43:55 -07:00
|
|
|
add_route(
|
|
|
|
|
wrap_view(database_download, self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)\.db$",
|
|
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2023-08-07 18:47:39 -07:00
|
|
|
wrap_view(DatabaseView, self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
2017-11-10 11:05:57 -08:00
|
|
|
)
|
2022-11-14 21:57:28 -08:00
|
|
|
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
/-/schema and /db/-/schema and /db/table/-/schema pages (plus .json/.md)
* Add schema endpoints for databases, instances, and tables
Closes: #2586
This commit adds new endpoints to view database schemas in multiple formats:
- /-/schema - View schemas for all databases (HTML, JSON, MD)
- /database/-/schema - View schema for a specific database (HTML, JSON, MD)
- /database/table/-/schema - View schema for a specific table (JSON, MD)
Features:
- Supports HTML, JSON, and Markdown output formats
- Respects view-database and view-table permissions
- Uses group_concat(sql, ';' || CHAR(10)) from sqlite_master to retrieve schemas
- Includes comprehensive tests covering all formats and permission checks
The JSON endpoints return:
- Instance level: {"schemas": [{"database": "name", "schema": "sql"}, ...]}
- Database level: {"database": "name", "schema": "sql"}
- Table level: {"database": "name", "table": "name", "schema": "sql"}
Markdown format provides formatted output with headings and SQL code blocks.
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 12:01:23 -08:00
|
|
|
add_route(
|
|
|
|
|
DatabaseSchemaView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
|
|
|
|
)
|
2024-07-15 10:33:51 -07:00
|
|
|
add_route(
|
|
|
|
|
wrap_view(QueryView, self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
|
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2023-03-22 15:49:39 -07:00
|
|
|
wrap_view(table_view, self),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
2017-11-10 11:05:57 -08:00
|
|
|
)
|
2019-06-23 20:13:09 -07:00
|
|
|
add_route(
|
2020-06-28 16:47:40 -07:00
|
|
|
RowView.as_view(self),
|
2022-03-19 13:29:10 -07:00
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)(\.(?P<format>\w+))?$",
|
2017-11-10 11:05:57 -08:00
|
|
|
)
|
2022-10-27 13:17:18 -07:00
|
|
|
add_route(
|
|
|
|
|
TableInsertView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/insert$",
|
|
|
|
|
)
|
2022-12-07 17:12:15 -08:00
|
|
|
add_route(
|
|
|
|
|
TableUpsertView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
|
|
|
|
|
)
|
2026-03-18 12:15:42 -07:00
|
|
|
add_route(
|
|
|
|
|
TableSetColumnTypeView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
|
|
|
|
)
|
2022-10-30 15:17:21 -07:00
|
|
|
add_route(
|
|
|
|
|
TableDropView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
|
|
|
|
)
|
/-/schema and /db/-/schema and /db/table/-/schema pages (plus .json/.md)
* Add schema endpoints for databases, instances, and tables
Closes: #2586
This commit adds new endpoints to view database schemas in multiple formats:
- /-/schema - View schemas for all databases (HTML, JSON, MD)
- /database/-/schema - View schema for a specific database (HTML, JSON, MD)
- /database/table/-/schema - View schema for a specific table (JSON, MD)
Features:
- Supports HTML, JSON, and Markdown output formats
- Respects view-database and view-table permissions
- Uses group_concat(sql, ';' || CHAR(10)) from sqlite_master to retrieve schemas
- Includes comprehensive tests covering all formats and permission checks
The JSON endpoints return:
- Instance level: {"schemas": [{"database": "name", "schema": "sql"}, ...]}
- Database level: {"database": "name", "schema": "sql"}
- Table level: {"database": "name", "table": "name", "schema": "sql"}
Markdown format provides formatted output with headings and SQL code blocks.
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 12:01:23 -08:00
|
|
|
add_route(
|
|
|
|
|
TableSchemaView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
|
|
|
|
)
|
2022-10-30 16:16:00 -07:00
|
|
|
add_route(
|
|
|
|
|
RowDeleteView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/delete$",
|
|
|
|
|
)
|
2022-11-29 10:06:19 -08:00
|
|
|
add_route(
|
|
|
|
|
RowUpdateView.as_view(self),
|
|
|
|
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/update$",
|
|
|
|
|
)
|
2022-03-18 21:03:08 -07:00
|
|
|
return [
|
|
|
|
|
# Compile any strings to regular expressions
|
|
|
|
|
((re.compile(pattern) if isinstance(pattern, str) else pattern), view)
|
|
|
|
|
for pattern, view in routes
|
|
|
|
|
]
|
|
|
|
|
|
2022-11-18 14:46:25 -08:00
|
|
|
async def resolve_database(self, request):
|
|
|
|
|
database_route = tilde_decode(request.url_vars["database"])
|
|
|
|
|
try:
|
|
|
|
|
return self.get_database(route=database_route)
|
|
|
|
|
except KeyError:
|
2024-06-21 16:02:15 -07:00
|
|
|
raise DatabaseNotFound(database_route)
|
2022-11-18 14:46:25 -08:00
|
|
|
|
|
|
|
|
async def resolve_table(self, request):
|
|
|
|
|
db = await self.resolve_database(request)
|
|
|
|
|
table_name = tilde_decode(request.url_vars["table"])
|
|
|
|
|
# Table must exist
|
|
|
|
|
is_view = False
|
|
|
|
|
table_exists = await db.table_exists(table_name)
|
|
|
|
|
if not table_exists:
|
|
|
|
|
is_view = await db.view_exists(table_name)
|
|
|
|
|
if not (table_exists or is_view):
|
2024-06-21 16:09:20 -07:00
|
|
|
raise TableNotFound(db.name, table_name)
|
2022-11-18 14:46:25 -08:00
|
|
|
return ResolvedTable(db, table_name, is_view)
|
|
|
|
|
|
|
|
|
|
async def resolve_row(self, request):
|
|
|
|
|
db, table_name, _ = await self.resolve_table(request)
|
|
|
|
|
pk_values = urlsafe_components(request.url_vars["pks"])
|
|
|
|
|
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
|
|
|
|
|
results = await db.execute(sql, params, truncate=True)
|
|
|
|
|
row = results.first()
|
|
|
|
|
if row is None:
|
2024-06-21 16:10:16 -07:00
|
|
|
raise RowNotFound(db.name, table_name, pk_values)
|
2022-11-18 14:46:25 -08:00
|
|
|
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
|
|
|
|
|
2022-03-18 21:03:08 -07:00
|
|
|
def app(self):
|
|
|
|
|
"""Returns an ASGI app function that serves the whole of Datasette"""
|
|
|
|
|
routes = self._routes()
|
2019-04-20 22:28:15 -07:00
|
|
|
|
2019-06-23 20:13:09 -07:00
|
|
|
async def setup_db():
|
|
|
|
|
# First time server starts up, calculate table counts for immutable databases
|
2022-12-15 09:34:07 -08:00
|
|
|
for database in self.databases.values():
|
2019-05-01 17:39:39 -07:00
|
|
|
if not database.is_mutable:
|
|
|
|
|
await database.table_counts(limit=60 * 60 * 1000)
|
|
|
|
|
|
2026-04-14 17:11:36 -07:00
|
|
|
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
2021-06-05 13:15:58 -07:00
|
|
|
if self.setting("trace_debug"):
|
|
|
|
|
asgi = AsgiTracer(asgi)
|
2022-12-17 17:22:00 -08:00
|
|
|
asgi = AsgiLifespan(asgi)
|
2022-12-15 09:34:07 -08:00
|
|
|
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
|
2019-07-02 20:57:28 -07:00
|
|
|
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
|
|
|
|
asgi = wrapper(asgi)
|
|
|
|
|
return asgi
|
2019-06-23 20:13:09 -07:00
|
|
|
|
|
|
|
|
|
2020-06-28 13:45:17 -07:00
|
|
|
class DatasetteRouter:
|
2019-06-23 20:13:09 -07:00
|
|
|
def __init__(self, datasette, routes):
|
|
|
|
|
self.ds = datasette
|
2022-03-18 21:03:08 -07:00
|
|
|
self.routes = routes or []
|
2020-06-28 13:45:17 -07:00
|
|
|
|
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
|
|
|
# Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves
|
|
|
|
|
path = scope["path"]
|
|
|
|
|
raw_path = scope.get("raw_path")
|
|
|
|
|
if raw_path:
|
|
|
|
|
path = raw_path.decode("ascii")
|
2021-10-14 11:03:44 -07:00
|
|
|
path = path.partition("?")[0]
|
2020-06-28 13:45:17 -07:00
|
|
|
return await self.route_path(scope, receive, send, path)
|
2019-06-23 20:13:09 -07:00
|
|
|
|
2020-03-24 17:18:43 -07:00
|
|
|
async def route_path(self, scope, receive, send, path):
|
|
|
|
|
# Strip off base_url if present before routing
|
2020-11-24 14:06:32 -08:00
|
|
|
base_url = self.ds.setting("base_url")
|
2020-03-24 17:18:43 -07:00
|
|
|
if base_url != "/" and path.startswith(base_url):
|
|
|
|
|
path = "/" + path[len(base_url) :]
|
2021-06-05 11:59:54 -07:00
|
|
|
scope = dict(scope, route_path=path)
|
2020-06-28 17:01:33 -07:00
|
|
|
request = Request(scope, receive)
|
2020-06-28 17:25:35 -07:00
|
|
|
# Populate request_messages if ds_messages cookie is present
|
|
|
|
|
try:
|
|
|
|
|
request._messages = self.ds.unsign(
|
|
|
|
|
request.cookies.get("ds_messages", ""), "messages"
|
|
|
|
|
)
|
|
|
|
|
except BadSignature:
|
|
|
|
|
pass
|
|
|
|
|
|
2020-06-02 17:05:33 -07:00
|
|
|
scope_modifications = {}
|
2020-05-28 10:09:32 -07:00
|
|
|
# Apply force_https_urls, if set
|
|
|
|
|
if (
|
2020-11-24 14:06:32 -08:00
|
|
|
self.ds.setting("force_https_urls")
|
2020-05-28 10:09:32 -07:00
|
|
|
and scope["type"] == "http"
|
|
|
|
|
and scope.get("scheme") != "https"
|
|
|
|
|
):
|
2020-06-02 17:05:33 -07:00
|
|
|
scope_modifications["scheme"] = "https"
|
2020-05-30 15:06:33 -07:00
|
|
|
# Handle authentication
|
2020-06-18 11:37:28 -07:00
|
|
|
default_actor = scope.get("actor") or None
|
2020-05-30 15:06:33 -07:00
|
|
|
actor = None
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
|
|
|
|
|
for result in results:
|
|
|
|
|
result = await await_me_maybe(result)
|
|
|
|
|
if result and actor is None:
|
|
|
|
|
actor = result
|
|
|
|
|
# Don't break — we must await all coroutines to avoid
|
|
|
|
|
# "coroutine was never awaited" warnings
|
2020-06-18 11:37:28 -07:00
|
|
|
scope_modifications["actor"] = actor or default_actor
|
2020-06-28 13:45:17 -07:00
|
|
|
scope = dict(scope, **scope_modifications)
|
2022-03-18 21:03:08 -07:00
|
|
|
|
|
|
|
|
match, view = resolve_routes(self.routes, path)
|
|
|
|
|
|
|
|
|
|
if match is None:
|
|
|
|
|
return await self.handle_404(request, send)
|
|
|
|
|
|
|
|
|
|
new_scope = dict(scope, url_route={"kwargs": match.groupdict()})
|
|
|
|
|
request.scope = new_scope
|
|
|
|
|
try:
|
|
|
|
|
response = await view(request, send)
|
|
|
|
|
if response:
|
|
|
|
|
self.ds._write_messages_to_response(request, response)
|
|
|
|
|
await response.asgi_send(send)
|
|
|
|
|
return
|
|
|
|
|
except NotFound as exception:
|
|
|
|
|
return await self.handle_404(request, send, exception)
|
2022-07-17 16:24:39 -07:00
|
|
|
except Forbidden as exception:
|
|
|
|
|
# Try the forbidden() plugin hook
|
|
|
|
|
for custom_response in pm.hook.forbidden(
|
|
|
|
|
datasette=self.ds, request=request, message=exception.args[0]
|
|
|
|
|
):
|
|
|
|
|
custom_response = await await_me_maybe(custom_response)
|
|
|
|
|
assert (
|
|
|
|
|
custom_response
|
|
|
|
|
), "Default forbidden() hook should have been called"
|
|
|
|
|
return await custom_response.asgi_send(send)
|
2022-03-18 21:03:08 -07:00
|
|
|
except Exception as exception:
|
2022-07-17 15:24:16 -07:00
|
|
|
return await self.handle_exception(request, send, exception)
|
2020-03-24 17:18:43 -07:00
|
|
|
|
2020-06-30 21:17:38 -07:00
|
|
|
async def handle_404(self, request, send, exception=None):
|
2022-03-15 11:01:57 -07:00
|
|
|
# If path contains % encoding, redirect to tilde encoding
|
2022-03-07 08:01:03 -08:00
|
|
|
if "%" in request.path:
|
2022-03-15 11:01:57 -07:00
|
|
|
# Try the same path but with "%" replaced by "~"
|
|
|
|
|
# and "~" replaced with "~7E"
|
|
|
|
|
# and "." replaced with "~2E"
|
|
|
|
|
new_path = (
|
|
|
|
|
request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E")
|
|
|
|
|
)
|
2022-03-07 08:18:07 -08:00
|
|
|
if request.query_string:
|
|
|
|
|
new_path += "?{}".format(request.query_string)
|
2022-03-07 08:01:03 -08:00
|
|
|
await asgi_send_redirect(send, new_path)
|
|
|
|
|
return
|
2019-06-23 20:13:09 -07:00
|
|
|
# If URL has a trailing slash, redirect to URL without it
|
2021-10-14 11:03:44 -07:00
|
|
|
path = request.scope.get(
|
|
|
|
|
"raw_path", request.scope["path"].encode("utf8")
|
|
|
|
|
).partition(b"?")[0]
|
2020-09-13 19:33:55 -07:00
|
|
|
context = {}
|
2019-06-23 20:13:09 -07:00
|
|
|
if path.endswith(b"/"):
|
|
|
|
|
path = path.rstrip(b"/")
|
2020-06-30 21:17:38 -07:00
|
|
|
if request.scope["query_string"]:
|
|
|
|
|
path += b"?" + request.scope["query_string"]
|
2019-06-23 20:13:09 -07:00
|
|
|
await asgi_send_redirect(send, path.decode("latin1"))
|
|
|
|
|
else:
|
2020-04-26 11:46:43 -07:00
|
|
|
# Is there a pages/* template matching this path?
|
2021-06-05 11:59:54 -07:00
|
|
|
route_path = request.scope.get("route_path", request.scope["path"])
|
2022-02-03 01:58:35 +00:00
|
|
|
# Jinja requires template names to use "/" even on Windows
|
|
|
|
|
template_name = "pages" + route_path + ".html"
|
2024-01-05 14:33:23 -08:00
|
|
|
# Build a list of pages/blah/{name}.html matching expressions
|
|
|
|
|
environment = self.ds.get_jinja_environment(request)
|
|
|
|
|
pattern_templates = [
|
|
|
|
|
filepath
|
|
|
|
|
for filepath in environment.list_templates()
|
|
|
|
|
if "{" in filepath and filepath.startswith("pages/")
|
|
|
|
|
]
|
|
|
|
|
page_routes = [
|
|
|
|
|
(route_pattern_from_filepath(filepath[len("pages/") :]), filepath)
|
|
|
|
|
for filepath in pattern_templates
|
|
|
|
|
]
|
2020-04-26 11:46:43 -07:00
|
|
|
try:
|
2024-01-05 14:33:23 -08:00
|
|
|
template = environment.select_template([template_name])
|
2020-04-26 11:46:43 -07:00
|
|
|
except TemplateNotFound:
|
|
|
|
|
template = None
|
2020-09-13 19:33:55 -07:00
|
|
|
if template is None:
|
|
|
|
|
# Try for a pages/blah/{name}.html template match
|
2024-01-05 14:33:23 -08:00
|
|
|
for regex, wildcard_template in page_routes:
|
2021-06-05 11:59:54 -07:00
|
|
|
match = regex.match(route_path)
|
2020-09-13 19:33:55 -07:00
|
|
|
if match is not None:
|
|
|
|
|
context.update(match.groupdict())
|
|
|
|
|
template = wildcard_template
|
|
|
|
|
break
|
|
|
|
|
|
2020-04-26 11:46:43 -07:00
|
|
|
if template:
|
|
|
|
|
headers = {}
|
|
|
|
|
status = [200]
|
|
|
|
|
|
|
|
|
|
def custom_header(name, value):
|
|
|
|
|
headers[name] = value
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def custom_status(code):
|
|
|
|
|
status[0] = code
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def custom_redirect(location, code=302):
|
|
|
|
|
status[0] = code
|
|
|
|
|
headers["Location"] = location
|
|
|
|
|
return ""
|
|
|
|
|
|
2020-09-14 10:39:13 -07:00
|
|
|
def raise_404(message=""):
|
|
|
|
|
raise NotFoundExplicit(message)
|
|
|
|
|
|
2020-09-13 19:33:55 -07:00
|
|
|
context.update(
|
2020-04-26 11:46:43 -07:00
|
|
|
{
|
|
|
|
|
"custom_header": custom_header,
|
|
|
|
|
"custom_status": custom_status,
|
|
|
|
|
"custom_redirect": custom_redirect,
|
2020-09-14 10:39:13 -07:00
|
|
|
"raise_404": raise_404,
|
2020-09-13 19:33:55 -07:00
|
|
|
}
|
|
|
|
|
)
|
2020-09-14 10:39:13 -07:00
|
|
|
try:
|
|
|
|
|
body = await self.ds.render_template(
|
|
|
|
|
template,
|
|
|
|
|
context,
|
|
|
|
|
request=request,
|
|
|
|
|
view_name="page",
|
|
|
|
|
)
|
|
|
|
|
except NotFoundExplicit as e:
|
2022-07-17 15:24:16 -07:00
|
|
|
await self.handle_exception(request, send, e)
|
2020-09-14 10:39:13 -07:00
|
|
|
return
|
2020-04-26 11:46:43 -07:00
|
|
|
# Pull content-type out into separate parameter
|
2020-05-04 10:41:58 -07:00
|
|
|
content_type = "text/html; charset=utf-8"
|
2020-04-26 11:46:43 -07:00
|
|
|
matches = [k for k in headers if k.lower() == "content-type"]
|
|
|
|
|
if matches:
|
|
|
|
|
content_type = headers[matches[0]]
|
|
|
|
|
await asgi_send(
|
|
|
|
|
send,
|
|
|
|
|
body,
|
|
|
|
|
status=status[0],
|
|
|
|
|
headers=headers,
|
|
|
|
|
content_type=content_type,
|
|
|
|
|
)
|
|
|
|
|
else:
|
2022-07-17 15:24:16 -07:00
|
|
|
await self.handle_exception(request, send, exception or NotFound("404"))
|
2019-06-23 20:13:09 -07:00
|
|
|
|
2022-07-17 15:24:16 -07:00
|
|
|
async def handle_exception(self, request, send, exception):
|
2022-07-17 16:24:39 -07:00
|
|
|
responses = []
|
|
|
|
|
for hook in pm.hook.handle_exception(
|
|
|
|
|
datasette=self.ds,
|
|
|
|
|
request=request,
|
|
|
|
|
exception=exception,
|
|
|
|
|
):
|
|
|
|
|
response = await await_me_maybe(hook)
|
|
|
|
|
if response is not None:
|
|
|
|
|
responses.append(response)
|
|
|
|
|
|
|
|
|
|
assert responses, "Default exception handler should have returned something"
|
|
|
|
|
# Even if there are multiple responses use just the first one
|
|
|
|
|
response = responses[0]
|
|
|
|
|
await response.asgi_send(send)
|
2019-12-04 22:46:39 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_cleaner_task_str_re = re.compile(r"\S*site-packages/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cleaner_task_str(task):
|
|
|
|
|
s = str(task)
|
|
|
|
|
# This has something like the following in it:
|
|
|
|
|
# running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361>
|
|
|
|
|
# Clean up everything up to and including site-packages
|
|
|
|
|
return _cleaner_task_str_re.sub("", s)
|
2020-06-08 20:12:06 -07:00
|
|
|
|
|
|
|
|
|
2023-05-25 17:18:43 -07:00
|
|
|
def wrap_view(view_fn_or_class, datasette):
|
|
|
|
|
is_function = isinstance(view_fn_or_class, types.FunctionType)
|
|
|
|
|
if is_function:
|
|
|
|
|
return wrap_view_function(view_fn_or_class, datasette)
|
|
|
|
|
else:
|
|
|
|
|
if not isinstance(view_fn_or_class, type):
|
|
|
|
|
raise ValueError("view_fn_or_class must be a function or a class")
|
|
|
|
|
return wrap_view_class(view_fn_or_class, datasette)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wrap_view_class(view_class, datasette):
|
|
|
|
|
async def async_view_for_class(request, send):
|
|
|
|
|
instance = view_class()
|
|
|
|
|
if inspect.iscoroutinefunction(instance.__call__):
|
|
|
|
|
return await async_call_with_supported_arguments(
|
|
|
|
|
instance.__call__,
|
|
|
|
|
scope=request.scope,
|
|
|
|
|
receive=request.receive,
|
|
|
|
|
send=send,
|
|
|
|
|
request=request,
|
|
|
|
|
datasette=datasette,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
return call_with_supported_arguments(
|
|
|
|
|
instance.__call__,
|
|
|
|
|
scope=request.scope,
|
|
|
|
|
receive=request.receive,
|
|
|
|
|
send=send,
|
|
|
|
|
request=request,
|
|
|
|
|
datasette=datasette,
|
|
|
|
|
)
|
|
|
|
|
|
2023-08-07 18:47:39 -07:00
|
|
|
async_view_for_class.view_class = view_class
|
2023-05-25 17:18:43 -07:00
|
|
|
return async_view_for_class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wrap_view_function(view_fn, datasette):
|
2021-11-18 19:19:43 -08:00
|
|
|
@functools.wraps(view_fn)
|
2020-06-28 17:01:33 -07:00
|
|
|
async def async_view_fn(request, send):
|
2020-06-27 11:30:34 -07:00
|
|
|
if inspect.iscoroutinefunction(view_fn):
|
|
|
|
|
response = await async_call_with_supported_arguments(
|
|
|
|
|
view_fn,
|
2020-06-28 17:01:33 -07:00
|
|
|
scope=request.scope,
|
|
|
|
|
receive=request.receive,
|
2020-06-27 11:30:34 -07:00
|
|
|
send=send,
|
2020-06-28 17:01:33 -07:00
|
|
|
request=request,
|
2020-06-27 11:30:34 -07:00
|
|
|
datasette=datasette,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
response = call_with_supported_arguments(
|
|
|
|
|
view_fn,
|
2020-06-28 17:01:33 -07:00
|
|
|
scope=request.scope,
|
|
|
|
|
receive=request.receive,
|
2020-06-27 11:30:34 -07:00
|
|
|
send=send,
|
2020-06-28 17:01:33 -07:00
|
|
|
request=request,
|
2020-06-27 11:30:34 -07:00
|
|
|
datasette=datasette,
|
|
|
|
|
)
|
2020-06-08 20:12:06 -07:00
|
|
|
if response is not None:
|
2020-06-28 17:25:35 -07:00
|
|
|
return response
|
2020-06-08 20:12:06 -07:00
|
|
|
|
2020-06-28 17:01:33 -07:00
|
|
|
return async_view_fn
|
2020-09-13 19:33:55 -07:00
|
|
|
|
|
|
|
|
|
2021-01-28 14:48:56 -08:00
|
|
|
def permanent_redirect(path, forward_query_string=False, forward_rest=False):
|
2020-11-24 12:19:14 -08:00
|
|
|
return wrap_view(
|
2021-01-28 14:48:56 -08:00
|
|
|
lambda request, send: Response.redirect(
|
|
|
|
|
path
|
|
|
|
|
+ (request.url_vars["rest"] if forward_rest else "")
|
|
|
|
|
+ (
|
|
|
|
|
("?" + request.query_string)
|
|
|
|
|
if forward_query_string and request.query_string
|
|
|
|
|
else ""
|
|
|
|
|
),
|
|
|
|
|
status=301,
|
|
|
|
|
),
|
2020-11-24 12:19:14 -08:00
|
|
|
datasette=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2020-12-23 18:04:32 +01:00
|
|
|
_curly_re = re.compile(r"({.*?})")
|
2020-09-13 19:33:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def route_pattern_from_filepath(filepath):
|
|
|
|
|
# Drop the ".html" suffix
|
|
|
|
|
if filepath.endswith(".html"):
|
|
|
|
|
filepath = filepath[: -len(".html")]
|
|
|
|
|
re_bits = ["/"]
|
|
|
|
|
for bit in _curly_re.split(filepath):
|
|
|
|
|
if _curly_re.match(bit):
|
2020-11-15 15:24:22 -08:00
|
|
|
re_bits.append(f"(?P<{bit[1:-1]}>[^/]*)")
|
2020-09-13 19:33:55 -07:00
|
|
|
else:
|
|
|
|
|
re_bits.append(re.escape(bit))
|
2020-10-07 15:51:11 -07:00
|
|
|
return re.compile("^" + "".join(re_bits) + "$")
|
2020-09-14 10:39:13 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotFoundExplicit(NotFound):
|
|
|
|
|
pass
|
2020-10-09 09:11:24 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class DatasetteClient:
|
2025-11-05 13:38:01 -08:00
|
|
|
"""Internal HTTP client for making requests to a Datasette instance.
|
|
|
|
|
|
|
|
|
|
Used for testing and for internal operations that need to make HTTP requests
|
|
|
|
|
to the Datasette app without going through an actual HTTP server.
|
|
|
|
|
"""
|
|
|
|
|
|
2020-10-09 09:11:24 -07:00
|
|
|
def __init__(self, ds):
|
2020-10-31 12:29:42 -07:00
|
|
|
self.ds = ds
|
2025-11-13 10:31:03 -08:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def app(self):
|
|
|
|
|
return self.ds.app()
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2022-12-15 14:18:40 -08:00
|
|
|
def actor_cookie(self, actor):
|
|
|
|
|
# Utility method, mainly for tests
|
|
|
|
|
return self.ds.sign({"a": actor}, "actor")
|
|
|
|
|
|
2021-06-05 11:59:54 -07:00
|
|
|
def _fix(self, path, avoid_path_rewrites=False):
|
|
|
|
|
if not isinstance(path, PrefixedUrlString) and not avoid_path_rewrites:
|
2020-10-31 12:29:42 -07:00
|
|
|
path = self.ds.urls.path(path)
|
2020-10-09 09:11:24 -07:00
|
|
|
if path.startswith("/"):
|
2020-11-15 15:24:22 -08:00
|
|
|
path = f"http://localhost{path}"
|
2020-10-09 09:11:24 -07:00
|
|
|
return path
|
|
|
|
|
|
2026-04-14 18:31:57 -07:00
|
|
|
def _apply_actor(self, kwargs):
|
|
|
|
|
"""If ``actor=`` was supplied, convert it into a signed ds_actor cookie."""
|
|
|
|
|
actor = kwargs.pop("actor", None)
|
|
|
|
|
if actor is None:
|
|
|
|
|
return
|
|
|
|
|
cookies = dict(kwargs.get("cookies") or {})
|
|
|
|
|
if "ds_actor" in cookies:
|
|
|
|
|
raise TypeError("Cannot pass both actor= and a ds_actor cookie")
|
|
|
|
|
cookies["ds_actor"] = self.actor_cookie(actor)
|
|
|
|
|
kwargs["cookies"] = cookies
|
|
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def _request(self, method, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
from datasette.permissions import SkipPermissions
|
|
|
|
|
|
2026-04-14 18:31:57 -07:00
|
|
|
self._apply_actor(kwargs)
|
2025-11-13 09:56:06 -08:00
|
|
|
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:
|
2025-11-05 13:38:01 -08:00
|
|
|
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(
|
|
|
|
|
"get", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def options(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"options", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
2024-03-15 15:29:03 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def head(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"head", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def post(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"post", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def put(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"put", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def patch(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"patch", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def delete(self, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
return await self._request(
|
|
|
|
|
"delete", path, skip_permission_checks=skip_permission_checks, **kwargs
|
|
|
|
|
)
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
async def request(self, method, path, skip_permission_checks=False, **kwargs):
|
|
|
|
|
"""Make an HTTP request with the specified method.
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
Args:
|
|
|
|
|
method: HTTP method (e.g., "GET", "POST", "PUT")
|
|
|
|
|
path: The path to request
|
|
|
|
|
skip_permission_checks: If True, bypass all permission checks for this request
|
|
|
|
|
**kwargs: Additional arguments to pass to httpx
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2025-11-05 13:38:01 -08:00
|
|
|
Returns:
|
|
|
|
|
httpx.Response: The response from the request
|
|
|
|
|
"""
|
|
|
|
|
from datasette.permissions import SkipPermissions
|
2020-10-09 09:11:24 -07:00
|
|
|
|
2021-06-05 11:59:54 -07:00
|
|
|
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
|
2026-04-14 18:31:57 -07:00
|
|
|
self._apply_actor(kwargs)
|
2025-11-13 09:56:06 -08:00
|
|
|
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:
|
2025-11-05 13:38:01 -08:00
|
|
|
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
|
|
|
|
|
)
|