Compare commits

..

2 commits

Author SHA1 Message Date
Alex Garcia
28d811320b Version-agnostic CodeMirror bundle filenames, rollup.config.mjs build
cm-editor-6.0.1.{js,bundle.js} -> cm-editor.{js,bundle.js}; new
npm run build:codemirror replaces the documented rollup one-liner.
Cache busting already handled by the static() template helper's
?_hash= content hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:44:58 -07:00
Alex Garcia
79f6ac3f47 Upgrade CodeMirror to latest 6.x, fix sql() option names
codemirror 6.0.1 -> 6.0.2, @codemirror/lang-sql 6.3.3 -> 6.10.0
(autocomplete 6.20.3, view 6.43.6, state 6.7.1).
defaultTableName/defaultSchemaName were never valid SQLConfig options;
the real names are defaultTable/defaultSchema. Also enables
caseInsensitiveIdentifiers on the SQLite dialect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:39:42 -07:00
177 changed files with 2736 additions and 7098 deletions

View file

@ -14,46 +14,24 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check deployment prerequisites
id: deployment-prerequisites
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
run: |
missing=()
for variable in GCP_SA_KEY LATEST_DATASETTE_SECRET; do
if [[ -z "${!variable:-}" ]]; then
missing+=("$variable")
fi
done
if (( ${#missing[@]} )); then
echo "::notice::Skipping deployment because required environment variables are missing: ${missing[*]}"
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Check out datasette
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: actions/checkout@v7
- name: Set up Python
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip
- name: Install Python dependencies
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
python -m pip install --upgrade pip
python -m pip install . --group dev
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
python -m pip install sphinx-to-sqlite==0.1a1
- name: Run tests
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
if: ${{ github.ref == 'refs/heads/main' }}
run: |
pytest -n auto -m "not serial"
pytest -m "serial"
- name: Build fixtures.db and other files needed to deploy the demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |-
python tests/fixtures.py \
fixtures.db \
@ -62,14 +40,13 @@ jobs:
plugins \
--extra-db-filename extra_database.db
- name: Build docs.db
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
if: ${{ github.ref == 'refs/heads/main' }}
run: |-
cd docs
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
sphinx-to-sqlite ../docs.db _build
cd ..
- name: Set up the alternate-route demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
echo '
from datasette import hookimpl
@ -81,7 +58,6 @@ jobs:
' > plugins/alternative_route.py
cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
cat > plugins/counters.py <<EOF
from datasette import hookimpl
@ -121,15 +97,12 @@ jobs:
# cat metadata.json
- id: auth
name: Authenticate to Google Cloud
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: google-github-actions/auth@v3
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: google-github-actions/setup-gcloud@v3
- name: Deploy to Cloud Run
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
env:
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
run: |-
@ -148,12 +121,12 @@ jobs:
--install 'datasette-ephemeral-tables>=0.2.2' \
--service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET
- name: Upload latest documentation database to S3 (only for main)
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
- name: Deploy to docs as well (only for main)
if: ${{ github.ref == 'refs/heads/main' }}
run: |-
# Keep development documentation separate from the stable release database.
s3-credentials put-object datasette-docs latest/docs.db docs.db \
--content-type application/octet-stream
# Deploy docs.db to a different service
datasette publish cloudrun docs.db \
--branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1" \
--service=datasette-docs-latest

View file

@ -2,7 +2,7 @@ name: Publish Python Package
on:
release:
types: [published]
types: [created]
permissions:
contents: read
@ -51,8 +51,6 @@ jobs:
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
# After the first non-prerelease 1.0 release, disable this job on 0.65.x,
# even for later 0.65 releases, so they cannot overwrite the 1.0 stable docs.
deploy_static_docs:
runs-on: ubuntu-latest
needs: [deploy]
@ -68,20 +66,26 @@ jobs:
- name: Install dependencies
run: |
python -m pip install . --group dev
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
python -m pip install sphinx-to-sqlite==0.1a1
- name: Build docs.db
run: |-
cd docs
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
sphinx-to-sqlite ../docs.db _build
cd ..
- name: Upload stable documentation database to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
- id: auth
name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v3
- name: Deploy stable-docs.datasette.io to Cloud Run
run: |-
s3-credentials put-object datasette-docs docs.db docs.db \
--content-type application/octet-stream
gcloud config set run/region us-central1
gcloud config set project datasette-222320
datasette publish cloudrun docs.db \
--service=datasette-docs-stable
deploy_docker:
runs-on: ubuntu-latest

View file

@ -11,17 +11,16 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: pip
cache-dependency-path: pyproject.toml
check-latest: true
- name: Build extension for --load-extension test
run: |-
(cd tests && gcc ext.c -fPIC -shared -o ext.so)

View file

@ -1,4 +1,4 @@
FROM python:3.11-slim-bookworm AS build
FROM python:3.11.0-slim-bullseye as build
# Version of Datasette to install, e.g. 0.55
# docker build . -t datasette --build-arg VERSION=0.55

View file

@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
continue
try:
ds.close()
except Exception as e: # noqa: BLE001
# Surfaced as a pytest warning; teardown must not fail the run
except Exception as e:
item.warn(
pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}"

View file

@ -1,9 +1,7 @@
import time
from itsdangerous import BadSignature
from datasette import hookimpl
from itsdangerous import BadSignature
from datasette.utils import baseconv
import time
@hookimpl

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import hashlib
from datasette import hookimpl
from datasette.utils.asgi import Response, BadRequest
from datasette.utils import to_css_class
from datasette.utils.asgi import BadRequest, Response
import hashlib
_BLOB_COLUMN = "_blob_column"
_BLOB_HASH = "_blob_hash"

View file

@ -1,45 +1,43 @@
import asyncio
import uvicorn
import click
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
import functools
import json
import os
import pathlib
from runpy import run_module
import shutil
from subprocess import call
import sys
import textwrap
import webbrowser
from runpy import run_module
from subprocess import call
import click
import uvicorn
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
from .app import (
Datasette,
DEFAULT_SETTINGS,
SETTINGS,
SQLITE_LIMIT_ATTACHED,
Datasette,
pm,
)
from .inspect import inspect_tables
from .utils import (
ConnectionProblem,
LoadExtension,
SpatialiteConnectionProblem,
SpatialiteNotFound,
StartupError,
StaticMount,
ValueAsBooleanError,
check_connection,
deep_dict_update,
find_spatialite,
parse_metadata,
ConnectionProblem,
SpatialiteConnectionProblem,
initial_path_for_datasette,
pairs_to_nested_config,
parse_metadata,
temporary_docker_directory,
value_as_boolean,
SpatialiteNotFound,
StaticMount,
ValueAsBooleanError,
)
from .utils.sqlite import sqlite3
from .utils.testing import TestClient
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
# Datasette 1.0, we turn bare setting names into setting.name
# Type checking for those older settings
default = DEFAULT_SETTINGS[name]
name = f"settings.{name}"
name = "settings.{}".format(name)
if isinstance(default, bool):
try:
return name, "true" if value_as_boolean(value) else "false"
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
@cli.group()
def publish():
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
pass
# Register publish plugins
@ -579,27 +578,27 @@ def serve(
# https://github.com/simonw/datasette/issues/2389
deep_dict_update(config_data, settings_updates)
kwargs = {
"immutables": immutable,
"cache_headers": not reload,
"cors": cors,
"inspect_data": inspect_data,
"config": config_data,
"metadata": metadata_data,
"sqlite_extensions": sqlite_extensions,
"template_dir": template_dir,
"plugins_dir": plugins_dir,
"static_mounts": static,
"settings": None, # These are passed in config= now
"memory": memory,
"secret": secret,
"version_note": version_note,
"pdb": pdb,
"crossdb": crossdb,
"nolock": nolock,
"internal": internal,
"default_deny": default_deny,
}
kwargs = dict(
immutables=immutable,
cache_headers=not reload,
cors=cors,
inspect_data=inspect_data,
config=config_data,
metadata=metadata_data,
sqlite_extensions=sqlite_extensions,
template_dir=template_dir,
plugins_dir=plugins_dir,
static_mounts=static,
settings=None, # These are passed in config= now
memory=memory,
secret=secret,
version_note=version_note,
pdb=pdb,
crossdb=crossdb,
nolock=nolock,
internal=internal,
default_deny=default_deny,
)
# Separate directories from files
directories = [f for f in files if os.path.isdir(f)]
@ -622,7 +621,9 @@ def serve(
conn.close()
else:
raise click.ClickException(
f"Invalid value for '[FILES]...': Path '{file}' does not exist."
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
file
)
)
# Check for duplicate files by resolving all paths to their absolute forms
@ -663,6 +664,16 @@ def serve(
# Private utility mechanism for writing unit tests
return ds
# Run async soundness checks before startup hooks, since invoke_startup
# now populates internal tables which requires querying each database
run_sync(lambda: check_databases(ds))
# Run the "startup" plugin hooks
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
if headers and not get:
raise click.ClickException("--headers can only be used with --get")
@ -670,18 +681,10 @@ def serve(
raise click.ClickException("--token can only be used with --get")
if get:
# --get means we don't run Uvicorn at all
run_sync(lambda: check_databases(ds))
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
client = TestClient(ds)
request_headers = {}
if token:
request_headers["Authorization"] = f"Bearer {token}"
request_headers["Authorization"] = "Bearer {}".format(token)
cookies = {}
if actor:
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
@ -702,54 +705,30 @@ def serve(
sys.exit(exit_code)
return
# check_databases, invoke_startup() and the uvicorn server all run on a
# single event loop, so that anything a plugin's "startup" hook schedules
# on the loop (asyncio.create_task, Lock/Queue/Event objects, ...) is
# still alive when the server starts handling requests.
async def _serve_async():
# Populate internal catalog tables before invoke_startup
await check_databases(ds)
# Run the full startup sequence (immutable-database table-count
# precompute + the "startup" plugin hooks) via the same entry point
# AsgiLifespan/AsgiRunOnFirstRequest use, so it's not skipped when
# uvicorn's lifespan.startup fires moments later.
try:
await ds._startup_sequence()
except StartupError as e:
raise click.ClickException(e.args[0])
# Start the server
url = None
if root:
ds.root_enabled = True
url = "http://{}:{}{}?token={}".format(
host, port, ds.urls.path("-/auth-token"), ds._root_token
)
click.echo(url)
if open_browser:
if url is None:
# Figure out most convenient URL - to table, database or homepage
path = await initial_path_for_datasette(ds)
url = f"http://{host}:{port}{path}"
webbrowser.open(url)
uvicorn_kwargs = {
"host": host,
"port": port,
"log_level": "info",
"lifespan": "on",
"workers": 1,
}
if uds:
uvicorn_kwargs["uds"] = uds
if ssl_keyfile:
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
if ssl_certfile:
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
await server.serve()
asyncio.run(_serve_async())
# Start the server
url = None
if root:
ds.root_enabled = True
url = "http://{}:{}{}?token={}".format(
host, port, ds.urls.path("-/auth-token"), ds._root_token
)
click.echo(url)
if open_browser:
if url is None:
# Figure out most convenient URL - to table, database or homepage
path = run_sync(lambda: initial_path_for_datasette(ds))
url = f"http://{host}:{port}{path}"
webbrowser.open(url)
uvicorn_kwargs = dict(
host=host, port=port, log_level="info", lifespan="on", workers=1
)
if uds:
uvicorn_kwargs["uds"] = uds
if ssl_keyfile:
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
if ssl_certfile:
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
uvicorn.run(ds.app(), **uvicorn_kwargs)
@cli.command()
@ -906,7 +885,7 @@ async def check_databases(ds):
)
except ConnectionProblem as e:
raise click.UsageError(
f"Connection to {database.path} failed check: {e.args[0]!s}"
f"Connection to {database.path} failed check: {str(e.args[0])}"
)
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
if (
@ -914,5 +893,9 @@ async def check_databases(ds):
and len([db for db in ds.databases.values() if not db.is_memory])
> SQLITE_LIMIT_ATTACHED
):
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases"
msg = (
"Warning: --crossdb only works with the first {} attached databases".format(
SQLITE_LIMIT_ATTACHED
)
)
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)

View file

@ -64,14 +64,14 @@ class ColumnType:
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
"""
return
return None
async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return
return None
async def transform_value(self, value, datasette):
"""

View file

@ -40,12 +40,12 @@ def _origin_tuple(value):
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
if not scheme or not host:
raise ValueError(f"missing scheme or host in {value!r}")
raise ValueError("missing scheme or host in {!r}".format(value))
port = parsed.port # may raise ValueError on bad ports
if port is None:
port = DEFAULT_PORTS.get(scheme)
if port is None:
raise ValueError(f"unknown default port for scheme {scheme!r}")
raise ValueError("unknown default port for scheme {!r}".format(scheme))
return scheme, host, port
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
return
await self._forbid(
send,
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'",
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
)
return
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
request_scheme = self._request_scheme(scope)
try:
origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
except ValueError:
await self._forbid(
send,
f"Malformed Origin {origin!r} or Host {host!r}",
"Malformed Origin {!r} or Host {!r}".format(origin, host),
)
return
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
await self._forbid(
send,
f"Origin {origin!r} does not match Host {host!r}",
"Origin {!r} does not match Host {!r}".format(origin, host),
)
def _request_scheme(self, scope):
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
try:
if self.datasette.setting("force_https_urls"):
return "https"
except Exception: # noqa: BLE001, S110
# Settings may not be readable this early; fall back to the ASGI scheme
except Exception:
pass
return scope.get("scheme") or "http"

View file

@ -1,35 +1,33 @@
import asyncio
import atexit
from collections import namedtuple
import inspect
import os
from pathlib import Path
import queue
import sqlite_utils
import sys
import tempfile
import threading
import uuid
from collections import namedtuple
from pathlib import Path
import sqlite_utils
from .inspect import inspect_hash
from .tracer import trace
from .utils import (
call_with_supported_arguments,
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
get_outbound_foreign_keys,
md5_not_usedforsecurity,
sqlite3,
sqlite_timelimit,
table_column_details,
sqlite3,
table_columns,
table_column_details,
)
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_derived_table_dependencies, sqlite_hidden_table_names
from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash
connections = threading.local()
@ -85,7 +83,6 @@ class Database:
self.cached_hash = None
self.cached_size = None
self._cached_table_counts = None
self._cached_derived_table_dependencies = None
self._write_thread = None
self._write_queue = None
self._closed = False
@ -101,7 +98,9 @@ class Database:
def _check_not_closed(self):
if self._closed:
raise DatasetteClosedError(f"Database {self.name!r} has been closed")
raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock:
@ -140,7 +139,7 @@ class Database:
if write:
extra_kwargs["isolation_level"] = "IMMEDIATE"
if self.memory_name:
uri = f"file:{self.memory_name}?mode=memory&cache=shared"
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
conn = sqlite3.connect(
uri, uri=True, check_same_thread=False, **extra_kwargs
)
@ -193,20 +192,21 @@ class Database:
write_thread.join(timeout=10)
if write_thread.is_alive():
sys.stderr.write(
f"Datasette: write thread for {self.name!r} did not exit within 10s\n"
"Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
)
sys.stderr.flush()
for future in pending_execute_futures:
try:
future.result()
except Exception: # noqa: BLE001, S110
# Shutdown teardown - a failed pending write must not block close()
except Exception:
pass
# Close anything still tracked in _all_file_connections
for connection in self._all_file_connections:
try:
connection.close()
except Exception: # noqa: BLE001, S110
except Exception:
pass
self._all_file_connections = []
# Drop per-thread cached read connections we can reach
@ -218,13 +218,13 @@ class Database:
if self._read_connection is not None:
try:
self._read_connection.close()
except Exception: # noqa: BLE001, S110
except Exception:
pass
self._read_connection = None
if self._write_connection is not None:
try:
self._write_connection.close()
except Exception: # noqa: BLE001, S110
except Exception:
pass
self._write_connection = None
if self.is_temp_disk:
@ -246,34 +246,19 @@ class Database:
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
time_limit_ms=2000,
):
self._check_not_closed()
if returning_limit < 0:
raise ValueError("returning_limit must be >= 0")
def execute_sql(conn):
def _inner(conn):
cursor = conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
def _inner(conn):
try:
if time_limit_ms is None:
return execute_sql(conn)
with sqlite_timelimit(conn, time_limit_ms):
return execute_sql(conn)
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
if e.args == ("interrupted",):
raise QueryInterrupted(e, sql, params)
raise
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(
_inner, block=block, request=request, transaction=transaction
)
results = await self.execute_write_fn(_inner, block=block, request=request)
return results
async def execute_write_script(self, sql, block=True, request=None):
@ -363,19 +348,9 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name)
if transaction:
with self._write_connection:
self._write_connection.execute("BEGIN IMMEDIATE")
result = fn(self._write_connection)
else:
result = fn(self._write_connection)
if not block:
# There is no write thread here, so the write has already
# finished. Hand back the same (task_id, reply_future) shape
# _send_to_write_thread() returns, with the future already
# resolved, so the block=False path below is identical in
# both modes.
reply_future = asyncio.get_running_loop().create_future()
reply_future.set_result(result)
result = (uuid.uuid4(), reply_future)
else:
result = await self._send_to_write_thread(
fn, block=block, transaction=transaction
@ -391,8 +366,7 @@ class Database:
async def _dispatch_events_after_write():
try:
await reply_future
except Exception: # noqa: BLE001
# The write failed; skip success events regardless of why
except Exception:
# if the write failed, don't emit success events
return
for event in pending_events:
@ -445,9 +419,11 @@ class Database:
self._write_thread = threading.Thread(
target=self._execute_writes, daemon=True
)
self._write_thread.name = f"_execute_writes for database {self.name}"
self._write_thread.name = "_execute_writes for database {}".format(
self.name
)
self._write_thread.start()
task_id = uuid.uuid4()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop()
reply_future = loop.create_future()
self._write_queue.put(
@ -466,8 +442,7 @@ class Database:
try:
conn = self.connect(write=True)
self.ds._prepare_connection(conn, self.name)
except Exception as e: # noqa: BLE001
# Stored and re-raised to whoever queues the next write
except Exception as e:
conn_exception = e
while True:
task = self._write_queue.get()
@ -475,8 +450,7 @@ class Database:
if conn is not None:
try:
conn.close()
except Exception: # noqa: BLE001, S110
# Best-effort close as the write thread exits
except Exception:
pass
return
exception = None
@ -495,21 +469,19 @@ class Database:
except ValueError:
# Was probably a memory connection
pass
except Exception as e: # noqa: BLE001
# Write thread must survive any task failure or the database wedges
sys.stderr.write(f"{e}\n")
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
else:
try:
if task.transaction:
with conn:
conn.execute("BEGIN IMMEDIATE")
result = task.fn(conn)
else:
result = task.fn(conn)
except Exception as e: # noqa: BLE001
sys.stderr.write(f"{e}\n")
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
@ -576,7 +548,9 @@ class Database:
raise QueryInterrupted(e, sql, params)
if log_sql_errors:
sys.stderr.write(
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
"ERROR: conn={}, sql = {}, params = {}: {}\n".format(
conn, repr(sql), params, e
)
)
sys.stderr.flush()
raise
@ -629,7 +603,7 @@ class Database:
try:
table_count = (
await self.execute(
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
custom_time_limit=limit,
)
).rows[0][0]
@ -733,9 +707,9 @@ class Database:
column_names
and len(column_names) == 2
and ("id" in column_names or "pk" in column_names)
and set(column_names) != {"id", "pk"}
and not set(column_names) == {"id", "pk"}
):
return next(c for c in column_names if c not in ("id", "pk"))
return [c for c in column_names if c not in ("id", "pk")][0]
# Couldn't find a label:
return None
@ -781,17 +755,6 @@ class Database:
return hidden_tables
async def derived_table_dependencies(self):
"""Return implementation tables and the tables they derive from."""
schema_version = (await self.execute("PRAGMA schema_version")).first()[0]
if (
self._cached_derived_table_dependencies is None
or self._cached_derived_table_dependencies[0] != schema_version
):
dependencies = await self.execute_fn(sqlite_derived_table_dependencies)
self._cached_derived_table_dependencies = (schema_version, dependencies)
return self._cached_derived_table_dependencies[1]
async def view_names(self):
results = await self.execute("select name from sqlite_master where type='view'")
return [r[0] for r in results.rows]
@ -888,10 +851,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask:
__slots__ = (
"fn",
"isolated_connection",
"task_id",
"loop",
"reply_future",
"task_id",
"isolated_connection",
"transaction",
)
@ -932,7 +895,7 @@ class QueryInterrupted(Exception):
self.params = params
def __str__(self):
return f"QueryInterrupted: {self.e}"
return "QueryInterrupted: {}".format(self.e)
class MultipleValues(Exception):

View file

@ -2,8 +2,8 @@ from datasette import hookimpl
from datasette.permissions import Action
from datasette.resources import (
DatabaseResource,
QueryResource,
TableResource,
QueryResource,
)

View file

@ -6,17 +6,6 @@ import markupsafe
from datasette import hookimpl
from datasette.column_types import ColumnType, SQLiteType
_HTTP_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
def _normalize_http_url(value):
if not isinstance(value, str):
return None
normalized = value.strip()
if not _HTTP_URL_RE.fullmatch(normalized):
return None
return normalized
class UrlColumnType(ColumnType):
name = "url"
@ -26,10 +15,7 @@ class UrlColumnType(ColumnType):
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
normalized = _normalize_http_url(value)
if normalized is None:
return markupsafe.escape(value.strip())
escaped = markupsafe.escape(normalized)
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
@ -37,7 +23,7 @@ class UrlColumnType(ColumnType):
return None
if not isinstance(value, str):
return "URL must be a string"
if _normalize_http_url(value) is None:
if not re.match(r"^https?://\S+$", value.strip()):
return "Invalid URL"
return None

View file

@ -1,9 +1,8 @@
from datasette import hookimpl
import datetime
import os
import time
from datasette import hookimpl
def header(key, request):
key = key.replace("_", "-").encode("utf-8")

View file

@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
from .defaults import (
default_action_permissions_sql as default_action_permissions_sql,
)
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
)
from .defaults import (
default_query_permissions_sql as default_query_permissions_sql,
)
from .restrictions import (
ActorRestrictions as ActorRestrictions,
)
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
)
from .restrictions import (
restrictions_allow_action as restrictions_allow_action,
ActorRestrictions as ActorRestrictions,
)
from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)

View file

@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
def __init__(
self,
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
):
self.datasette = datasette
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency
self.restricted_databases: set[str] = set()
self.restricted_tables: set[tuple[str, str]] = set()
self.restricted_databases: Set[str] = set()
self.restricted_tables: Set[Tuple[str, str]] = set()
if self.has_restrictions:
self.restricted_databases = {
@ -92,27 +92,16 @@ class ConfigPermissionProcessor:
# Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables)
# Resolve identity keys once per action, rather than scanning the
# restriction allowlist for every configured table's allow block.
self.restricted_table_keys = {
(db, self.action_obj.normalize_child(table) if self.action_obj else table)
for db, table in self.restricted_tables
}
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
# integers, but should retain the CLI's boolean 1/0 behavior.
if isinstance(allow_block, int) and allow_block in (0, 1):
return bool(allow_block)
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(
self,
parent: str | None,
child: str | None,
parent: Optional[str],
child: Optional[str],
) -> bool:
"""Check if resource is allowed by actor restrictions."""
if not self.has_restrictions:
@ -132,10 +121,8 @@ class ConfigPermissionProcessor:
if parent:
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
if child:
child_key = (
self.action_obj.normalize_child(child) if self.action_obj else child
)
if (parent, child_key) in self.restricted_table_keys:
table_actions = table_restrictions.get(child, [])
if self.action_checks.intersection(table_actions):
return True
else:
# Parent query should proceed if any child in this database is allowlisted
@ -156,9 +143,9 @@ class ConfigPermissionProcessor:
def add_permissions_rule(
self,
parent: str | None,
child: str | None,
permissions_block: dict | None,
parent: Optional[str],
child: Optional[str],
permissions_block: Optional[dict],
scope_desc: str,
) -> None:
"""Add a rule from a permissions:{action} block."""
@ -178,8 +165,8 @@ class ConfigPermissionProcessor:
def add_allow_block_rule(
self,
parent: str | None,
child: str | None,
parent: Optional[str],
child: Optional[str],
allow_block: Any,
scope_desc: str,
) -> None:
@ -211,8 +198,8 @@ class ConfigPermissionProcessor:
def _add_restriction_gate_denies(
self,
parent: str | None,
child: str | None,
parent: Optional[str],
child: Optional[str],
is_allowed: bool,
scope_desc: str,
) -> None:
@ -244,7 +231,7 @@ class ConfigPermissionProcessor:
if db_name == parent:
self.collector.add(db_name, table_name, False, reason)
def process(self) -> PermissionSQL | None:
def process(self) -> Optional[PermissionSQL]:
"""Process all config rules and return combined PermissionSQL."""
self._process_root_permissions()
self._process_databases()
@ -434,10 +421,10 @@ class ConfigPermissionProcessor:
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> list[PermissionSQL] | None:
) -> Optional[List[PermissionSQL]]:
"""
Apply permission rules from datasette.yaml configuration.

View file

@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> PermissionSQL | None:
) -> Optional[PermissionSQL]:
"""
Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules.
"""
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
if action == "execute-sql":
if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> PermissionSQL | None:
) -> Optional[PermissionSQL]:
"""
Provide default allow rules for standard view/execute actions.
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
@hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql(
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> PermissionSQL | None:
) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}:

View file

@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List, Optional, Set
if TYPE_CHECKING:
from datasette.app import Datasette
@ -13,7 +13,7 @@ if TYPE_CHECKING:
from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
"""
Get all name variants for an action (full name and abbreviation).
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
return variants
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool:
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list))
@ -36,8 +36,8 @@ def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool
class PermissionRow:
"""A single permission rule row."""
parent: str | None
child: str | None
parent: Optional[str]
child: Optional[str]
allow: bool
reason: str
@ -46,14 +46,14 @@ class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"):
self.rows: list[PermissionRow] = []
self.rows: List[PermissionRow] = []
self.prefix = prefix
def add(
self,
parent: str | None,
child: str | None,
allow: bool | None,
parent: Optional[str],
child: Optional[str],
allow: Optional[bool],
reason: str,
if_not_none: bool = False,
) -> None:
@ -62,7 +62,7 @@ class PermissionRowCollector:
return
self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> PermissionSQL | None:
def to_permission_sql(self) -> Optional[PermissionSQL]:
"""Convert collected rows to a PermissionSQL object."""
if not self.rows:
return None

View file

@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
class ActorRestrictions:
"""Parsed actor restrictions from the _r key."""
global_actions: list[str] # _r.a - globally allowed actions
global_actions: List[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None:
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
"""Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor:
return None
@ -44,11 +44,11 @@ class ActorRestrictions:
table_actions=restrictions.get("r", {}),
)
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool:
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
"""Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]:
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
"""Get database names where this action is allowed."""
allowed = set()
for db_name, db_actions in self.database_actions.items():
@ -57,8 +57,8 @@ class ActorRestrictions:
return allowed
def get_allowed_tables(
self, datasette: Datasette, action: str
) -> set[tuple[str, str]]:
self, datasette: "Datasette", action: str
) -> Set[Tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed."""
allowed = set()
for db_name, tables in self.table_actions.items():
@ -70,10 +70,10 @@ class ActorRestrictions:
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(
datasette: Datasette,
actor: dict | None,
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> list[PermissionSQL] | None:
) -> Optional[List[PermissionSQL]]:
"""
Handle actor restriction-based permission rules.
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
def restrictions_allow_action(
datasette: Datasette,
datasette: "Datasette",
restrictions: dict,
action: str,
resource: str | tuple[str, str] | None,
resource: Optional[str | Tuple[str, str]],
) -> bool:
"""
Check if restrictions allow the requested action on the requested resource.
@ -185,15 +185,11 @@ def restrictions_allow_action(
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
action_obj = datasette.actions.get(action)
normalize = action_obj.normalize_child if action_obj else lambda name: name
for table_name, table_allowed in (
restrictions.get("r", {}).get(database, {}).items()
):
if normalize(table_name) == normalize(table):
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False

View file

@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(
datasette: Datasette,
actor: dict | None,
) -> PermissionSQL | None:
datasette: "Datasette",
actor: Optional[dict],
) -> Optional[PermissionSQL]:
"""
Grant root user full permissions when --root flag is used.
"""

View file

@ -1,25 +0,0 @@
"""Default table-access policy for SQLite optimizer statistics."""
import json
from datasette import hookimpl
from datasette.permissions import PermissionSQL
@hookimpl
def permission_resources_sql(action):
if action != "view-table":
return None
return PermissionSQL(
sql="""
SELECT database_name AS parent, value AS child, 0 AS allow,
'SQLite statistics tables are denied by default' AS reason
FROM catalog_databases
CROSS JOIN json_each(:sqlite_statistics_names)
""",
params={
"sqlite_statistics_names": json.dumps(
["sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"]
)
},
)

View file

@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
@hookimpl
def register_token_handler(datasette: Datasette):
def register_token_handler(datasette: "Datasette"):
"""Register the default signed token handler."""
return SignedTokenHandler()
@hookimpl(specname="actor_from_request")
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None:
async def actor_from_signed_api_token(
datasette: "Datasette", request
) -> Optional[dict]:
"""
Authenticate requests using API tokens by delegating to all registered
token handlers via datasette.verify_token().

View file

@ -20,7 +20,7 @@ def table_actions(datasette, actor, database, table, request):
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": f"Alter table {table}",
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}

View file

@ -1,8 +1,7 @@
from abc import ABC, abstractproperty
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from datasette.hookspecs import hookimpl
from datetime import datetime, timezone
@dataclass

View file

@ -1,13 +1,12 @@
import json
import urllib
from datasette import hookimpl
from datasette.database import QueryInterrupted
from datasette.utils import (
detect_json1,
escape_sqlite,
path_with_added_args,
path_with_removed_args,
detect_json1,
sqlite3,
)
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
assert (
len(facet_config.values()) == 1
), "Metadata config dicts should be {type: config}"
type, facet_config = next(iter(facet_config.items()))
type, facet_config = list(facet_config.items())[0]
if isinstance(facet_config, str):
facet_config = {"simple": facet_config}
facet_configs.setdefault(type, []).append(
@ -86,7 +85,7 @@ class Facet:
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table
self.sql = sql or f"select * from {escape_sqlite(table)}"
self.sql = sql or f"select * from [{table}]"
self.params = params or []
self.table_config = table_config
# row_count can be None, in which case we calculate it ourselves:
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
for column in columns:
if column in already_enabled:
continue
suggested_facet_sql = f"""
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
select {escape_sqlite(column)} as value, count(*) as n from limited
suggested_facet_sql = """
with limited as (select * from ({sql}) limit {suggest_consider})
select {column} as value, count(*) as n from limited
where value is not null
group by value
limit {facet_size + 1}
"""
limit {limit}
""".format(
column=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
suggest_consider=self.suggest_consider,
)
distinct_values = None
try:
distinct_values = await self.ds.execute(
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
for row in facet_rows:
column_qs = column
if column.startswith("_"):
column_qs = f"{column}__exact"
column_qs = "{}__exact".format(column)
selected = (column_qs, str(row["value"])) in qs_pairs
if selected:
toggle_path = path_with_removed_args(
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
for v in await self.ds.execute(
self.database,
(
f"select {escape_sqlite(column)} from ({self.sql}) "
f"where {escape_sqlite(column)} is not null "
f"and {escape_sqlite(column)} != '' "
f"and json_array_length({escape_sqlite(column)}) > 0 "
"select {column} from ({sql}) "
"where {column} is not null "
"and {column} != '' "
"and json_array_length({column}) > 0 "
"limit 100"
),
).format(column=escape_sqlite(column), sql=self.sql),
self.params,
truncate=False,
custom_time_limit=self.ds.setting(
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
source = source_and_config["source"]
column = config.get("column") or config["simple"]
# https://github.com/simonw/datasette/issues/448
facet_sql = f"""
with inner as ({self.sql}),
facet_sql = """
with inner as ({sql}),
deduped_array_items as (
select
distinct j.value,
inner.*
from
json_each([inner].{escape_sqlite(column)}) j
json_each([inner].{col}) j
join inner
)
select
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
group by
value
order by
count(*) desc, value limit {facet_size + 1}
"""
count(*) desc, value limit {limit}
""".format(
col=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
)
try:
facet_rows_results = await self.ds.execute(
self.database,

View file

@ -1,11 +1,8 @@
import json
from typing import ClassVar
from datasette import hookimpl
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import BadRequest
from datasette.resources import DatabaseResource
from datasette.views.base import DatasetteError
from datasette.utils.asgi import BadRequest
import json
from .utils import detect_json1, escape_sqlite, path_with_removed_args
@ -51,20 +48,13 @@ def search_filters(request, database, table, datasette):
human_descriptions = []
extra_context = {}
# Figure out which trusted fts_table to use. Query string parameters can
# repeat this mapping (for backwards compatibility), but must not select
# a different table or primary key.
# Figure out which fts_table to use
table_metadata = await datasette.table_config(database, table)
db = datasette.get_database(database)
fts_table = table_metadata.get("fts_table")
fts_table = request.args.get("_fts_table")
fts_table = fts_table or table_metadata.get("fts_table")
fts_table = fts_table or await db.fts_table(table)
fts_pk = table_metadata.get("fts_pk", "rowid")
requested_fts_table = request.args.get("_fts_table")
requested_fts_pk = request.args.get("_fts_pk")
if (requested_fts_table and requested_fts_table != fts_table) or (
requested_fts_pk and requested_fts_pk != fts_pk
):
raise BadRequest("Invalid _fts_table or _fts_pk")
fts_pk = request.args.get("_fts_pk", table_metadata.get("fts_pk", "rowid"))
search_args = {
key: request.args[key]
for key in request.args
@ -82,11 +72,6 @@ def search_filters(request, database, table, datasette):
extra_context["supports_search"] = bool(fts_table)
if fts_table and search_args:
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database=database, table=fts_table),
actor=request.actor,
)
if "_search" in search_args:
# Simple ?_search=xxx
search = search_args["_search"]
@ -114,9 +99,9 @@ def search_filters(request, database, table, datasette):
fts_table=escape_sqlite(fts_table),
search_col=escape_sqlite(search_col),
match_clause=(
f":search_{i}"
":search_{}".format(i)
if search_mode_raw
else f"escape_fts(:search_{i})"
else "escape_fts(:search_{})".format(i)
),
)
)
@ -147,18 +132,13 @@ def through_filters(request, database, table, datasette):
through_table = through_data["table"]
other_column = through_data["column"]
value = through_data["value"]
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database=database, table=through_table),
actor=request.actor,
)
db = datasette.get_database(database)
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
fk_to_us = next(
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
None,
)
if fk_to_us is None:
try:
fk_to_us = [
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
][0]
except IndexError:
raise DatasetteError(
"Invalid _through - could not find corresponding foreign key"
)
@ -226,14 +206,10 @@ class TemplatedFilter(Filter):
if self.numeric and converted.isdigit():
converted = int(converted)
if self.no_argument:
kwargs = {"c": _quote_sqlite_identifier(column)}
kwargs = {"c": column}
converted = None
else:
kwargs = {
"c": _quote_sqlite_identifier(column),
"p": f"p{param_counter}",
"t": _quote_sqlite_identifier(table),
}
kwargs = {"c": column, "p": f"p{param_counter}", "t": table}
return self.sql_template.format(**kwargs), converted
def human_clause(self, column, value):
@ -247,14 +223,6 @@ class TemplatedFilter(Filter):
return template.format(c=column, v=value)
def _quote_sqlite_identifier(identifier):
# Preserve the historic always-quoted SQL generated by TemplatedFilter.
escaped = escape_sqlite(identifier)
if escaped == identifier:
return f'"{identifier}"'
return escaped
class InFilter(Filter):
key = "in"
display = "in"
@ -296,56 +264,56 @@ class Filters:
TemplatedFilter(
"exact",
"=",
"{c} = :{p}",
'"{c}" = :{p}',
lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"',
),
TemplatedFilter(
"not",
"!=",
"{c} != :{p}",
'"{c}" != :{p}',
lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"',
),
TemplatedFilter(
"contains",
"contains",
"{c} like :{p}",
'"{c}" like :{p}',
'{c} contains "{v}"',
format="%{}%",
),
TemplatedFilter(
"notcontains",
"does not contain",
"{c} not like :{p}",
'"{c}" not like :{p}',
'{c} does not contain "{v}"',
format="%{}%",
),
TemplatedFilter(
"endswith",
"ends with",
"{c} like :{p}",
'"{c}" like :{p}',
'{c} ends with "{v}"',
format="%{}",
),
TemplatedFilter(
"startswith",
"starts with",
"{c} like :{p}",
'"{c}" like :{p}',
'{c} starts with "{v}"',
format="{}%",
),
TemplatedFilter("gt", ">", "{c} > :{p}", "{c} > {v}", numeric=True),
TemplatedFilter("gt", ">", '"{c}" > :{p}', "{c} > {v}", numeric=True),
TemplatedFilter(
"gte", "\u2265", "{c} >= :{p}", "{c} \u2265 {v}", numeric=True
"gte", "\u2265", '"{c}" >= :{p}', "{c} \u2265 {v}", numeric=True
),
TemplatedFilter("lt", "<", "{c} < :{p}", "{c} < {v}", numeric=True),
TemplatedFilter("lt", "<", '"{c}" < :{p}', "{c} < {v}", numeric=True),
TemplatedFilter(
"lte", "\u2264", "{c} <= :{p}", "{c} \u2264 {v}", numeric=True
"lte", "\u2264", '"{c}" <= :{p}', "{c} \u2264 {v}", numeric=True
),
TemplatedFilter("like", "like", "{c} like :{p}", '{c} like "{v}"'),
TemplatedFilter("like", "like", '"{c}" like :{p}', '{c} like "{v}"'),
TemplatedFilter(
"notlike", "not like", "{c} not like :{p}", '{c} not like "{v}"'
"notlike", "not like", '"{c}" not like :{p}', '{c} not like "{v}"'
),
TemplatedFilter("glob", "glob", "{c} glob :{p}", '{c} glob "{v}"'),
TemplatedFilter("glob", "glob", '"{c}" glob :{p}', '{c} glob "{v}"'),
InFilter(),
NotInFilter(),
]
@ -354,13 +322,13 @@ class Filters:
TemplatedFilter(
"arraycontains",
"array contains",
""":{p} in (select value from json_each({t}.{c}))""",
""":{p} in (select value from json_each([{t}].[{c}]))""",
'{c} contains "{v}"',
),
TemplatedFilter(
"arraynotcontains",
"array does not contain",
""":{p} not in (select value from json_each({t}.{c}))""",
""":{p} not in (select value from json_each([{t}].[{c}]))""",
'{c} does not contain "{v}"',
),
]
@ -368,34 +336,36 @@ class Filters:
else []
)
+ [
TemplatedFilter("date", "date", "date({c}) = :{p}", '"{c}" is on date {v}'),
TemplatedFilter(
"isnull", "is null", "{c} is null", "{c} is null", no_argument=True
"date", "date", 'date("{c}") = :{p}', '"{c}" is on date {v}'
),
TemplatedFilter(
"isnull", "is null", '"{c}" is null', "{c} is null", no_argument=True
),
TemplatedFilter(
"notnull",
"is not null",
"{c} is not null",
'"{c}" is not null',
"{c} is not null",
no_argument=True,
),
TemplatedFilter(
"isblank",
"is blank",
"({c} is null or {c} = '')",
'("{c}" is null or "{c}" = "")',
"{c} is blank",
no_argument=True,
),
TemplatedFilter(
"notblank",
"is not blank",
"({c} is not null and {c} != '')",
'("{c}" is not null and "{c}" != "")',
"{c} is not blank",
no_argument=True,
),
]
)
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters}
_filters_by_key = {f.key: f for f in _filters}
def __init__(self, pairs):
self.pairs = pairs

View file

@ -1,10 +1,9 @@
from datasette.utils.sqlite import sqlite3
from datasette.utils import documented
import itertools
import random
import string
from datasette.utils import documented
from datasette.utils.sqlite import sqlite3
__all__ = [
"EXTRA_DATABASE_SQL",
"TABLES",
@ -347,7 +346,9 @@ CREATE VIEW searchable_view_configured_by_metadata AS
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
+ "\n".join(
[
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
a=a, b=b, c=c, content=content
)
for a, b, c, content in generate_compound_rows(1001)
]
)

View file

@ -1,5 +1,4 @@
from datasette import Response, hookimpl
from datasette import hookimpl, Response
from .utils import add_cors_headers

View file

@ -1,21 +1,16 @@
import traceback
from markupsafe import Markup
from datasette import Response, hookimpl
from datasette import hookimpl, Response
from .utils import add_cors_headers, error_body
from .utils.asgi import (
Base400,
)
from .views.base import DatasetteError
from markupsafe import Markup
import traceback
# Debugger imports are deliberate - they back the "pdb" setting, which drops
# into a debugger on unhandled exceptions
try:
import ipdb as pdb # noqa: T100
import ipdb as pdb
except ImportError:
import pdb # noqa: T100
import pdb
try:
import rich
@ -74,7 +69,7 @@ def handle_exception(datasette, request, exception):
dict(
info,
urls=datasette.urls,
menu_links=list,
menu_links=lambda: [],
)
),
status=status,

View file

@ -1,4 +1,5 @@
from pluggy import HookimplMarker, HookspecMarker
from pluggy import HookimplMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("datasette")
hookimpl = HookimplMarker("datasette")

View file

@ -1,13 +1,13 @@
import hashlib
from .utils import (
detect_spatialite,
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
sqlite3,
table_columns,
sqlite3,
)
HASH_BLOCK_SIZE = 1024 * 1024
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
""")
]
for t, table_info in tables.items():
for t in tables.keys():
for hidden_table in hidden_tables:
if t == hidden_table or t.startswith(hidden_table):
table_info["hidden"] = True
tables[t]["hidden"] = True
continue
return tables

View file

@ -21,7 +21,7 @@ class JumpSQL:
search_text: str | None = None,
display_name: str | None = None,
item_type: str = "menu",
) -> JumpSQL:
) -> "JumpSQL":
if search_text is None:
search_text = " ".join(
text for text in (label, display_name, description) if text is not None

View file

@ -1,11 +1,7 @@
import contextvars
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, NamedTuple
_SQLITE_IDENTIFIER_CASE = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
)
import contextvars
# Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar(
@ -53,15 +49,6 @@ class Resource(ABC):
# Class-level metadata (subclasses must define these)
name: str = None # e.g., "table", "database", "model"
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
case_insensitive_child: bool = False
@classmethod
def normalize_child(cls, child: str | None) -> str | None:
"""Return a comparison key without changing the resource's display name."""
if cls.case_insensitive_child and child is not None:
# Match SQLite NOCASE: fold ASCII only, not Unicode lower/casefold.
return child.translate(_SQLITE_IDENTIFIER_CASE)
return child
# Instance-level optional extra attributes
reasons: list[str] | None = None
@ -85,8 +72,8 @@ class Resource(ABC):
)
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
return "{}(parent={!r}, child={!r})".format(
self.__class__.__name__, self.parent, self.child
)
@property
@ -142,6 +129,7 @@ class Resource(ABC):
Must return two columns: parent, child
"""
pass
class AllowedResource(NamedTuple):
@ -159,11 +147,6 @@ class Action:
resource_class: type[Resource] | None = None
also_requires: str | None = None # Optional action name that must also be allowed
def normalize_child(self, child: str | None) -> str | None:
if self.resource_class is None:
return child
return self.resource_class.normalize_child(child)
@property
def takes_parent(self) -> bool:
"""

View file

@ -1,14 +1,20 @@
import importlib
import importlib.metadata as importlib_metadata
import importlib.resources as importlib_resources
import os
import sys
from pprint import pprint
import pluggy
from pprint import pprint
import sys
from . import hookspecs
if sys.version_info >= (3, 9):
import importlib.resources as importlib_resources
else:
import importlib_resources
if sys.version_info >= (3, 10):
import importlib.metadata as importlib_metadata
else:
import importlib_metadata
DEFAULT_PLUGINS = (
"datasette.publish.heroku",
"datasette.publish.cloudrun",
@ -18,7 +24,6 @@ DEFAULT_PLUGINS = (
"datasette.actor_auth_cookie",
"datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_permissions.sqlite_statistics",
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
@ -80,7 +85,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
# Ensure name can be found in plugin_to_distinfo later:
pm._plugin_distinfo.append((mod, distribution))
except importlib_metadata.PackageNotFoundError:
sys.stderr.write(f"Plugin {package_name} could not be found\n")
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
# Load default plugins

View file

@ -1,17 +1,15 @@
from datasette import hookimpl
import click
import json
import os
import re
from subprocess import CalledProcessError, check_call, check_output
import click
from datasette import hookimpl
from ..utils import temporary_docker_directory
from .common import (
add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed,
)
from ..utils import temporary_docker_directory
@hookimpl
@ -221,7 +219,7 @@ def publish_subcommand(publish):
check_call(
"gcloud builds submit --tag {}{}".format(
image_id, f" --timeout {timeout}" if timeout else ""
image_id, " --timeout {}".format(timeout) if timeout else ""
),
shell=True,
)
@ -233,7 +231,7 @@ def publish_subcommand(publish):
("--min-instances", min_instances),
):
if value is not None:
extra_deploy_options.append(f"{option} {value}")
extra_deploy_options.append("{} {}".format(option, value))
check_call(
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
image_id,
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
) from exc
describe_cmd = (
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
f"--location {artifact_region} --quiet"
"gcloud artifacts repositories describe {repo} --project {project} "
"--location {location} --quiet"
).format(
repo=artifact_repository,
project=artifact_project,
location=artifact_region,
)
try:
check_call(describe_cmd, shell=True)
return
except CalledProcessError:
create_cmd = (
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
"gcloud artifacts repositories create {repo} --repository-format=docker "
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
).format(
repo=artifact_repository,
location=artifact_region,
project=artifact_project,
)
try:
check_call(create_cmd, shell=True)

View file

@ -1,11 +1,9 @@
from ..utils import StaticMount
import click
import os
import shutil
import sys
import click
from ..utils import StaticMount
def add_common_publish_arguments_and_options(subcommand):
for decorator in reversed(
@ -78,7 +76,9 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary):
click.secho(
f"Publishing to {publish_target} requires {binary} to be installed and configured",
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
bg="red",
fg="white",
bold=True,

View file

@ -1,21 +1,19 @@
from contextlib import contextmanager
from datasette import hookimpl
import click
import json
import os
import pathlib
import shlex
import shutil
import tempfile
from contextlib import contextmanager
from subprocess import call, check_output
import click
from datasette import hookimpl
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
import tempfile
from .common import (
add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed,
)
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
@hookimpl
@ -236,7 +234,7 @@ def temporary_heroku_directory(
extras.extend(["--static", f"{mount_point}:{mount_point}"])
quoted_files = " ".join(
[f"-i {shlex.quote(file_name)}" for file_name in file_names]
["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
)
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
quoted_files=quoted_files, extras=" ".join(extras)

View file

@ -1,13 +1,12 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
CustomJSONEncoder,
error_body,
path_from_row_pks,
remove_infinites,
sqlite3,
value_as_boolean,
remove_infinites,
CustomJSONEncoder,
path_from_row_pks,
sqlite3,
)
from datasette.utils.asgi import Response

View file

@ -25,7 +25,6 @@ class TableResource(Resource):
name = "table"
parent_class = DatabaseResource
case_insensitive_child = True
def __init__(self, database: str, table: str):
super().__init__(parent=database, child=table)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -14,6 +14,7 @@ const SQLite = SQLDialect.define({
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
caseInsensitiveIdentifiers: true,
});
// Utility function from https://codemirror.net/docs/migration/
@ -48,9 +49,8 @@ export function editorFromTextArea(textarea, conf = {}) {
sql({
dialect: SQLite,
schema: conf.schema,
tables: conf.tables,
defaultTableName: conf.defaultTableName,
defaultSchemaName: conf.defaultSchemaName,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
}),
],
});

View file

@ -472,13 +472,11 @@ class ColumnChooser extends HTMLElement {
<span class="drag-item-check">
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
</span>
<span class="drag-item-label"></span>
<span class="drag-item-label">${col}</span>
</label>
<div class="drop-indicator"></div>
`;
li.querySelector(".drag-item-label").textContent = col;
li.querySelector("input").addEventListener("change", (e) => {
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
this._updateCounts();

View file

@ -0,0 +1,56 @@
/*
https://github.com/luyilin/json-format-highlight
From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js
MIT Licensed
*/
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jsonFormatHighlight = factory());
})(this, function () {
"use strict";
var defaultColors = {
keyColor: "dimgray",
numberColor: "lightskyblue",
stringColor: "lightcoral",
trueColor: "lightseagreen",
falseColor: "#f66578",
nullColor: "cornflowerblue",
};
function index(json, colorOptions) {
if (colorOptions === void 0) colorOptions = {};
if (!json) {
return;
}
if (typeof json !== "string") {
json = JSON.stringify(json, null, 2);
}
var colors = Object.assign({}, defaultColors, colorOptions);
json = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
function (match) {
var color = colors.numberColor;
if (/^"/.test(match)) {
color = /:$/.test(match) ? colors.keyColor : colors.stringColor;
} else {
color = /true/.test(match)
? colors.trueColor
: /false/.test(match)
? colors.falseColor
: /null/.test(match)
? colors.nullColor
: color;
}
return '<span style="color: ' + color + '">' + match + "</span>";
},
);
}
return index;
});

View file

@ -1,9 +1,8 @@
from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components
@ -387,7 +386,7 @@ async def count_queries(
OR q.sql LIKE :query_search
)
""")
params["query_search"] = f"%{q}%"
params["query_search"] = "%{}%".format(q)
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))
@ -463,7 +462,7 @@ async def list_queries(
except ValueError:
components = []
if database is None and len(components) == 3:
where_clauses.append(f"""
where_clauses.append("""
(
q.database_name > :cursor_database
OR (
@ -477,12 +476,12 @@ async def list_queries(
)
)
)
""")
""".format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2]
elif database is not None and len(components) == 2:
where_clauses.append(f"""
where_clauses.append("""
(
{sort_key_sql} > :cursor_sort_key
OR (
@ -490,7 +489,7 @@ async def list_queries(
AND q.name > :cursor_name
)
)
""")
""".format(sort_key_sql=sort_key_sql))
params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1]
@ -503,7 +502,7 @@ async def list_queries(
OR q.sql LIKE :query_search
)
""")
params["query_search"] = f"%{q}%"
params["query_search"] = "%{}%".format(q)
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<script src="{{ static('cm-editor.bundle.js') }}"></script>
<style>
.cm-editor {
resize: both;

View file

@ -6,20 +6,8 @@
padding: 1.5em;
margin-bottom: 2em;
}
.permission-form form {
max-width: 60rem;
}
.permission-form-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-form-result {
margin-top: 1rem;
max-width: 60rem;
}
.form-section {
margin-bottom: 1.25em;
margin-bottom: 1em;
}
.form-section label {
display: block;
@ -27,51 +15,22 @@
font-weight: bold;
}
.form-section input[type="text"],
.form-section input[type="number"],
.form-section select,
.permission-textarea {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
color: #222;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
max-width: none;
width: 100%;
}
.form-section input[type="text"] {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.form-section input[type="number"] {
height: 3rem;
max-width: 7rem;
padding: 0.6rem 0.75rem;
}
.form-section select {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.permission-textarea {
font-family: monospace;
min-height: 12rem;
padding: 0.75rem;
resize: vertical;
width: 100%;
max-width: 500px;
padding: 0.5em;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 3px;
}
.form-section input[type="text"]:focus,
.form-section input[type="number"]:focus,
.form-section select:focus,
.permission-textarea:focus {
.form-section select:focus {
outline: 2px solid #0066cc;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
outline: none;
}
.form-section small {
display: block;
margin-top: 0.45em;
margin-top: 0.3em;
color: #666;
}
.form-actions {
@ -183,9 +142,4 @@
text-align: center;
color: #666;
}
@media only screen and (max-width: 576px) {
.permission-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View file

@ -44,10 +44,10 @@
</style>
<nav class="permissions-debug-tabs">
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
</nav>

View file

@ -3,11 +3,29 @@
{% block title %}Debug allow rules{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
<style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning {
white-space: pre-wrap;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
@ -20,28 +38,24 @@ p.message-warning {
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<div class="permission-form">
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
<div class="permission-form-grid">
<div class="form-section">
<label for="allow-block">Allow block</label>
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
</div>
<div class="form-section">
<label for="allow-actor">Actor</label>
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">Apply allow block to actor</button>
</div>
</form>
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
<div class="two-col">
<p><label>Allow block</label></p>
<textarea name="allow">{{ allow_input }}</textarea>
</div>
<div class="two-col">
<p><label>Actor</label></p>
<textarea name="actor">{{ actor_input }}</textarea>
</div>
<div style="margin-top: 1em;">
<input type="submit" value="Apply allow block to actor">
</div>
</form>
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
</div>
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
{% endblock %}

View file

@ -3,6 +3,7 @@
{% block title %}API Explorer{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
{% endblock %}
{% block content %}
@ -125,7 +126,7 @@ getForm.addEventListener("submit", (ev) => {
document.getElementById('response-status').textContent = response.status;
return response.json();
}).then((data) => {
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
errorList.style.display = 'none';
}).catch((error) => {
alert(error);
@ -173,7 +174,7 @@ postForm.addEventListener("submit", (ev) => {
} else {
errorList.style.display = 'none';
}
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
output.style.display = 'block';
}).catch(err => {
alert("Error: " + err);

View file

@ -3,6 +3,7 @@
{% block title %}Allowed Resources{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
{% endblock %}
@ -48,7 +49,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -197,7 +198,7 @@ function displayResults(data) {
}
// Update raw JSON
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
function displayError(data) {
@ -207,7 +208,7 @@ function displayError(data) {
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
// Disable child input if parent is empty

View file

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% block title %}Explain a permission decision{% endblock %}
{% block title %}Permission Check{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
<style>
@ -12,35 +13,29 @@
border-radius: 5px;
}
#output.allowed {
background-color: #f3fbf4;
background-color: #e8f5e9;
border: 2px solid #4caf50;
}
#output.denied {
background-color: #fff7f7;
background-color: #ffebee;
border: 2px solid #f44336;
}
#output h2 {
margin-top: 0;
}
#output h3 {
margin-bottom: 0.5em;
}
#output .result-badge,
.effect-badge,
.rule-status {
#output .result-badge {
display: inline-block;
padding: 0.2em 0.5em;
padding: 0.3em 0.8em;
border-radius: 3px;
font-weight: bold;
font-size: 1.1em;
}
#output .allowed-badge,
.effect-allow {
background-color: #2e7d32;
#output .allowed-badge {
background-color: #4caf50;
color: white;
}
#output .denied-badge,
.effect-deny {
background-color: #c62828;
#output .denied-badge {
background-color: #f44336;
color: white;
}
.details-section {
@ -53,130 +48,70 @@
.details-section dd {
margin-left: 1em;
}
.explanation-section {
background: rgba(255, 255, 255, 0.75);
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 1em;
padding: 0 1em 1em;
}
.rules-table {
border-collapse: collapse;
width: 100%;
}
.rules-table th,
.rules-table td {
border-bottom: 1px solid #ddd;
padding: 0.5em;
text-align: left;
vertical-align: top;
}
.rule-status {
background: #e8f5e9;
color: #1b5e20;
}
.rule-ignored {
background: #eee;
color: #555;
font-weight: normal;
}
.requirement-allowed {
color: #1b5e20;
}
.requirement-denied {
color: #b71c1c;
}
@media only screen and (max-width: 576px) {
.rules-table,
.rules-table tbody,
.rules-table tr,
.rules-table td {
display: block;
}
.rules-table thead {
display: none;
}
.rules-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Explain a permission decision</h1>
<h1>Permission check</h1>
{% set current_tab = "check" %}
{% include "_permissions_debug_tabs.html" %}
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
{% if request.actor %}
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
{% else %}
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
{% endif %}
<div class="permission-form">
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
<div class="form-section">
<label for="actor">Actor JSON:</label>
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
</div>
<div class="form-section">
<label for="action">Action:</label>
<label for="action">Action (permission name):</label>
<select id="action" name="action" required>
<option value="">Select an action...</option>
{% for action in actions %}
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
{% for action_name in sorted_actions %}
<option value="{{ action_name }}">{{ action_name }}</option>
{% endfor %}
</select>
<small id="action-help">The operation to evaluate</small>
<small>The permission action to check</small>
</div>
<div class="form-section" id="parent-section">
<label for="parent">Parent resource:</label>
<div class="form-section">
<label for="parent">Parent resource (optional):</label>
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
<small>The database or other parent resource</small>
<small>For database-level permissions, specify the database name</small>
</div>
<div class="form-section" id="child-section">
<label for="child">Child resource:</label>
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
<small>The table, query or other child resource</small>
<div class="form-section">
<label for="child">Child resource (optional):</label>
<input type="text" id="child" name="child" placeholder="e.g., table name">
<small>For table-level permissions, specify the table name (requires parent)</small>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
</div>
</form>
</div>
<div id="output" style="display: none;">
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
<p id="result-summary"></p>
<dl class="details-section">
<dt>Actor:</dt>
<dd><code id="result-actor"></code></dd>
<dt>Action:</dt>
<dd><code id="result-action"></code></dd>
<dt>Resource:</dt>
<dd><code id="result-resource"></code></dd>
<dd id="result-action"></dd>
<dt>Resource Path:</dt>
<dd id="result-resource"></dd>
<dt>Actor ID:</dt>
<dd id="result-actor"></dd>
<div id="additional-details"></div>
</dl>
<section class="explanation-section">
<h3>Matching rules</h3>
<div id="matching-rules"></div>
</section>
<section class="explanation-section" id="restrictions-section">
<h3>Actor restrictions</h3>
<div id="restriction-results"></div>
</section>
<section class="explanation-section" id="requirements-section">
<h3>Required actions</h3>
<div id="requirement-results"></div>
</section>
<details style="margin-top: 1em;">
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
@ -184,134 +119,152 @@
</div>
<script>
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
const form = document.getElementById('check-form');
const output = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
const actionSelect = document.getElementById('action');
function updateResourceFields() {
const action = actions[actionSelect.value];
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
let help = action && action.description ? action.description : 'The operation to evaluate';
if (action && action.also_requires) {
help += `; also requires ${action.also_requires}`;
}
document.getElementById('action-help').textContent = help;
}
async function performCheck() {
submitBtn.disabled = true;
submitBtn.textContent = 'Explaining...';
const params = new URLSearchParams(new FormData(form));
submitBtn.textContent = 'Checking...';
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value) {
params.append(key, value);
}
}
try {
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
headers: {'Accept': 'application/json'}
method: 'GET',
headers: {
'Accept': 'application/json',
}
});
const data = await response.json();
if (response.ok) {
displayResult(data);
} else {
displayError(data);
}
} catch (error) {
displayError({error: error.message});
alert('Error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Explain decision';
submitBtn.textContent = 'Check Permission';
}
}
// Populate form on initial load
(function() {
const params = populateFormFromURL();
const action = params.get('action');
if (action) {
performCheck();
}
})();
function displayResult(data) {
output.style.display = 'block';
// Set badge and styling
const resultBadge = document.getElementById('result-badge');
output.className = data.allowed ? 'allowed' : 'denied';
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
document.getElementById('result-summary').textContent = data.explanation.summary;
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
document.getElementById('result-action').textContent = data.action;
document.getElementById('result-resource').textContent = data.resource.path;
displayRules(data.explanation);
displayRestrictions(data.explanation.restrictions);
displayRequirements(data.explanation.required_actions);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
function displayRules(explanation) {
const container = document.getElementById('matching-rules');
if (!explanation.matched_rules.length) {
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
return;
if (data.allowed) {
output.className = 'allowed';
resultBadge.className = 'result-badge allowed-badge';
resultBadge.textContent = 'ALLOWED ✓';
} else {
output.className = 'denied';
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'DENIED ✗';
}
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
for (const rule of explanation.matched_rules) {
const status = rule.decisive
? '<span class="rule-status">Decisive</span>'
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
html += '<tr>';
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
html += `<td data-label="Role in decision">${status}</td>`;
html += '</tr>';
// Basic details
document.getElementById('result-action').textContent = data.action || 'N/A';
document.getElementById('result-resource').textContent = data.resource?.path || '/';
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
// Additional details
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '';
if (data.reason !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Reason:';
const dd = document.createElement('dd');
dd.textContent = data.reason || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
container.innerHTML = html + '</tbody></table>';
}
function displayRestrictions(restrictions) {
const section = document.getElementById('restrictions-section');
const container = document.getElementById('restriction-results');
section.style.display = restrictions.length ? 'block' : 'none';
container.innerHTML = restrictions.map(restriction => {
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
}).join('');
}
if (data.source_plugin !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Source Plugin:';
const dd = document.createElement('dd');
dd.textContent = data.source_plugin || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
function displayRequirements(requirements) {
const section = document.getElementById('requirements-section');
const container = document.getElementById('requirement-results');
section.style.display = requirements.length ? 'block' : 'none';
container.innerHTML = requirements.map(requirement => {
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
}).join('');
if (data.used_default !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Used Default:';
const dd = document.createElement('dd');
dd.textContent = data.used_default ? 'Yes' : 'No';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.depth !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Depth:';
const dd = document.createElement('dd');
dd.textContent = data.depth;
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
// Raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
// Scroll to output
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function displayError(data) {
output.style.display = 'block';
output.className = 'denied';
const resultBadge = document.getElementById('result-badge');
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'ERROR';
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
document.getElementById('result-actor').textContent = '—';
document.getElementById('result-action').textContent = '—';
document.getElementById('result-resource').textContent = '—';
document.getElementById('matching-rules').innerHTML = '';
document.getElementById('restrictions-section').style.display = 'none';
document.getElementById('requirements-section').style.display = 'none';
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
document.getElementById('result-action').textContent = 'N/A';
document.getElementById('result-resource').textContent = 'N/A';
document.getElementById('result-actor').textContent = 'N/A';
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
form.addEventListener('submit', event => {
event.preventDefault();
performCheck();
});
actionSelect.addEventListener('change', updateResourceFields);
// Disable child input if parent is empty
const parentInput = document.getElementById('parent');
const childInput = document.getElementById('child');
(function initializeFromUrl() {
const params = populateFormFromURL();
updateResourceFields();
if (params.get('action')) {
performCheck();
childInput.addEventListener('focus', () => {
if (!parentInput.value) {
alert('Please specify a parent resource first before adding a child resource.');
parentInput.focus();
}
})();
});
</script>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Permission activity{% endblock %}
{% block title %}Debug permissions{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
@ -20,45 +20,60 @@
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission activity</h1>
<h1>Permission playground</h1>
{% set current_tab = "permissions" %}
{% include "_permissions_debug_tabs.html" %}
<h2>Raw simulator</h2>
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
<div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<div class="permission-form-grid">
<div>
<div class="form-section">
<label for="activity-actor">Actor</label>
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
<div class="two-col">
<div class="form-section">
<label>Actor</label>
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
<div>
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
<div class="two-col" style="vertical-align: top">
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
<div class="form-actions">
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
});
</script>
<h2>Recent permission checks</h2>
<h1>Recent permissions checks</h1>
<p>
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},

View file

@ -3,6 +3,7 @@
{% block title %}Permission Rules{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
{% endblock %}
@ -36,7 +37,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -184,7 +185,7 @@ function displayResults(data) {
}
// Update raw JSON
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
function displayError(data) {
@ -194,7 +195,7 @@ function displayError(data) {
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
</script>

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import dataclasses
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
import itsdangerous
@ -50,24 +50,24 @@ class TokenRestrictions:
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
def allow_all(self, action: str) -> TokenRestrictions:
def allow_all(self, action: str) -> "TokenRestrictions":
"""Allow an action across all databases and resources."""
self.all.append(action)
return self
def allow_database(self, database: str, action: str) -> TokenRestrictions:
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
"""Allow an action on a specific database."""
self.database.setdefault(database, []).append(action)
return self
def allow_resource(
self, database: str, resource: str, action: str
) -> TokenRestrictions:
) -> "TokenRestrictions":
"""Allow an action on a specific resource within a database."""
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self
def abbreviated(self, datasette: Datasette) -> dict | None:
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
"""
Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``.
@ -112,16 +112,16 @@ class TokenHandler:
async def create_token(
self,
datasette: Datasette,
datasette: "Datasette",
actor_id: str,
*,
expires_after: int | None = None,
restrictions: TokenRestrictions | None = None,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
) -> str:
"""Create and return a token string for the given actor."""
raise NotImplementedError
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
"""
Verify a token and return an actor dict.
@ -142,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
async def create_token(
self,
datasette: Datasette,
datasette: "Datasette",
actor_id: str,
*,
expires_after: int | None = None,
restrictions: TokenRestrictions | None = None,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
) -> str:
if not datasette.setting("allow_signed_tokens"):
raise ValueError(
@ -163,7 +163,7 @@ class SignedTokenHandler(TokenHandler):
token["_r"] = abbreviated
return "dstok_{}".format(datasette.sign(token, namespace="token"))
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_"
if not token.startswith(prefix):
@ -200,8 +200,9 @@ class SignedTokenHandler(TokenHandler):
):
duration = max_signed_tokens_ttl
if duration and time.time() - created > duration:
raise TokenInvalid("Token has expired")
if duration:
if time.time() - created > duration:
raise TokenInvalid("Token has expired")
actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1,11 +1,10 @@
import asyncio
import json
import time
import traceback
from contextlib import contextmanager
from contextvars import ContextVar
from markupsafe import escape
import time
import json
import traceback
tracers = {}
@ -133,17 +132,17 @@ class AsgiTracer:
"num_traces": len(traces),
"traces": traces,
}
content_type = next(
(
try:
content_type = [
v.decode("utf8")
for k, v in response_headers
if k.lower() == b"content-type"
),
"",
)
][0]
except IndexError:
content_type = ""
if "text/html" in content_type and b"</body>" in accumulated_body:
extra = escape(json.dumps(trace_info, indent=2))
extra_html = f"<pre>{extra}</pre></body>".encode()
extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
elif "json" in content_type and accumulated_body.startswith(b"{"):
data = json.loads(accumulated_body.decode("utf8"))

View file

@ -1,7 +1,6 @@
from .utils import tilde_encode, path_with_format, PrefixedUrlString
import urllib
from .utils import PrefixedUrlString, path_with_format, tilde_encode
class Urls:
def __init__(self, ds):
@ -9,7 +8,8 @@ class Urls:
def path(self, path, format=None):
if not isinstance(path, PrefixedUrlString):
path = path.removeprefix("/")
if path.startswith("/"):
path = path[1:]
path = self.ds.setting("base_url") + path
if format is not None:
path = path_with_format(path=path, format=format)
@ -56,7 +56,6 @@ class Urls:
return PrefixedUrlString(path)
def row_blob(self, database, table, row_path, column):
return (
self.table(database, table)
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
row_path, urllib.parse.quote_plus(column)
)

View file

@ -1,31 +1,29 @@
import asyncio
import base64
import binascii
from contextlib import contextmanager
import aiofiles
import click
from collections import OrderedDict, namedtuple, Counter
import copy
import dataclasses
import base64
import hashlib
import inspect
import json
import os
import re
import secrets
import shlex
import shutil
import tempfile
import time
import types
import typing
import urllib
from collections import Counter, OrderedDict, namedtuple
from collections.abc import Iterable
from contextlib import contextmanager
import aiofiles
import click
import markupsafe
import mergedeep
import os
import re
import shlex
import tempfile
import typing
import time
import types
import secrets
import shutil
from typing import Iterable, List, Tuple
import urllib
import yaml
from .shutil_backport import copytree
from .sqlite import sqlite3, supports_table_xinfo
@ -38,7 +36,7 @@ if typing.TYPE_CHECKING:
class PaginatedResources:
"""Paginated results from allowed_resources query."""
resources: list["Resource"]
resources: List["Resource"]
next: str | None # Keyset token for next page (None if no more results)
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
_action: str = dataclasses.field(default=None, repr=False)
@ -85,132 +83,22 @@ class PaginatedResources:
# From https://www.sqlite.org/lang_keywords.html
reserved_words = {
"abort",
"action",
"add",
"after",
"all",
"alter",
"analyze",
"and",
"as",
"asc",
"attach",
"autoincrement",
"before",
"begin",
"between",
"by",
"cascade",
"case",
"cast",
"check",
"collate",
"column",
"commit",
"conflict",
"constraint",
"create",
"cross",
"current_date",
"current_time",
"current_timestamp",
"database",
"default",
"deferrable",
"deferred",
"delete",
"desc",
"detach",
"distinct",
"drop",
"each",
"else",
"end",
"escape",
"except",
"exclusive",
"exists",
"explain",
"fail",
"for",
"foreign",
"from",
"full",
"glob",
"group",
"having",
"if",
"ignore",
"immediate",
"in",
"index",
"indexed",
"initially",
"inner",
"insert",
"instead",
"intersect",
"into",
"is",
"isnull",
"join",
"key",
"left",
"like",
"limit",
"match",
"natural",
"no",
"not",
"notnull",
"null",
"of",
"offset",
"on",
"or",
"order",
"outer",
"plan",
"pragma",
"primary",
"query",
"raise",
"recursive",
"references",
"regexp",
"reindex",
"release",
"rename",
"replace",
"restrict",
"right",
"rollback",
"row",
"savepoint",
"select",
"set",
"table",
"temp",
"temporary",
"then",
"to",
"transaction",
"trigger",
"union",
"unique",
"update",
"using",
"vacuum",
"values",
"view",
"virtual",
"when",
"where",
"with",
"without",
}
reserved_words = set(
(
"abort action add after all alter analyze and as asc attach autoincrement "
"before begin between by cascade case cast check collate column commit "
"conflict constraint create cross current_date current_time "
"current_timestamp database default deferrable deferred delete desc detach "
"distinct drop each else end escape except exclusive exists explain fail "
"for foreign from full glob group having if ignore immediate in index "
"indexed initially inner insert instead intersect into is isnull join key "
"left like limit match natural no not notnull null of offset on or order "
"outer plan pragma primary query raise recursive references regexp reindex "
"release rename replace restrict right rollback row savepoint select set "
"table temp temporary then to transaction trigger union unique update using "
"vacuum values view virtual when where with without"
).split()
)
APT_GET_DOCKERFILE_EXTRAS = r"""
RUN apt-get update && \
@ -270,7 +158,7 @@ functions_marked_as_documented = []
def documented(fn=None, *, label=None):
def decorate(fn):
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}"
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
functions_marked_as_documented.append(fn)
return fn
@ -472,7 +360,7 @@ disallawed_sql_res = [
(
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas)
", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
),
)
]
@ -646,7 +534,10 @@ CMD {cmd}""".format(
else ""
),
environment_variables="\n".join(
[f"ENV {key} '{value}'" for key, value in environment_variables.items()]
[
"ENV {} '{}'".format(key, value)
for key, value in environment_variables.items()
]
),
install_from=" ".join(install),
files=" ".join(files),
@ -745,11 +636,11 @@ def detect_primary_keys(conn, table):
def get_outbound_foreign_keys(conn, table):
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
fks = []
for info in infos:
if info is not None:
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info
id, seq, table_name, from_, to_, on_update, on_delete, match = info
fks.append(
{
"column": from_,
@ -820,8 +711,7 @@ def detect_spatialite(conn):
def detect_fts(conn, table):
"""Detect if table has a corresponding FTS virtual table and return it"""
sql, params = detect_fts_sql(table)
rows = conn.execute(sql, params).fetchall()
rows = conn.execute(detect_fts_sql(table)).fetchall()
if len(rows) == 0:
return None
else:
@ -829,26 +719,18 @@ def detect_fts(conn, table):
def detect_fts_sql(table):
escaped_table = table.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return (
r"""
select name from sqlite_master
where rootpage = 0
and (
sql like :fts_double_quoted escape char(92)
or sql like :fts_bracket_quoted escape char(92)
or (
tbl_name = :table
and sql like '%VIRTUAL TABLE%USING FTS%'
)
return r"""
select name from sqlite_master
where rootpage = 0
and (
sql like '%VIRTUAL TABLE%USING FTS%content="{table}"%'
or sql like '%VIRTUAL TABLE%USING FTS%content=[{table}]%'
or (
tbl_name = "{table}"
and sql like '%VIRTUAL TABLE%USING FTS%'
)
""",
{
"fts_double_quoted": f'%VIRTUAL TABLE%USING FTS%content="{escaped_table}"%',
"fts_bracket_quoted": f"%VIRTUAL TABLE%USING FTS%content=[{escaped_table}]%",
"table": table,
},
)
)
""".format(table=table.replace("'", "''"))
def detect_json1(conn=None):
@ -859,7 +741,7 @@ def detect_json1(conn=None):
try:
conn.execute("SELECT json('{}')")
return True
except sqlite3.Error:
except Exception:
return False
finally:
if close_conn:
@ -939,7 +821,9 @@ def is_url(value):
if not value.startswith("http://") and not value.startswith("https://"):
return False
# Any whitespace at all is invalid
return not whitespace_re.search(value)
if whitespace_re.search(value):
return False
return True
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
@ -992,9 +876,7 @@ def module_from_path(path, name):
mod.__file__ = path
with open(path, "r") as file:
code = compile(file.read(), path, "exec", dont_inherit=True)
# Executing the file is the whole point - this is how --plugins-dir loads
# plugins and how metadata/config .py files are evaluated
exec(code, mod.__dict__) # noqa: S102
exec(code, mod.__dict__)
return mod
@ -1151,7 +1033,9 @@ def escape_fts(query):
query += '"'
bits = _escape_fts_re.split(query)
bits = [b for b in bits if b and b != '""']
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits)
return " ".join(
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
)
class MultiParams:
@ -1163,7 +1047,7 @@ class MultiParams:
data[key], (list, tuple)
), "dictionary data should be a dictionary of key => [list]"
self._data = data
elif isinstance(data, (list, tuple)):
elif isinstance(data, list) or isinstance(data, tuple):
new_data = {}
for item in data:
assert (
@ -1253,7 +1137,9 @@ def _gather_arguments(fn, kwargs):
for parameter in parameters:
if parameter not in kwargs:
raise TypeError(
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}"
"{} requires parameters {}, missing: {}".format(
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
)
)
call_with.append(kwargs[parameter])
return call_with
@ -1322,9 +1208,9 @@ def resolve_env_secrets(config, environ):
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
if isinstance(config, dict):
if list(config.keys()) == ["$env"]:
return environ.get(next(iter(config.values())))
return environ.get(list(config.values())[0])
elif list(config.keys()) == ["$file"]:
with open(next(iter(config.values()))) as fp:
with open(list(config.values())[0]) as fp:
return fp.read()
else:
return {
@ -1420,7 +1306,7 @@ _named_param_re = re.compile(r":(\w+)")
@documented
def named_parameters(sql: str) -> list[str]:
def named_parameters(sql: str) -> List[str]:
"""
Given a SQL statement, return a list of named parameters that are used in the statement
@ -1433,7 +1319,7 @@ def named_parameters(sql: str) -> list[str]:
return _named_param_re.findall(sql)
async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
"""
This undocumented but stable method exists for backwards compatibility
with plugins that were using it before it switched to named_parameters()
@ -1457,9 +1343,9 @@ def parse_size_limit(value, default, maximum, name="_size"):
if size < 0:
raise ValueError
except ValueError:
raise ValueError(f"{name} must be a positive integer")
raise ValueError("{} must be a positive integer".format(name))
if size > maximum:
raise ValueError(f"{name} must be <= {maximum}")
raise ValueError("{} must be <= {}".format(name, maximum))
return size
@ -1517,7 +1403,7 @@ class TildeEncoder(dict):
elif b == _space:
res = "+"
else:
res = f"~{b:02X}"
res = "~{:02X}".format(b)
self[b] = res
return res
@ -1566,13 +1452,7 @@ async def row_sql_params_pks(db, table, pk_values):
if use_rowid:
select = "rowid, *"
pks = ["rowid"]
wheres = []
for i, pk in enumerate(pks):
escaped_pk = escape_sqlite(pk)
# Preserve the historic always-quoted SQL exposed by _extra=query
if escaped_pk == pk:
escaped_pk = f'"{pk}"'
wheres.append(f"{escaped_pk}=:p{i}")
wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
params = {}
for i, pk_value in enumerate(pk_values):
@ -1618,7 +1498,7 @@ def _combine(base: dict, update: dict) -> dict:
return base
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict:
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
"""
Parse a list of key-value pairs into a nested dictionary.
"""
@ -1633,7 +1513,7 @@ def make_slot_function(name, datasette, request, **kwargs):
from datasette.plugins import pm
method = getattr(pm.hook, name, None)
assert method is not None, f"No hook found for {name}"
assert method is not None, "No hook found for {}".format(name)
async def inner():
html_bits = []
@ -1657,7 +1537,7 @@ def prune_empty_dicts(d: dict):
d.pop(key, None)
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]:
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
"""
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
hierarchy in destination if needed. After moving, recursively remove any keys
@ -1744,7 +1624,7 @@ def redact_keys(original: dict, key_patterns: Iterable) -> dict:
return {
k: (
redact(v)
if not any(pattern in k.casefold() for pattern in key_patterns)
if not any(pattern in k for pattern in key_patterns)
else "***"
)
for k, v in data.items()

View file

@ -29,15 +29,6 @@ from datasette.utils.permissions import gather_permission_sql_from_hooks
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette.permissions import Action
def _child_collation(action: "Action") -> str:
"""Match resource identity without changing the spelling returned by SQL."""
resource_class = action.resource_class
if resource_class is not None and resource_class.case_insensitive_child:
return "NOCASE"
return "BINARY"
async def build_allowed_resources_sql(
@ -158,7 +149,6 @@ async def _build_single_action_sql(
raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class
child_collation = _child_collation(action_obj)
base_resources_sql = await action_obj.resource_class.resources_sql(
datasette, actor=actor
)
@ -195,7 +185,7 @@ async def _build_single_action_sql(
if permission_sql.sql is None:
continue
rule_sqls.append(f"""
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql}
)
""".strip())
@ -262,62 +252,88 @@ async def _build_single_action_sql(
]
)
# Continue with the cascading logic.
# Aggregate the RULES by cascade level (small), rather than grouping
# base x rules (which scales with the number of resources).
def _agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
f" FROM all_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
# Continue with the cascading logic
query_parts.extend(
["child_agg AS ("]
+ _agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "parent_agg AS ("]
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "global_agg AS ("]
+ _agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
[
"child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
]
)
# Add anonymous decision logic if needed
if include_is_private:
def _anon_agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
f" FROM anon_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["anon_child_agg AS ("]
+ _anon_agg(
f"parent, child COLLATE {child_collation} AS child,",
"parent IS NOT NULL AND child IS NOT NULL",
f"parent, child COLLATE {child_collation}",
)
+ ["),", "anon_parent_agg AS ("]
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "anon_global_agg AS ("]
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
[
"anon_child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"anon_parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
"),",
]
)
# Final decisions
@ -326,28 +342,31 @@ async def _build_single_action_sql(
"decisions AS (",
" SELECT",
" b.parent, b.child,",
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Priority order:",
" -- 1. Child-level deny 2. Child-level allow",
" -- 3. Parent-level deny 4. Parent-level allow",
" -- 5. Global-level deny 6. Global-level allow",
" -- 1. Child-level deny (most specific, blocks access)",
" -- 2. Child-level allow (most specific, grants access)",
" -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 7. Default deny (no rules match)",
" CASE",
" WHEN ca.any_deny = 1 THEN 0",
" WHEN ca.any_allow = 1 THEN 1",
" WHEN pa.any_deny = 1 THEN 0",
" WHEN pa.any_allow = 1 THEN 1",
" WHEN ga.any_deny = 1 THEN 0",
" WHEN ga.any_allow = 1 THEN 1",
" WHEN cl.any_deny = 1 THEN 0",
" WHEN cl.any_allow = 1 THEN 1",
" WHEN pl.any_deny = 1 THEN 0",
" WHEN pl.any_allow = 1 THEN 1",
" WHEN gl.any_deny = 1 THEN 0",
" WHEN gl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" ELSE '[]'",
" END AS reason",
]
@ -355,34 +374,21 @@ async def _build_single_action_sql(
if include_is_private:
query_parts.append(
" , CASE WHEN ("
"CASE"
" WHEN aca.any_deny = 1 THEN 0"
" WHEN aca.any_allow = 1 THEN 1"
" WHEN apa.any_deny = 1 THEN 0"
" WHEN apa.any_allow = 1 THEN 1"
" WHEN aga.any_deny = 1 THEN 0"
" WHEN aga.any_allow = 1 THEN 1"
" ELSE 0 END"
") = 0 THEN 1 ELSE 0 END AS is_private"
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
)
query_parts.extend(
[
" FROM base b",
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
" CROSS JOIN global_agg ga",
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
]
)
if include_is_private:
query_parts.extend(
[
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
" CROSS JOIN anon_global_agg aga",
]
query_parts.append(
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
)
query_parts.append(")")
@ -392,31 +398,10 @@ async def _build_single_action_sql(
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
# with UNION ALL inside the restriction SQL statements
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
for sql in restriction_sqls
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
# Decompose by NULL-pattern so the final filter can use pure-equality
# EXISTS lookups (satisfiable via automatic indexes) instead of a
# correlated OR-scan over the whole list.
query_parts.extend(
[
",",
"restriction_list AS (",
f" {restriction_intersect}",
"),",
"restriction_exact AS (",
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
"),",
"restriction_parent_any AS (",
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
"),",
"restriction_child_any AS (",
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
"),",
"restriction_all AS (",
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
")",
]
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
)
# Final SELECT
@ -431,11 +416,10 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions
if restriction_sqls:
query_parts.append("""
AND (
EXISTS (SELECT 1 FROM restriction_all)
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
AND EXISTS (
SELECT 1 FROM restriction_list r
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
AND (r.child = decisions.child OR r.child IS NULL)
)""")
# Add parent filter if specified
@ -491,7 +475,6 @@ async def build_permission_rules_sql(
union_parts = []
all_params = {}
restriction_sqls = []
child_collation = _child_collation(action_obj)
for permission_sql in permission_sqls:
all_params.update(permission_sql.params or {})
@ -505,7 +488,7 @@ async def build_permission_rules_sql(
continue
union_parts.append(f"""
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql}
)
""".strip())
@ -576,7 +559,6 @@ async def check_permissions_for_actions(
verdicts = {}
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
child_collation = _child_collation(datasette.actions[action])
prefix = f"a{i}_"
rule_parts = []
restriction_parts = []
@ -602,7 +584,7 @@ async def check_permissions_for_actions(
if sql is None:
continue
rule_parts.append(
f"SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
)
if not rule_parts:
@ -636,8 +618,7 @@ async def check_permissions_for_actions(
if restriction_parts:
# Database-level restrictions (parent, NULL) match all children
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
for sql in restriction_parts
f"SELECT * FROM ({sql})" for sql in restriction_parts
)
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
verdict_sql = f"""({verdict_sql}) AND EXISTS (
@ -692,240 +673,3 @@ async def check_permission_for_resource(
child=child,
)
return results[action]
async def explain_permission_for_resource(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Explain a permission decision for one action and resource.
This is intended for Datasette's permission debugging tools. It uses the
same ``permission_resources_sql`` hook results and the same resolution
rules as :func:`check_permissions_for_actions`, but also returns the
matching rules, actor restriction results and ``also_requires`` chain.
The returned dictionary is part of Datasette's unstable debugging API.
"""
action_obj = datasette.actions.get(action)
if action_obj is None:
raise ValueError(f"Unknown action: {action}")
explanation = await _explain_single_action(
datasette=datasette,
actor=actor,
action=action,
parent=parent,
child=child,
)
required_actions = []
if action_obj.also_requires:
required = await explain_permission_for_resource(
datasette=datasette,
actor=actor,
action=action_obj.also_requires,
parent=parent,
child=child,
)
required_actions.append(required)
explanation["required_actions"] = required_actions
explanation["allowed"] = bool(
explanation["rule_allowed"]
and explanation["restriction_allowed"]
and all(required["allowed"] for required in required_actions)
)
explanation["summary"] = _permission_explanation_summary(explanation)
return explanation
async def _explain_single_action(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Return matching rules and restrictions for a single action."""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action=action,
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return {
"action": action,
"rule_allowed": True,
"restriction_allowed": True,
"winning_scope": "global",
"matched_rules": [
{
"scope": "global",
"effect": "allow",
"source": "skip_permission_checks",
"reason": "Permission checks were explicitly skipped",
"decisive": True,
"ignored_because": None,
}
],
"restrictions": [],
}
db = datasette.get_internal_database()
matched_rules = []
restrictions = []
child_collation = _child_collation(datasette.actions[action])
for permission_sql in permission_sqls:
params = dict(permission_sql.params or {})
parent_param = _unused_parameter_name(params, "_explain_parent")
params[parent_param] = parent
child_param = _unused_parameter_name(params, "_explain_child")
params[child_param] = child
if permission_sql.sql:
rows = await db.execute(
f"""
SELECT parent, child, allow, reason
FROM ({permission_sql.sql}) AS permission_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
""",
params,
)
for row in rows:
specificity = (
2
if row["child"] is not None
else 1 if row["parent"] is not None else 0
)
matched_rules.append(
{
"scope": ("resource", "parent", "global")[2 - specificity],
"effect": "allow" if row["allow"] else "deny",
"source": permission_sql.source,
"reason": row["reason"],
"_specificity": specificity,
}
)
if permission_sql.restriction_sql:
restriction_row = (
await db.execute(
f"""
SELECT EXISTS(
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
) AS resource_is_in_allowlist
""",
params,
)
).first()
restriction_allowed = bool(restriction_row[0])
restrictions.append(
{
"source": permission_sql.source,
"allowed": restriction_allowed,
"reason": params.get("deny")
or (
"Resource is included in this restriction allowlist"
if restriction_allowed
else "Resource is not included in this restriction allowlist"
),
}
)
matched_rules.sort(
key=lambda rule: (
-rule["_specificity"],
0 if rule["effect"] == "deny" else 1,
rule["source"] or "",
rule["reason"] or "",
)
)
if matched_rules:
winning_specificity = matched_rules[0]["_specificity"]
winning_rules = [
rule
for rule in matched_rules
if rule["_specificity"] == winning_specificity
]
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
winning_scope = winning_rules[0]["scope"]
else:
winning_specificity = None
rule_allowed = False
winning_scope = None
for rule in matched_rules:
specificity = rule.pop("_specificity")
if specificity != winning_specificity:
rule["decisive"] = False
rule["ignored_because"] = "A more specific rule matched"
elif not rule_allowed and rule["effect"] == "allow":
rule["decisive"] = False
rule["ignored_because"] = "A deny rule matched at the same scope"
else:
rule["decisive"] = True
rule["ignored_because"] = None
return {
"action": action,
"rule_allowed": rule_allowed,
"restriction_allowed": all(
restriction["allowed"] for restriction in restrictions
),
"winning_scope": winning_scope,
"matched_rules": matched_rules,
"restrictions": restrictions,
}
def _unused_parameter_name(params: dict, preferred: str) -> str:
"""Return a SQL parameter name that is not already in ``params``."""
candidate = preferred
suffix = 2
while candidate in params:
candidate = f"{preferred}_{suffix}"
suffix += 1
return candidate
def _permission_explanation_summary(explanation: dict) -> str:
denied_requirement = next(
(
required
for required in explanation["required_actions"]
if not required["allowed"]
),
None,
)
if denied_requirement:
return (
f"Denied because {explanation['action']} also requires "
f"{denied_requirement['action']}, which was denied."
)
if not explanation["matched_rules"]:
return "Denied because no permission rule matched this actor and resource."
if not explanation["rule_allowed"]:
return (
f"Denied by a {explanation['winning_scope']}-level rule. "
"Deny rules take precedence over allow rules at the same scope."
)
if not explanation["restriction_allowed"]:
return (
"Denied because the resource is not included in the actor's restrictions."
)
return f"Allowed by the matching {explanation['winning_scope']}-level rule."

View file

@ -1,30 +1,28 @@
import asyncio
import json
import re
from http.cookies import Morsel, SimpleCookie
from mimetypes import guess_type
from pathlib import Path
from urllib.parse import parse_qs, parse_qsl, urlunparse
import aiofiles
import aiofiles.os
from typing import Optional
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils.multipart import (
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_FIELDS,
parse_form_data,
MultipartParseError,
FormData,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_MEMORY_FILE_SIZE,
DEFAULT_MAX_PART_HEADER_BYTES,
DEFAULT_MAX_PART_HEADER_LINES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MIN_FREE_DISK_BYTES,
FormData,
MultipartParseError,
parse_form_data,
)
from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
from http.cookies import SimpleCookie, Morsel
import aiofiles
import aiofiles.os
import re
# Workaround for adding samesite support to pre 3.8 python
Morsel._reserved["samesite"] = "SameSite"
@ -90,7 +88,7 @@ class Request:
self.max_post_body_bytes = max_post_body_bytes
def __repr__(self):
return f'<asgi.Request method="{self.method}" url="{self.url}">'
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
@property
def method(self):
@ -169,7 +167,7 @@ class Request:
if max_bytes is None:
max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge(
f"Request body exceeded maximum size of {max_bytes} bytes"
"Request body exceeded maximum size of {} bytes".format(max_bytes)
)
if max_bytes:
# Reject early if the client declares an oversized body
@ -208,7 +206,7 @@ class Request:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -301,24 +299,12 @@ class AsgiLifespan:
while True:
message = await receive()
if message["type"] == "lifespan.startup":
try:
for fn in self.on_startup:
await fn()
except Exception as e: # noqa: BLE001
await send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
return
for fn in self.on_startup:
await fn()
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
try:
for fn in self.on_shutdown:
await fn()
except Exception as e: # noqa: BLE001
await send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
return
for fn in self.on_shutdown:
await fn()
await send({"type": "lifespan.shutdown.complete"})
return
else:
@ -498,8 +484,6 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
await asgi_send_html(send, "404: File not found", 404)
return
# Only the actual static-file handler can bypass dynamic response privacy.
inner_static._datasette_static = True
return inner_static
@ -545,9 +529,9 @@ class Response:
httponly=False,
samesite="lax",
):
assert (
samesite in SAMESITE_VALUES
), f"samesite should be one of {SAMESITE_VALUES}"
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
SAMESITE_VALUES
)
cookie = SimpleCookie()
cookie[key] = value
for prop_name, prop_value in (
@ -639,23 +623,10 @@ class AsgiRunOnFirstRequest:
self.asgi = asgi
self.on_startup = on_startup
self._started = False
# Guards against concurrent early requests interleaving with startup:
# without this, several requests could all observe `_started is
# False` and proceed before any of them finish running the hooks.
self._lock = asyncio.Lock()
async def __call__(self, scope, receive, send):
# Leave "lifespan" scope events alone - this shim only exists as a
# fallback for hosts that never send them. It wraps AsgiLifespan, so
# if it ran on_startup here too, a startup exception would escape
# before AsgiLifespan's own try/except got a chance to turn it into
# a lifespan.startup.failed message.
if scope["type"] != "lifespan" and not self._started:
async with self._lock:
# Re-check: another request may have finished startup while
# we were waiting for the lock.
if not self._started:
for hook in self.on_startup:
await hook()
self._started = True
if not self._started:
self._started = True
for hook in self.on_startup:
await hook()
return await self.asgi(scope, receive, send)

View file

@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
"""
class BaseConverter:
class BaseConverter(object):
decimal_digits = "0123456789"
def __init__(self, digits):

View file

@ -1,6 +1,6 @@
import inspect
import types
from typing import Any, NamedTuple
from typing import NamedTuple, Any
class CallableStatus(NamedTuple):
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
if isinstance(obj, types.FunctionType):
return CallableStatus(True, inspect.iscoroutinefunction(obj))
if callable(obj):
if hasattr(obj, "__call__"):
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
assert False, f"obj {obj!r} is somehow callable with no __call__ method"
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))

View file

@ -3,7 +3,7 @@ import textwrap
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
from datasette.utils import escape_sqlite, table_column_details
from datasette.utils import table_column_details
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
@ -180,9 +180,29 @@ async def init_internal_db(db):
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db, schema_version):
async def populate_schema_tables(internal_db, db):
database_name = db.name
def delete_everything(conn):
conn.execute(
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
[database_name],
)
conn.execute(
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
)
await internal_db.execute_write_fn(delete_everything)
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
@ -207,30 +227,25 @@ async def populate_schema_tables(internal_db, db, schema_version):
columns = table_column_details(conn, table_name)
columns_to_insert.extend(
{
"database_name": database_name,
"table_name": table_name,
**{"database_name": database_name, "table_name": table_name},
**column._asdict(),
}
for column in columns
)
foreign_keys = conn.execute(
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
f"PRAGMA foreign_key_list([{table_name}])"
).fetchall()
foreign_keys_to_insert.extend(
{
"database_name": database_name,
"table_name": table_name,
**{"database_name": database_name, "table_name": table_name},
**dict(foreign_key),
}
for foreign_key in foreign_keys
)
indexes = conn.execute(
f"PRAGMA index_list({escape_sqlite(table_name)})"
).fetchall()
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
indexes_to_insert.extend(
{
"database_name": database_name,
"table_name": table_name,
**{"database_name": database_name, "table_name": table_name},
**dict(index),
}
for index in indexes
@ -251,76 +266,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
indexes_to_insert,
) = await db.execute_fn(collect_info)
def replace_catalog(conn):
# Delete child rows before their catalog_tables parents so this also
# works if a prepare_connection plugin enables foreign key enforcement.
for table in (
"catalog_columns",
"catalog_foreign_keys",
"catalog_indexes",
"catalog_views",
"catalog_tables",
):
conn.execute(
f"DELETE FROM {table} WHERE database_name = ?",
[database_name],
)
conn.execute(
"""
INSERT OR REPLACE INTO catalog_databases (
database_name, path, is_memory, schema_version
) VALUES (?, ?, ?, ?)
""",
[
database_name,
str(db.path) if db.path is not None else None,
db.is_memory,
schema_version,
],
await internal_db.execute_write_many(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
conn.executemany(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
""",
columns_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
conn.executemany(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
""",
foreign_keys_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
conn.executemany(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
""",
columns_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
""",
foreign_keys_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
""",
indexes_to_insert,
)
await internal_db.execute_write_fn(replace_catalog)
""",
indexes_to_insert,
)

View file

@ -11,10 +11,15 @@ Supports:
import asyncio
import shutil
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
from urllib.parse import parse_qsl
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
DEFAULT_MAX_FIELDS = 1000
DEFAULT_MAX_FILES = 100
# If max_parts is not specified, it defaults to max_fields + max_files
DEFAULT_MAX_PARTS: int | None = None
DEFAULT_MAX_PARTS: Optional[int] = None
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
class MultipartParseError(Exception):
"""Raised when multipart parsing fails."""
pass
@dataclass
class UploadedFile:
@ -50,7 +57,7 @@ class UploadedFile:
name: str
filename: str
content_type: str | None
content_type: Optional[str]
size: int
_file: tempfile.SpooledTemporaryFile = field(repr=False)
@ -79,8 +86,7 @@ class UploadedFile:
def __del__(self):
try:
self._file.close()
except Exception: # noqa: BLE001, S110
# __del__ must never raise
except Exception:
pass
@ -92,27 +98,27 @@ class FormData:
"""
def __init__(self):
self._data: list[tuple[str, str | UploadedFile]] = []
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
def append(self, key: str, value: str | UploadedFile) -> None:
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
"""Add a key-value pair."""
self._data.append((key, value))
def __getitem__(self, key: str) -> str | UploadedFile:
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
"""Get the first value for a key."""
for k, v in self._data:
if k == key:
return v
raise KeyError(key)
def get(self, key: str, default: Any = None) -> str | UploadedFile | None:
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
"""Get the first value for a key, or default if not found."""
try:
return self[key]
except KeyError:
return default
def getlist(self, key: str) -> list[str | UploadedFile]:
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
"""Get all values for a key."""
return [v for k, v in self._data if k == key]
@ -136,15 +142,15 @@ class FormData:
"""Return unique keys."""
return list(self)
def items(self) -> list[tuple[str, str | UploadedFile]]:
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
"""Return all key-value pairs."""
return list(self._data)
def values(self) -> list[str | UploadedFile]:
def values(self) -> List[Union[str, UploadedFile]]:
"""Return all values."""
return [v for _, v in self._data]
def _uploaded_files(self) -> list[UploadedFile]:
def _uploaded_files(self) -> List[UploadedFile]:
"""Return UploadedFile instances contained in this form."""
return [v for _, v in self._data if isinstance(v, UploadedFile)]
@ -157,7 +163,7 @@ class FormData:
for uploaded in self._uploaded_files():
try:
uploaded.close_sync()
except Exception: # noqa: BLE001, S110
except Exception:
# Best-effort cleanup; ignore close errors
pass
@ -166,7 +172,7 @@ class FormData:
for uploaded in self._uploaded_files():
try:
await uploaded.close()
except Exception: # noqa: BLE001, S110
except Exception:
# Best-effort cleanup; ignore close errors
pass
@ -183,13 +189,13 @@ class FormData:
await self.aclose()
def parse_content_disposition(header: str) -> dict[str, str | None]:
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
"""
Parse Content-Disposition header value.
Returns dict with 'name', 'filename' keys (filename may be None).
"""
result: dict[str, str | None] = {"name": None, "filename": None}
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
# Split on semicolons, handling quoted strings
parts = []
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
from urllib.parse import unquote
result["filename"] = unquote(encoded, encoding="utf-8")
except Exception: # noqa: BLE001, S110
# Malformed RFC 5987 filename* - fall back to the plain filename
except Exception:
pass
continue
@ -245,19 +250,20 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
if key == "name":
result["name"] = value
# Only set filename if filename* hasn't already set it
elif key == "filename" and result["filename"] is None:
# Strip path components (security)
# Handle both Unix and Windows paths
value = value.replace("\\", "/")
if "/" in value:
value = value.rsplit("/", 1)[-1]
result["filename"] = value
elif key == "filename":
# Only set if filename* hasn't already set it
if result["filename"] is None:
# Strip path components (security)
# Handle both Unix and Windows paths
value = value.replace("\\", "/")
if "/" in value:
value = value.rsplit("/", 1)[-1]
result["filename"] = value
return result
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
"""
Parse Content-Type header value.
@ -301,7 +307,7 @@ class MultipartParser:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -342,12 +348,12 @@ class MultipartParser:
self._tempdir = tempfile.gettempdir()
# Current part state
self.current_headers: dict[str, str] = {}
self.current_file: tempfile.SpooledTemporaryFile | None = None
self.current_headers: Dict[str, str] = {}
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
self.current_body = bytearray()
self.current_name: str | None = None
self.current_filename: str | None = None
self.current_content_type: str | None = None
self.current_name: Optional[str] = None
self.current_filename: Optional[str] = None
self.current_content_type: Optional[str] = None
def feed(self, chunk: bytes) -> None:
"""Feed a chunk of data to the parser."""
@ -448,7 +454,7 @@ class MultipartParser:
# Parse header
try:
line_str = line.decode("utf-8", errors="replace")
except UnicodeDecodeError:
except Exception:
line_str = line.decode("latin-1")
if ":" in line_str:
@ -475,9 +481,7 @@ class MultipartParser:
if self.file_count > self.max_files:
raise MultipartParseError("Too many files")
if self.handle_files:
# Outlives this method - it is filled in across parser callbacks
# and then handed to the UploadedFile the caller consumes
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
self.current_file = tempfile.SpooledTemporaryFile(
max_size=self.max_memory_file_size
)
else:
@ -640,7 +644,7 @@ async def parse_form_data(
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,

View file

@ -2,9 +2,8 @@
from __future__ import annotations
import json
from typing import Any, Dict, Iterable, List, Sequence, Tuple
import sqlite3
from collections.abc import Iterable, Sequence
from typing import Any
from datasette.permissions import PermissionSQL
from datasette.plugins import pm
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
async def gather_permission_sql_from_hooks(
*, datasette, actor: dict | None, action: str
) -> list[PermissionSQL] | object:
) -> List[PermissionSQL] | object:
"""Collect PermissionSQL objects from the permission_resources_sql hook.
Ensures that each returned PermissionSQL has a populated ``source``.
@ -35,7 +34,7 @@ async def gather_permission_sql_from_hooks(
hookimpls = hook_caller.get_hookimpls()
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
collected: list[PermissionSQL] = []
collected: List[PermissionSQL] = []
actor_json = json.dumps(actor) if actor is not None else None
actor_id = actor.get("id") if isinstance(actor, dict) else None
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
if isinstance(result, PermissionSQL):
return [result]
if isinstance(result, (list, tuple)):
collected: list[PermissionSQL] = []
collected: List[PermissionSQL] = []
for item in result:
collected.extend(_iter_permission_sql_from_result(item, action=action))
return collected
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
def build_rules_union(
actor: dict | None, plugins: Sequence[PermissionSQL]
) -> tuple[str, dict[str, Any]]:
) -> Tuple[str, Dict[str, Any]]:
"""
Compose plugin SQL into a UNION ALL.
@ -103,10 +102,10 @@ def build_rules_union(
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
"""
parts: list[str] = []
parts: List[str] = []
actor_json = json.dumps(actor) if actor else None
actor_id = actor.get("id") if actor else None
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
for p in plugins:
# No namespacing - just use plugin params as-is
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
plugins: Sequence[Any],
action: str,
candidate_sql: str,
candidate_params: dict[str, Any] | None = None,
candidate_params: Dict[str, Any] | None = None,
*,
implicit_deny: bool = True,
) -> list[dict[str, Any]]:
) -> List[Dict[str, Any]]:
"""
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
@ -169,8 +168,8 @@ async def resolve_permissions_from_catalog(
- parent, child, allow, reason, source_plugin, depth
- resource (rendered "/parent/child" or "/parent" or "/")
"""
resolved_plugins: list[PermissionSQL] = []
restriction_sqls: list[str] = []
resolved_plugins: List[PermissionSQL] = []
restriction_sqls: List[str] = []
for plugin in plugins:
if callable(plugin) and not isinstance(plugin, PermissionSQL):
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
db,
actor: dict | None,
plugins: Sequence[Any],
candidates: list[tuple[str, str | None]],
candidates: List[Tuple[str, str | None]],
action: str,
*,
implicit_deny: bool = True,
) -> list[dict[str, Any]]:
) -> List[Dict[str, Any]]:
"""
Resolve permissions without any external candidate table by embedding
the candidates as a UNION of parameterized SELECTs in a CTE.
@ -412,8 +411,8 @@ async def resolve_permissions_with_candidates(
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
"""
# Build a small CTE for candidates.
cand_rows_sql: list[str] = []
cand_params: dict[str, Any] = {}
cand_rows_sql: List[str] = []
cand_params: Dict[str, Any] = {}
for i, (parent, child) in enumerate(candidates):
pkey = f"cand_p_{i}"
ckey = f"cand_c_{i}"

View file

@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
"""
import os
from shutil import Error, copy, copy2, copystat
from shutil import copy, copy2, copystat, Error
def _copytree(

View file

@ -1,8 +1,6 @@
import sys
from dataclasses import dataclass
from typing import Literal
from datasette.utils import escape_sqlite
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
SQLOperation = Literal[
@ -197,16 +195,6 @@ def _allow_authorizer_action(*args):
return sqlite3.SQLITE_OK
def _disable_authorizer(conn):
# Python 3.11 added support for unregistering an authorizer using None.
# On Python 3.10, None is installed as the callback instead, and the next
# statement fails with "not authorized" when sqlite3 tries to call it.
if sys.version_info >= (3, 11):
conn.set_authorizer(None)
else:
conn.set_authorizer(_allow_authorizer_action)
def analyze_sql_tables(
conn,
sql: str,
@ -220,9 +208,7 @@ def analyze_sql_tables(
This function is synchronous and connection-based. It temporarily installs a
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
callbacks observed while SQLite compiles the statement. ``CREATE VIEW`` is
additionally executed inside a rolled-back savepoint so its source-table reads
can be discovered by analyzing a query against the temporary view.
callbacks observed while SQLite compiles the statement.
"""
operations: dict[OperationKey, set[str]] = {}
@ -427,12 +413,12 @@ def analyze_sql_tables(
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=f"{arg1} {arg2}" if arg2 is not None else arg1,
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
source=source,
)
return sqlite3.SQLITE_OK
action_name = _AUTHORIZER_ACTION_NAMES.get(action, f"SQLITE_{action}")
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
record(
"unknown",
"unknown",
@ -495,7 +481,7 @@ def analyze_sql_tables(
conn, key.table, schema=key.sqlite_schema
)
finally:
_disable_authorizer(conn)
conn.set_authorizer(None)
has_schema_operation = any(
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
@ -535,7 +521,9 @@ def analyze_sql_tables(
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
):
return True
return bool(key_is_drop_table_delete(key))
if key_is_drop_table_delete(key):
return True
return False
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
if (
@ -546,7 +534,7 @@ def analyze_sql_tables(
return None
return table_kind_cache[(key.sqlite_schema, key.table)]
analysis = SQLAnalysis(
return SQLAnalysis(
operations=tuple(
Operation(
operation=key.operation,
@ -563,58 +551,3 @@ def analyze_sql_tables(
for key, columns in operations.items()
)
)
# SQLite does not resolve the SELECT body of a view when preparing CREATE
# VIEW, so its authorizer does not report reads from the view's source
# tables. Temporarily create the view, analyze a query against it (which
# does resolve the body), then roll the schema change back. Database-level
# callers use an isolated writable connection for this analysis.
create_view_operations = tuple(
operation
for operation in analysis.operations
if operation.operation == "create" and operation.target_type == "view"
)
if not create_view_operations:
return analysis
savepoint = "datasette_analyze_create_view"
conn.execute(f"SAVEPOINT {savepoint}")
try:
conn.execute(sql, params if params is not None else {})
dependency_reads = []
for view_operation in create_view_operations:
if view_operation.sqlite_schema is None or view_operation.table is None:
raise sqlite3.OperationalError(
"Could not determine the created view name"
)
quoted_schema = escape_sqlite(view_operation.sqlite_schema)
quoted_view = escape_sqlite(view_operation.table)
qualified_view = f"{quoted_schema}.{quoted_view}"
view_analysis = analyze_sql_tables(
conn,
f"SELECT * FROM {qualified_view}",
database_name=database_name,
schema_to_database=schema_to_database,
)
dependency_reads.extend(
operation
for operation in view_analysis.operations
if operation.operation == "read"
and not (
operation.sqlite_schema == view_operation.sqlite_schema
and operation.table == view_operation.table
)
)
finally:
conn.execute(f"ROLLBACK TO {savepoint}")
conn.execute(f"RELEASE {savepoint}")
existing_operations = set(analysis.operations)
return SQLAnalysis(
operations=analysis.operations
+ tuple(
operation
for operation in dependency_reads
if operation not in existing_operations
)
)

View file

@ -15,17 +15,8 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
_cached_sqlite_version = None
_cached_supports_returning = None
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
_SQLITE_IDENTIFIER_RE = (
r"""(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`(?:[^`]|``)*`|\[[^\]]*\]|[^\s.()'"`\[\]]+)"""
)
_VIRTUAL_TABLE_MODULE_RE = re.compile(
r"^\s*CREATE\s+VIRTUAL\s+TABLE\b\s*(?:IF\s+NOT\s+EXISTS\s+)?"
+ _SQLITE_IDENTIFIER_RE
+ r"(?:\s*\.\s*"
+ _SQLITE_IDENTIFIER_RE
+ r")?\s*\bUSING\b\s*("
+ _SQLITE_IDENTIFIER_RE
+ r")",
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
re.IGNORECASE | re.DOTALL,
)
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
@ -92,58 +83,24 @@ def sqlite_table_type(
) -> SQLiteTableType | None:
if supports_table_list():
try:
# Use the "PRAGMA table_list" statement form rather than the
# pragma_table_list(...) table-valued function. The
# table-valued function is resolved like an ordinary relation
# name, so an attacker-created table or view literally named
# "pragma_table_list" can shadow it and spoof the reported
# type (e.g. claiming a virtual table is an ordinary table).
# The PRAGMA statement form is a distinct piece of SQL syntax
# that always invokes SQLite's built-in pragma, so it cannot
# be shadowed by a user-created relation.
query = "select type from pragma_table_list where name = ?"
params: tuple[str, ...] = (table,)
if schema is not None:
query = f"PRAGMA {_quote_identifier(schema)}.table_list"
else:
query = "PRAGMA table_list"
cursor = conn.execute(query)
columns = [description[0] for description in cursor.description]
for row in cursor.fetchall():
record = dict(zip(columns, row))
if record.get("name") != table:
continue
if schema is not None and record.get("schema") != schema:
continue
row_type = record.get("type")
if row_type in {"table", "view", "virtual", "shadow"}:
return row_type
query += " and schema = ?"
params = (table, schema)
row = conn.execute(query, params).fetchone()
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
return row[0]
except sqlite3.DatabaseError:
pass
return _sqlite_table_type_from_schema(conn, table, schema=schema)
def check_structured_write_table(conn, table: str, *, allow_missing=False):
"""Validate a row-write target on the connection that will perform the write."""
# SQLite resolves identifiers case-insensitively. The create API must not
# treat a differently cased existing name as a missing table.
row = conn.execute(
"select name from main.sqlite_master where name = ? collate nocase "
"and type in ('table', 'view')",
(table,),
).fetchone()
if row is None and allow_missing:
return
if row is not None and sqlite_table_type(conn, row[0]) == "table":
return
# Virtual table modules can interpret row writes as administrative operations.
# Their shadow tables are internal storage, not independently writable data.
raise ValueError("Structured writes require an ordinary table")
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'"
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return []
@ -161,63 +118,6 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
return sorted(hidden_tables) + content_fts_tables
def sqlite_derived_table_dependencies(
conn, *, schema: str | None = "main"
) -> dict[str, str]:
"""Return implementation table -> logical/content table dependencies.
``PRAGMA table_list`` safely identifies virtual and shadow tables, but
does not report which virtual table owns a shadow table or which table is
named by an FTS ``content=`` option. Derive those relationships from
``sqlite_master`` DDL and the documented shadow-table suffixes.
Database errors propagate: failed discovery must not be mistaken for an
empty dependency map and cached as permission to skip inheritance.
"""
schema_table = _sqlite_schema_table(schema)
rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'"
).fetchall()
table_names = {row[0] for row in rows}
# SQLite identifiers fold ASCII letters only.
identifier_case = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
)
canonical_names = {name.translate(identifier_case): name for name in table_names}
dependencies = {}
for virtual_table, sql in rows:
module = _virtual_table_module(sql)
if module is None:
continue
# SQLite's documented shadow tables are implementation details of
# their logical virtual table.
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
shadow_table = virtual_table + suffix
if shadow_table in table_names:
dependencies[shadow_table] = virtual_table
# An external-content FTS table can expose values fetched from its
# content table, so it must also depend on that table's permission.
if module in {"fts3", "fts4", "fts5"}:
content_table = _fts_external_content_table(sql)
if content_table:
dependencies[virtual_table] = content_table
if module in {"fts5vocab", "fts4aux"}:
source = _fts_vocabulary_source(sql, module, schema or "main")
source = (
canonical_names.get(source.translate(identifier_case))
if source
else None
)
# An unresolved source is itself derived, so the one-hop policy denies it.
dependencies[virtual_table] = source or virtual_table
return dependencies
def _sqlite_table_type_from_schema(
conn,
table: str,
@ -227,7 +127,7 @@ def _sqlite_table_type_from_schema(
schema_table = _sqlite_schema_table(schema)
try:
row = conn.execute(
f"select type, sql from {schema_table} where name = ?",
"select type, sql from {} where name = ?".format(schema_table),
(table,),
).fetchone()
except sqlite3.DatabaseError:
@ -255,7 +155,7 @@ def _is_known_shadow_table(
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'"
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return False
@ -274,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
return "sqlite_master"
if schema == "temp":
return "sqlite_temp_master"
return f"{_quote_identifier(schema)}.sqlite_master"
return "{}.sqlite_master".format(_quote_identifier(schema))
def _quote_identifier(value: str) -> str:
@ -284,151 +184,10 @@ def _quote_identifier(value: str) -> str:
def _virtual_table_module(sql: str | None) -> str | None:
if not sql:
return None
match = _VIRTUAL_TABLE_MODULE_RE.search(_strip_sql_comments(sql))
if match is None:
return None
return _unquote_sql_value(match.group(1)).lower()
def _fts_external_content_table(sql: str | None) -> str | None:
"""Extract the external ``content=`` table from an FTS declaration."""
if not sql:
return None
sql = _strip_sql_comments(sql)
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
if match is None:
return None
open_paren = sql.find("(", match.end())
if open_paren == -1:
return None
close_paren = sql.rfind(")")
if close_paren <= open_paren:
return None
for argument in _split_sql_arguments(sql[open_paren + 1 : close_paren]):
key, separator, value = argument.partition("=")
if not separator or key.strip().lower() != "content":
continue
return _unquote_sql_value(value.strip())
return None
def _fts_vocabulary_source(sql: str, module: str, schema: str) -> str | None:
"""Resolve a vocabulary source within the current SQLite schema.
Cross-schema sources cannot be represented by the dependency map and
are conservatively left unresolved.
"""
sql = _strip_sql_comments(sql)
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
if match is None:
return None
start = sql.find("(", match.end())
end = sql.rfind(")")
if start < 0 or end <= start:
return None
arguments = [
_unquote_sql_value(arg.strip())
for arg in _split_sql_arguments(sql[start + 1 : end])
]
expected = 2 if module == "fts5vocab" else 1
if len(arguments) == expected:
return arguments[0]
if len(arguments) == expected + 1 and arguments[0].lower() == schema.lower():
return arguments[1]
return None
def _split_sql_arguments(arguments: str) -> list[str]:
"""Split comma-separated SQLite arguments without splitting quoted text."""
parts = []
start = 0
quote = None
closing_quote = None
index = 0
while index < len(arguments):
char = arguments[index]
if quote is None:
if char in {"'", '"', "`", "["}:
quote = char
closing_quote = "]" if char == "[" else char
elif char == ",":
parts.append(arguments[start:index])
start = index + 1
elif char == closing_quote:
# Single/double/backtick quoting escapes the delimiter by
# doubling it. Square-bracket identifiers do not.
if (
quote != "["
and index + 1 < len(arguments)
and arguments[index + 1] == closing_quote
):
index += 1
else:
quote = None
closing_quote = None
index += 1
parts.append(arguments[start:])
return parts
def _strip_sql_comments(sql: str) -> str:
"""Remove SQLite comments while preserving quoted strings/identifiers."""
output = []
quote = None
closing_quote = None
index = 0
while index < len(sql):
char = sql[index]
next_char = sql[index + 1] if index + 1 < len(sql) else ""
if quote is None:
if char in {"'", '"', "`", "["}:
quote = char
closing_quote = "]" if char == "[" else char
output.append(char)
elif char == "-" and next_char == "-":
index += 2
while index < len(sql) and sql[index] not in "\r\n":
index += 1
output.append(" ")
continue
elif char == "/" and next_char == "*":
index += 2
while index + 1 < len(sql) and sql[index : index + 2] != "*/":
index += 1
index = min(index + 2, len(sql))
output.append(" ")
continue
else:
output.append(char)
else:
output.append(char)
if char == closing_quote:
if (
quote != "["
and index + 1 < len(sql)
and sql[index + 1] == closing_quote
):
output.append(sql[index + 1])
index += 1
else:
quote = None
closing_quote = None
index += 1
return "".join(output)
def _unquote_sql_value(value: str) -> str:
if len(value) < 2:
return value
pairs = {"'": "'", '"': '"', "`": "`", "[": "]"}
closing = pairs.get(value[0])
if closing is None or value[-1] != closing:
return value
unquoted = value[1:-1]
if value[0] != "[":
unquoted = unquoted.replace(closing * 2, closing)
return unquoted
return match.group(1).strip("\"'[]`").lower()
def _is_fts_content_virtual_table(sql: str | None) -> bool:

View file

@ -1,10 +1,9 @@
import json
from urllib.parse import urlencode
from asgiref.sync import async_to_sync
from urllib.parse import urlencode
import json
# These wrapper classes pre-date the introduction of
# datasette.client and httpx2 to Datasette. They could
# datasette.client and httpx to Datasette. They could
# be removed if the Datasette tests are modified to
# call datasette.client directly.

View file

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

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass
import dataclasses
import types
import typing
from dataclasses import dataclass
@dataclass(frozen=True)
@ -74,14 +74,16 @@ class Context:
extra_class = table_extra_registry.classes_by_name[name]
except KeyError:
raise KeyError(
f"{cls.__name__}.{name} is declared with from_extra() but there is no "
"registered extra of that name"
"{}.{} is declared with from_extra() but there is no "
"registered extra of that name".format(cls.__name__, name)
)
if cls.extras_scope is not None and not extra_class.available_for(
cls.extras_scope
):
raise ValueError(
f"{cls.__name__}.{name} is declared with from_extra() but the {name} extra is "
f"not available for scope {cls.extras_scope}"
"{}.{} is declared with from_extra() but the {} extra is "
"not available for scope {}".format(
cls.__name__, name, name, cls.extras_scope
)
)
return extra_class.description or ""

View file

@ -2,20 +2,20 @@ import csv
import hashlib
import sys
from datasette.utils.asgi import Request
from datasette.utils import (
add_cors_headers,
EscapeHtmlWriter,
InvalidSql,
LimitedWriter,
add_cors_headers,
path_from_row_pks,
path_with_format,
sqlite3,
)
from datasette.utils.asgi import (
AsgiStream,
BadRequest,
Request,
Response,
BadRequest,
)
@ -129,10 +129,12 @@ class BaseView:
template = environment.select_template(templates)
template_context = {
**context,
"select_templates": [
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
**{
"select_templates": [
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
},
}
headers = {}
if self.has_json_alternate:
@ -149,7 +151,9 @@ class BaseView:
template_context["alternate_url_json"] = alternate_url_json
headers.update(
{
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}
)
return Response.html(
@ -180,7 +184,9 @@ async def stream_csv(datasette, fetch_data, request, database):
stream = request.args.get("_stream")
# Do not calculate facets or counts:
extra_parameters = [
f"{key}=1" for key in ("_nofacet", "_nocount") if not request.args.get(key)
"{}=1".format(key)
for key in ("_nofacet", "_nocount")
if not request.args.get(key)
]
if extra_parameters:
# Replace request object with a new one with modified scope
@ -210,6 +216,9 @@ async def stream_csv(datasette, fetch_data, request, database):
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
@ -316,9 +325,8 @@ async def stream_csv(datasette, fetch_data, request, database):
else:
new_row.append(cell)
await writer.writerow(new_row)
except Exception as ex: # noqa: BLE001
# Streaming CSV: report the error into the response body and stop
sys.stderr.write(f"Caught this error: {ex}\n")
except Exception as ex:
sys.stderr.write("Caught this error: {}\n".format(ex))
sys.stderr.flush()
await r.write(str(ex))
return

View file

@ -1,56 +1,49 @@
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import asyncio
import hashlib
import itertools
import json
import markupsafe
import os
import textwrap
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import markupsafe
from datasette.extras import extra_names_from_request, ExtraScope
from datasette.database import QueryInterrupted
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
InvalidSql,
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
error_body,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
format_bytes,
is_url,
make_slot_function,
tilde_decode,
to_css_class,
validate_sql_select,
is_url,
path_with_added_args,
path_with_format,
path_with_removed_args,
sqlite3,
tilde_decode,
to_css_class,
truncate_url,
validate_sql_select,
InvalidSql,
)
from datasette.utils import (
named_parameters as derive_named_parameters,
)
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
from datasette.write_sql import QueryWriteRejected
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from . import Context
from .base import DatasetteError, View, stream_csv
from .query_helpers import (
_block_framing,
_ensure_stored_query_execution_permissions,
_table_columns,
)
from .table_create_alter import _create_table_ui_context
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
table_extra_registry,
)
from .table_create_alter import _create_table_ui_context
from . import Context
@dataclass
@ -107,7 +100,7 @@ class DatabaseView(View):
return response
if format_ not in ("html", "json"):
raise NotFound(f"Invalid format: {format_}")
raise NotFound("Invalid format: {}".format(format_))
metadata = await datasette.get_database_metadata(database)
@ -171,7 +164,7 @@ class DatabaseView(View):
"label": "Create table",
"description": "Create a new table in this database.",
"attrs": {
"aria-label": f"Create table in {database}",
"aria-label": "Create table in {}".format(database),
"data-database-action": "create-table",
},
}
@ -278,7 +271,9 @@ class DatabaseView(View):
view_name="database",
),
headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
)
@ -561,7 +556,7 @@ async def database_download(request, datasette):
if datasette.cors:
add_cors_headers(headers)
if db.hash:
etag = f'"{db.hash}"'
etag = '"{}"'.format(db.hash)
headers["Etag"] = etag
# Has user seen this already?
if_none_match = request.headers.get("if-none-match")
@ -648,15 +643,8 @@ class QueryView(View):
ok = None
redirect_url = None
try:
execute_write_kwargs = {"request": request}
if stored_query.is_trusted:
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
if any(
operation.operation == "vacuum" for operation in analysis.operations
):
execute_write_kwargs["transaction"] = False
cursor = await db.execute_write(
stored_query.sql, params_for_query, **execute_write_kwargs
stored_query.sql, params_for_query, request=request
)
# success message can come from on_success_message or on_success_message_sql
message = None
@ -669,9 +657,8 @@ class QueryView(View):
).first()
if message_result:
message = message_result[0]
except Exception as ex: # noqa: BLE001
# Stored-query on_success_message_sql is user-authored
message = f"Error running on_success_message_sql: {ex}"
except Exception as ex:
message = "Error running on_success_message_sql: {}".format(ex)
message_type = datasette.ERROR
if not message:
if stored_query.on_success_message:
@ -685,8 +672,7 @@ class QueryView(View):
redirect_url = stored_query.on_success_redirect
ok = True
except Exception as ex: # noqa: BLE001
# Stored-query execution is user-authored SQL
except Exception as ex:
message = stored_query.on_error_message or str(ex)
message_type = datasette.ERROR
redirect_url = stored_query.on_error_redirect
@ -820,16 +806,16 @@ class QueryView(View):
rows = results.rows
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent(f"""
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""").strip(),
""".format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
@ -845,6 +831,8 @@ class QueryView(View):
columns = []
except (sqlite3.OperationalError, InvalidSql) as ex:
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
except sqlite3.OperationalError as ex:
raise DatasetteError(str(ex))
except DatasetteError:
raise
@ -861,13 +849,12 @@ class QueryView(View):
raise DatasetteError("?sql= is required", status=400)
async def fetch_data_for_csv(request, _next=None):
# Reuse the trusted magic parameter values prepared above.
results = await db.execute(sql, params_for_query, truncate=True)
results = await db.execute(sql, params, truncate=True)
data = {"rows": results.rows, "columns": results.columns}
return data, None, None
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers:
elif format_ in datasette.renderers.keys():
if not sql:
raise DatasetteError("?sql= is required", status=400)
data = {"ok": True, "rows": rows, "columns": columns}
@ -959,7 +946,9 @@ class QueryView(View):
}
headers.update(
{
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}
)
metadata = await query_metadata()
@ -1040,7 +1029,9 @@ class QueryView(View):
+ "?"
+ urlencode(
{
"sql": sql,
**{
"sql": sql,
},
**named_parameter_values,
}
)
@ -1142,11 +1133,9 @@ class QueryView(View):
headers=headers,
)
else:
assert False, f"Invalid format: {format_}"
assert False, "Invalid format: {}".format(format_)
if datasette.cors:
add_cors_headers(r.headers)
if stored_query_write and format_ == "html":
_block_framing(r)
return r
@ -1245,7 +1234,7 @@ async def display_rows(datasette, database, request, rows, columns):
'<a class="blob-download" href="{}"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format(
blob_url,
(
f' title="{formatted}"'
' title="{}"'.format(formatted)
if "bytes" not in formatted
else ""
),

View file

@ -1,7 +1,6 @@
import re
from urllib.parse import urlencode
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
from datasette.utils.asgi import Response
@ -9,8 +8,8 @@ from datasette.utils.asgi import Response
from .base import BaseView
from .database import display_rows as display_query_rows
from .query_helpers import (
SQL_PARAMETER_FORM_PREFIX,
QueryValidationError,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write,
_analysis_rows,
_analysis_rows_with_permissions,
@ -32,7 +31,15 @@ WRITE_TEMPLATE_LABELS = {
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "create table new_table (\n id integer primary key,\n name text\n -- created text default (datetime('now'))\n)"
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _parameter_names(columns):
@ -42,11 +49,11 @@ def _parameter_names(columns):
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value"
if base[0].isdigit():
base = f"p_{base}"
base = "p_{}".format(base)
name = base
index = 2
while name in seen:
name = f"{base}_{index}"
name = "{}_{}".format(base, index)
index += 1
seen.add(name)
names[column] = name
@ -58,7 +65,7 @@ def _quote_identifier(identifier):
def _preferred_where_column(table, columns):
lower_table_id = f"{table.lower()}_id"
lower_table_id = "{}_id".format(table.lower())
return (
next((column for column in columns if column.lower() == "id"), None)
or next(
@ -83,15 +90,17 @@ def _insert_template_sql(table, columns):
auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns:
return f"insert into {_quote_identifier(table)}\ndefault values"
return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns)
return "\n".join(
(
f"insert into {_quote_identifier(table)} (",
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns),
"insert into {} (".format(_quote_identifier(table)),
",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")",
"values (",
",\n".join(f" :{names[column]}" for column in insert_columns),
",\n".join(" :{}".format(names[column]) for column in insert_columns),
")",
)
)
@ -105,14 +114,18 @@ def _update_template_sql(table, columns):
if not set_columns:
return "\n".join(
(
f"update {_quote_identifier(table)}",
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}",
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
"update {}".format(_quote_identifier(table)),
"set {} = :new_{}".format(
_quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
return "\n".join(
(
f"update {_quote_identifier(table)}",
"update {}".format(_quote_identifier(table)),
"set "
+ ",\n".join(
"{}{} = :{}".format(
@ -122,7 +135,9 @@ def _update_template_sql(table, columns):
)
for index, column in enumerate(set_columns)
),
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
@ -133,8 +148,10 @@ def _delete_template_sql(table, columns):
where_column = _preferred_where_column(table, column_names)
return "\n".join(
(
f"delete from {_quote_identifier(table)}",
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
"delete from {}".format(_quote_identifier(table)),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
@ -385,7 +402,7 @@ class ExecuteWriteView(BaseView):
try:
execute_write_kwargs = {"request": request}
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
except (QueryInterrupted, sqlite3.DatabaseError) as ex:
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
return _block_framing(Response.error([message], 400))

View file

@ -2,11 +2,11 @@ import json
from datasette.plugins import pm
from datasette.utils import (
UNSTABLE_API_MESSAGE,
CustomJSONEncoder,
add_cors_headers,
await_me_maybe,
make_slot_function,
CustomJSONEncoder,
UNSTABLE_API_MESSAGE,
)
from datasette.utils.asgi import Response
from datasette.version import __version__
@ -46,15 +46,15 @@ class IndexView(BaseView):
databases = []
# Iterate over allowed databases instead of all databases
for name, allowed_db in allowed_db_dict.items():
for name in allowed_db_dict.keys():
db = self.ds.databases[name]
database_private = allowed_db.private
database_private = allowed_db_dict[name].private
# Get allowed tables/views for this database
allowed_for_db = tables_by_db.get(name, {})
# Get table names from allowed set instead of db.table_names()
table_names = [child_name for child_name in allowed_for_db]
table_names = [child_name for child_name in allowed_for_db.keys()]
hidden_table_names = set(await db.hidden_table_names())
@ -99,7 +99,7 @@ class IndexView(BaseView):
# We will be sorting by number of relationships, so populate that field
all_foreign_keys = await db.get_all_foreign_keys()
for table, foreign_keys in all_foreign_keys.items():
if table in tables:
if table in tables.keys():
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
tables[table]["num_relationships_for_sorting"] = count
@ -121,7 +121,8 @@ class IndexView(BaseView):
# Only add views if this is less than TRUNCATE_AT
if len(tables_and_views_truncated) < TRUNCATE_AT:
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
tables_and_views_truncated.extend(views[:num_views_to_add])
for view in views[:num_views_to_add]:
tables_and_views_truncated.append(view)
databases.append(
{

View file

@ -5,19 +5,6 @@ from datasette.resources import DatabaseResource
from datasette.stored_queries import (
StoredQuery,
)
from datasette.utils import (
InvalidSql,
escape_sqlite,
parse_size_limit,
path_from_row_pks,
sqlite3,
validate_sql_select,
)
from datasette.utils import (
named_parameters as derive_named_parameters,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
from datasette.write_sql import (
IgnoreWriteSqlOperation,
QueryWriteRejected,
@ -25,6 +12,17 @@ from datasette.write_sql import (
decision_for_write_sql_operation,
operation_is_write,
)
from datasette.utils import (
parse_size_limit,
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
sqlite3,
validate_sql_select,
InvalidSql,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
_query_name_re = re.compile(r"^[^/\.\n]+$")
@ -93,7 +91,7 @@ def _as_optional_bool(value, name):
return True
if lowered in {"0", "false", "f", "no", "off"}:
return False
raise QueryValidationError(f"{name} must be 0 or 1")
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default, maximum):
@ -173,7 +171,7 @@ async def _json_or_form_payload(request):
try:
return json.loads(body or b"{}"), True
except json.JSONDecodeError as e:
raise QueryValidationError(f"Invalid JSON: {e}")
raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False
@ -194,7 +192,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis)
if is_write:
@ -295,7 +293,8 @@ def _coerce_execute_write_payload(data, is_json):
for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}:
continue
key = key.removeprefix(SQL_PARAMETER_FORM_PREFIX)
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value
if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary")
@ -315,7 +314,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis):
raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes"
@ -497,7 +496,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
)
try:
result = await db.execute(
f"select {select} from {escape_sqlite(table)} where rowid = ?",
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid],
)
except sqlite3.DatabaseError:

View file

@ -8,37 +8,34 @@ from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted
from datasette.events import DeleteRowEvent, UpdateRowEvent
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
from .base import BaseView, DatasetteError, stream_csv
from datasette.utils import (
CustomJSONEncoder,
CustomRow,
InvalidSql,
WriteJsonValueError,
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
decode_write_json_row,
escape_sqlite,
InvalidSql,
make_slot_function,
path_from_row_pks,
path_with_format,
path_with_removed_args,
sqlite3,
tilde_decode,
to_css_class,
escape_sqlite,
sqlite3,
WriteJsonValueError,
)
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import check_structured_write_table
from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope
from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
from .table import (
_table_page_data,
display_columns_and_rows,
_table_page_data,
row_label_from_label_column,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@ -139,12 +136,6 @@ class RowContext(Context):
)
async def _database_and_table_resource_from_request(datasette, request):
db = await datasette.resolve_database(request)
table = tilde_decode(request.url_vars["table"])
return db, table, TableResource(database=db.name, table=table)
class RowView(BaseView):
name = "row"
@ -196,16 +187,16 @@ class RowView(BaseView):
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent(f"""
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""").strip(),
""".format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
@ -216,13 +207,15 @@ class RowView(BaseView):
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
if format_ in self.ds.renderers:
if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
@ -265,13 +258,13 @@ class RowView(BaseView):
if status_code is not None:
response.status = status_code
else:
raise NotFound(f"Invalid format: {format_}")
raise NotFound("Invalid format: {}".format(format_))
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(response, ttl, request)
return self.set_response_headers(response, ttl)
async def html(self, request, data, extra_template_data, templates):
extras = {}
@ -380,54 +373,42 @@ class RowView(BaseView):
view_name=self.name,
),
headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
)
def set_response_headers(self, response, ttl, request=None):
private = getattr(request, "_datasette_private_response", False)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
if private:
# This response is only visible to the current actor (denied
# to anonymous requests), so it must never be stored by a
# shared cache/CDN - and ?_ttl= must not override that.
response.headers["Cache-Control"] = "private, no-store"
response.headers["Vary"] = "Cookie"
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def data(self, request, default_labels=False):
db, table, resource = await _database_and_table_resource_from_request(
self.ds, request
)
resolved = await self.ds.resolve_row(request)
db = resolved.db
database = db.name
table = resolved.table
pk_values = resolved.pk_values
# Check the URL resource before resolving the row, so a denied request
# cannot distinguish an existing primary key from a missing one.
# Ensure user has permission to view this row
visible, private = await self.ds.check_visibility(
request.actor,
action="view-table",
resource=resource,
resource=TableResource(database=database, table=table),
)
if not visible:
raise Forbidden("You do not have permission to view this table")
# Record whether this response is private (visible to this actor
# only) so set_response_headers() can set appropriate Cache-Control
# headers, regardless of which output format ends up being rendered.
request._datasette_private_response = private
resolved = await self.ds.resolve_row(request)
pk_values = resolved.pk_values
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
@ -504,8 +485,8 @@ class RowView(BaseView):
for row in display_rows:
for cell in row:
if cell["column"] in pk_set:
cell["value"] = markupsafe.Markup("<strong>{}</strong>").format(
cell["value"]
cell["value"] = markupsafe.Markup(
"<strong>{}</strong>".format(cell["value"])
)
label_column = await db.label_column_for_table(table) if is_table else None
@ -519,7 +500,7 @@ class RowView(BaseView):
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = f"{pk_path} {row_label}"
row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {}
if is_table and db.is_mutable:
@ -532,7 +513,7 @@ class RowView(BaseView):
row_actions = []
if row_action_permissions.get("update-row"):
attrs = {
"aria-label": f"Edit row {row_action_label}",
"aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "edit",
}
@ -548,7 +529,7 @@ class RowView(BaseView):
)
if row_action_permissions.get("delete-row"):
attrs = {
"aria-label": f"Delete row {row_action_label}",
"aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "delete",
}
@ -578,7 +559,7 @@ class RowView(BaseView):
"private": private,
"columns": reordered_columns,
"foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values, actor=request.actor
database, table, pk_values
),
"database_color": db.color,
"display_columns": display_columns,
@ -655,23 +636,12 @@ class RowView(BaseView):
),
)
async def foreign_key_tables(self, database, table, pk_values, *, actor):
async def foreign_key_tables(self, database, table, pk_values):
if len(pk_values) != 1:
return []
db = self.ds.databases[database]
all_foreign_keys = await db.get_all_foreign_keys()
foreign_keys = []
table_permissions = {}
for fk in all_foreign_keys[table]["incoming"]:
other_table = fk["other_table"]
if other_table not in table_permissions:
table_permissions[other_table] = await self.ds.allowed(
action="view-table",
resource=TableResource(database=database, table=other_table),
actor=actor,
)
if table_permissions[other_table]:
foreign_keys.append(fk)
foreign_keys = all_foreign_keys[table]["incoming"]
if len(foreign_keys) == 0:
return []
@ -709,7 +679,7 @@ class RowView(BaseView):
key,
",".join(pk_values),
)
foreign_key_tables.append({**fk, "count": count, "link": link})
foreign_key_tables.append({**fk, **{"count": count, "link": link}})
return foreign_key_tables
@ -728,57 +698,38 @@ def _truncated_row_flash_label(label):
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _row_flash_message(
datasette, request, action, resolved, row=None, *, refresh_row=False
):
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
# Mutation permission does not grant access to stored row labels.
if not await datasette.allowed(
action="view-table",
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return f"{action} row {pk_label}"
if refresh_row and row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
row = results.first()
label_column = await resolved.db.label_column_for_table(resolved.table)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return f"{action} row {pk_label} ({label})"
return f"{action} row {pk_label}"
return "{} row {} ({})".format(action, pk_label, label)
return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound
try:
_, _, resource = await _database_and_table_resource_from_request(
datasette, request
)
except DatabaseNotFound as e:
return False, Response.error([f"Database not found: {e.database_name}"], 404)
# Check the URL resource before resolving the row, so a denied request
# cannot distinguish an existing primary key from a missing one.
if not await datasette.allowed(
action=permission,
resource=resource,
actor=request.actor,
):
return False, Response.error(["Permission denied"], 403)
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
try:
resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e:
return False, Response.error(
["Database not found: {}".format(e.database_name)], 404
)
except TableNotFound as e:
return False, Response.error([f"Table not found: {e.table}"], 404)
return False, Response.error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e:
return False, Response.error([f"Record not found: {e.pk_values}"], 404)
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row
if not await datasette.allowed(
action=permission,
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return False, Response.error(["Permission denied"], 403)
return True, resolved
@ -798,13 +749,11 @@ class RowDeleteView(BaseView):
# Delete table
def delete_row(conn):
check_structured_write_table(conn, resolved.table)
sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)
try:
await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e: # noqa: BLE001
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
except Exception as e:
return Response.error([str(e)], 400)
await self.ds.track_event(
@ -820,7 +769,7 @@ class RowDeleteView(BaseView):
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
self.ds.add_message(
request,
await _row_flash_message(self.ds, request, "Deleted", resolved),
await _row_flash_message(resolved.db, "Deleted", resolved),
self.ds.INFO,
)
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
@ -844,7 +793,7 @@ class RowUpdateView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"])
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
@ -881,27 +830,18 @@ class RowUpdateView(BaseView):
return Response.error(["Permission denied for alter-table"], 403)
def update_row(conn):
check_structured_write_table(conn, resolved.table)
sqlite_utils.Database(conn)[resolved.table].update(
resolved.pk_values, update, alter=alter
)
try:
await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e: # noqa: BLE001
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
except Exception as e:
return Response.error([str(e)], 400)
result = {"ok": True}
returned_row = None
# Only read back and disclose the stored row if the actor is also
# allowed to view this table - update-row alone must not be usable
# to read data the actor cannot otherwise see.
if data.get("return") and await self.ds.allowed(
action="view-table",
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
if data.get("return"):
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
@ -918,15 +858,16 @@ class RowUpdateView(BaseView):
)
if request.args.get("_message"):
message_row = returned_row
if message_row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
message_row = results.first()
self.ds.add_message(
request,
await _row_flash_message(
self.ds,
request,
"Updated",
resolved,
row=returned_row,
refresh_row=True,
resolved.db, "Updated", resolved, row=message_row
),
self.ds.INFO,
)

View file

@ -1,25 +1,23 @@
import json
import logging
import secrets
import urllib
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
UNSTABLE_API_MESSAGE,
actor_matches_allow,
parse_size_limit,
add_cors_headers,
await_me_maybe,
error_body,
parse_size_limit,
tilde_decode,
tilde_encode,
tilde_decode,
)
from datasette.utils.asgi import Forbidden, Response
from .base import BaseView, View
import secrets
import urllib
logger = logging.getLogger(__name__)
@ -181,7 +179,9 @@ class AutocompleteDebugView(BaseView):
)
context.update(
{
"autocomplete_url": f"{self.ds.urls.table(database_name, table_name)}/-/autocomplete",
"autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name),
}
)
@ -311,7 +311,6 @@ class AllowedResourcesView(BaseView):
has_json_alternate = False
async def get(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
await self.ds.refresh_schemas()
# Check if user has permissions-debug (to show sensitive fields)
@ -421,11 +420,8 @@ class AllowedResourcesView(BaseView):
row["reason"] = resource.reasons
allowed_rows.append(row)
except Exception: # noqa: BLE001
# Returns empty results if the catalog tables don't exist yet, but
# also swallows the AttributeError raised for instance-level actions
# such as view-instance, which have no resource_class.
# TODO: handle that case explicitly and narrow this to sqlite3.Error
except Exception:
# If catalog tables don't exist yet, return empty results
return (
{
"ok": True,
@ -527,7 +523,7 @@ class PermissionRulesView(BaseView):
from datasette.utils.actions_sql import build_permission_rules_sql
union_sql, union_params, _restriction_sqls = await build_permission_rules_sql(
union_sql, union_params, restriction_sqls = await build_permission_rules_sql(
self.ds, actor, action
)
await self.ds.refresh_schemas()
@ -604,7 +600,7 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking and explaining a permission decision."""
"""Shared logic for checking permissions. Returns a dict with check results."""
if action not in ds.actions:
return error_body(f"Unknown action: {action}", 404), 404
@ -633,28 +629,15 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
from datasette.utils.actions_sql import explain_permission_for_resource
explanation = await explain_permission_for_resource(
datasette=ds,
actor=actor,
action=action,
parent=parent,
child=child,
)
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"action": action,
"allowed": bool(allowed),
"actor": actor,
"resource": {
"parent": parent,
"child": child,
"path": _resource_path(parent, child),
},
"explanation": explanation,
}
if actor and "id" in actor:
@ -672,25 +655,11 @@ class PermissionCheckView(BaseView):
as_format = request.url_vars.get("format")
if not as_format:
actions = [
{
"name": action.name,
"description": action.description,
"takes_parent": action.takes_parent,
"takes_child": action.takes_child,
"also_requires": action.also_requires,
}
for action in sorted(
self.ds.actions.values(), key=lambda action: action.name
)
]
return await self.render(
["debug_check.html"],
request,
{
"actions": actions,
"actor_json": request.args.get("actor")
or json.dumps(request.actor, indent=2),
"sorted_actions": sorted(self.ds.actions.keys()),
"has_debug_permission": True,
},
)
@ -702,18 +671,9 @@ class PermissionCheckView(BaseView):
parent = request.args.get("parent")
child = request.args.get("child")
actor = request.actor
actor_json = request.args.get("actor")
if actor_json is not None:
try:
actor = json.loads(actor_json)
except json.JSONDecodeError as ex:
return Response.error(f"Invalid actor JSON: {ex}", 400)
if actor is not None and not isinstance(actor, dict):
return Response.error("actor must be a JSON object or null", 400)
response, status = await _check_permission_for_actor(
self.ds, action, parent, child, actor
self.ds, action, parent, child, request.actor
)
return Response.json(response, status=status)
@ -797,8 +757,6 @@ class CreateTokenView(BaseView):
raise Forbidden(
"Token authentication cannot be used to create additional tokens"
)
if "_r" in request.actor:
raise Forbidden("Restricted actors cannot create API tokens")
async def shared(self, request):
self.check_permission(request)
@ -876,11 +834,6 @@ class CreateTokenView(BaseView):
else:
errors.append("Invalid expire duration unit")
if errors:
context = await self.shared(request)
context["errors"] = errors
return await self.render(["create_token.html"], request, context)
# Are there any restrictions?
from datasette.tokens import TokenRestrictions
@ -947,7 +900,7 @@ class ApiExplorerView(BaseView):
tables.append({"name": table, "links": table_links})
table_links.append(
{
"label": f"Get rows for {table}",
"label": "Get rows for {}".format(table),
"method": "GET",
"path": self.ds.urls.table(name, table, format="json"),
}
@ -967,7 +920,7 @@ class ApiExplorerView(BaseView):
{
"path": self.ds.urls.table(name, table) + "/-/insert",
"method": "POST",
"label": f"Insert rows into {table}",
"label": "Insert rows into {}".format(table),
"json": {
"rows": [
{
@ -981,7 +934,7 @@ class ApiExplorerView(BaseView):
{
"path": self.ds.urls.table(name, table) + "/-/upsert",
"method": "POST",
"label": f"Upsert rows into {table}",
"label": "Upsert rows into {}".format(table),
"json": {
"rows": [
{
@ -1011,7 +964,7 @@ class ApiExplorerView(BaseView):
table_links.append(
{
"path": self.ds.urls.table(name, table) + "/-/drop",
"label": f"Drop table {table}",
"label": "Drop table {}".format(table),
"json": {"confirm": False},
"method": "POST",
}
@ -1028,7 +981,7 @@ class ApiExplorerView(BaseView):
database_links.append(
{
"path": self.ds.urls.database(name) + "/-/create",
"label": f"Create table in {name}",
"label": "Create table in {}".format(name),
"json": {
"table": "new_table",
"columns": [
@ -1269,21 +1222,14 @@ class SchemaBaseView(BaseView):
has_json_alternate = False
async def get_database_schema(self, database_name, actor):
async def get_database_schema(self, database_name):
"""Get schema SQL for a database."""
db = self.ds.databases[database_name]
allowed_tables_page = await self.ds.allowed_resources(
"view-table", actor, parent=database_name
)
allowed_table_names = {
resource.child async for resource in allowed_tables_page.all()
}
result = await db.execute(
"select tbl_name, sql from sqlite_master where sql is not null"
)
return ";\n".join(
row["sql"] for row in result.rows if row["tbl_name"] in allowed_table_names
"select group_concat(sql, ';' || CHAR(10)) as schema from sqlite_master where sql is not null"
)
row = result.first()
return row["schema"] if row and row["schema"] else ""
def format_json_response(self, data):
"""Format data as JSON response with CORS headers if needed."""
@ -1345,7 +1291,7 @@ class InstanceSchemaView(SchemaBaseView):
# Get schema for each database
schemas = []
for database_name in allowed_databases:
schema = await self.get_database_schema(database_name, request.actor)
schema = await self.get_database_schema(database_name)
schemas.append({"database": database_name, "schema": schema})
if format_ == "json":
@ -1386,7 +1332,7 @@ class DatabaseSchemaView(SchemaBaseView):
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
schema = await self.get_database_schema(database_name, request.actor)
schema = await self.get_database_schema(database_name)
if format_ == "json":
return self.format_json_response(
@ -1425,8 +1371,7 @@ class TableSchemaView(SchemaBaseView):
# Get schema for the table
db = self.ds.databases[database_name]
result = await db.execute(
"select sql from sqlite_master where name = ? "
"and type in ('table', 'view') and sql is not null",
"select sql from sqlite_master where name = ? and sql is not null",
[table_name],
)
row = result.first()

View file

@ -124,7 +124,7 @@ class QueryListView(BaseView):
pairs.append(("_next", page.next))
next_url = self.ds.absolute_url(
request,
f"{request.path}?{urlencode(pairs)}",
"{}?{}".format(request.path, urlencode(pairs)),
)
current_filters = {
@ -279,7 +279,7 @@ class QueryCreateView(BaseView):
),
)
response.status = status
return _block_framing(response)
return response
async def get(self, request):
db = await self.ds.resolve_database(request)
@ -415,7 +415,7 @@ class QueryDefinitionView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
@ -439,7 +439,7 @@ class QueryUpdateView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
@ -527,12 +527,12 @@ class QueryEditView(BaseView):
),
)
response.status = status
return _block_framing(response)
return response
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
@ -545,7 +545,7 @@ class QueryEditView(BaseView):
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
@ -629,7 +629,7 @@ class QueryDeleteView(BaseView):
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
@ -639,23 +639,21 @@ class QueryDeleteView(BaseView):
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return _block_framing(
await self.render(
["query_delete.html"],
request,
{
"database": db.name,
"database_color": db.color,
"query": stored_query_to_dict(existing),
"query_url": self.ds.urls.table(db.name, query_name),
},
)
return await self.render(
["query_delete.html"],
request,
{
"database": db.name,
"database_color": db.color,
"query": stored_query_to_dict(existing),
"query_url": self.ds.urls.table(db.name, query_name),
},
)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error([f"Query not found: {query_name}"], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
@ -667,13 +665,13 @@ class QueryDeleteView(BaseView):
["Trusted queries cannot be deleted using the API"], 403
)
_data, is_json = await _json_or_form_payload(request)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)
if is_json:
return Response.json({"ok": True})
self.ds.add_message(
request,
f"Query “{existing.title or query_name}” deleted",
"Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO,
)
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

View file

@ -3,51 +3,48 @@ import itertools
import json
import urllib
import urllib.parse
from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette import tracer
from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.extras import extra_names_from_request
from datasette.plugins import pm
from datasette.events import (
AlterTableEvent,
DropTableEvent,
InsertRowsEvent,
UpsertRowsEvent,
)
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.filters import Filters
from datasette.plugins import pm
from datasette.database import QueryInterrupted
from datasette import tracer
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
CustomJSONEncoder,
CustomRow,
InvalidSql,
WriteJsonValueError,
add_cors_headers,
append_querystring,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
append_querystring,
compound_keys_after_sql,
decode_write_json_rows,
format_bytes,
make_slot_function,
tilde_encode,
escape_sqlite,
filters_should_redirect,
format_bytes,
is_url,
make_slot_function,
path_from_row_pks,
path_with_added_args,
path_with_format,
path_with_removed_args,
path_with_replaced_args,
sqlite3,
tilde_encode,
to_css_class,
truncate_url,
urlsafe_components,
value_as_boolean,
InvalidSql,
WriteJsonValueError,
sqlite3,
)
from datasette.utils.asgi import (
BadRequest,
@ -57,8 +54,11 @@ from datasette.utils.asgi import (
Request,
Response,
)
from datasette.utils.sqlite import check_structured_write_table
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
from datasette.extras import ExtraScope
from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
from .database import QueryView
@ -536,7 +536,7 @@ async def _table_insert_ui(
columns.append(column_data)
data = {
"path": f"{datasette.urls.table(database_name, table_name)}/-/insert",
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"bulkColumns": bulk_columns,
@ -544,8 +544,8 @@ async def _table_insert_ui(
"maxInsertRows": datasette.setting("max_insert_rows"),
}
if can_update:
data["upsertPath"] = (
f"{datasette.urls.table(database_name, table_name)}/-/upsert"
data["upsertPath"] = "{}/-/upsert".format(
datasette.urls.table(database_name, table_name)
)
return data
@ -604,7 +604,7 @@ async def _table_alter_ui(
columns.append(column_data)
data = {
"path": f"{datasette.urls.table(database_name, table_name)}/-/alter",
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
@ -630,7 +630,9 @@ async def _table_alter_ui(
actor=request.actor,
)
if can_drop_table:
data["dropPath"] = f"{datasette.urls.table(database_name, table_name)}/-/drop"
data["dropPath"] = "{}/-/drop".format(
datasette.urls.table(database_name, table_name)
)
return data
@ -726,10 +728,12 @@ async def display_columns_and_rows(
row_label = row_label_from_label_column(row, label_column)
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = f"{pk_path} {row_label}"
row_action_label = "{} {}".format(pk_path, row_label)
table_path = datasette.urls.table(database_name, table_name)
row_link = (
f'<a href="{table_path}/{row_path}">{markupsafe.escape(pk_path)!s}</a>'
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
table_path=table_path,
flat_pks=str(markupsafe.escape(pk_path)),
flat_pks_quoted=row_path,
)
edit_icon = (
'<svg class="row-inline-action-icon" aria-hidden="true" '
@ -756,16 +760,22 @@ async def display_columns_and_rows(
if row_action_permissions.get("update-row"):
row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-edit" '
f'aria-label="Edit row {markupsafe.escape(row_action_label)}" title="Edit row" '
'aria-label="Edit row {row_label}" title="Edit row" '
'data-row-action="edit">'
f"{edit_icon}</button>"
"{edit_icon}</button>".format(
edit_icon=edit_icon,
row_label=markupsafe.escape(row_action_label),
)
)
if row_action_permissions.get("delete-row"):
row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-delete" '
f'aria-label="Delete row {markupsafe.escape(row_action_label)}" title="Delete row" '
'aria-label="Delete row {row_label}" title="Delete row" '
'data-row-action="delete">'
f"{delete_icon}</button>"
"{delete_icon}</button>".format(
delete_icon=delete_icon,
row_label=markupsafe.escape(row_action_label),
)
)
if row_actions:
row_link = (
@ -833,7 +843,11 @@ async def display_columns_and_rows(
path_from_row_pks(row, pks, not pks),
column,
),
(f' title="{formatted}"' if "bytes" not in formatted else ""),
(
' title="{}"'.format(formatted)
if "bytes" not in formatted
else ""
),
len(value),
"" if len(value) == 1 else "s",
)
@ -945,7 +959,7 @@ class TableInsertView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return _errors([f"Invalid JSON: {e}"])
return _errors(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return _errors(["JSON must be a dictionary"])
keys = data.keys()
@ -973,7 +987,9 @@ class TableInsertView(BaseView):
# Does this exceed max_insert_rows?
max_insert_rows = self.ds.setting("max_insert_rows")
if len(rows) > max_insert_rows:
return _errors([f"Too many rows, maximum allowed is {max_insert_rows}"])
return _errors(
["Too many rows, maximum allowed is {}".format(max_insert_rows)]
)
# Validate other parameters
extras = {
@ -1031,7 +1047,7 @@ class TableInsertView(BaseView):
# Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return Response.error([f"Table not found: {table_name}"], 404)
return Response.error(["Table not found: {}".format(table_name)], 404)
if upsert:
# Must have insert-row AND upsert-row permissions
@ -1127,7 +1143,6 @@ class TableInsertView(BaseView):
row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]
def insert_or_upsert_rows(conn):
check_structured_write_table(conn, table_name)
table = sqlite_utils.Database(conn)[table_name]
kwargs = {}
if upsert:
@ -1155,36 +1170,20 @@ class TableInsertView(BaseView):
try:
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
except Exception as e: # noqa: BLE001
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
except Exception as e:
return Response.error([str(e)])
result = {"ok": True}
# Only read back and disclose stored rows if the actor is also
# allowed to view this table - insert-row/update-row alone must
# not be usable to read data the actor cannot otherwise see.
if should_return and not await self.ds.allowed(
action="view-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
should_return = False
if should_return:
if upsert:
# Fetch based on initial input IDs
where_clause = " OR ".join(
[
"({})".format(
" AND ".join(f"{escape_sqlite(pk)} = ?" for pk in pks)
)
]
["({})".format(" AND ".join("{} = ?".format(pk) for pk in pks))]
* len(row_pk_values_for_later)
)
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
fetched_rows = await db.execute(
"select {}* from {} where {}".format(
"rowid, " if pks == ["rowid"] else "",
escape_sqlite(table_name),
where_clause,
"select {}* from [{}] where {}".format(
"rowid, " if pks == ["rowid"] else "", table_name, where_clause
),
args,
)
@ -1268,7 +1267,7 @@ class TableSetColumnTypeView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"], 400)
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
@ -1295,7 +1294,7 @@ class TableSetColumnTypeView(BaseView):
database_name, table_name
)
if column not in column_details:
return Response.error([f"Column not found: {column}"], 400)
return Response.error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"]
if column_type_data is None:
@ -1336,7 +1335,7 @@ class TableSetColumnTypeView(BaseView):
return Response.error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types:
return Response.error([f"Unknown column type: {column_type}"], 400)
return Response.error(["Unknown column type: {}".format(column_type)], 400)
try:
await self.ds.set_column_type(
@ -1374,7 +1373,7 @@ class TableDropView(BaseView):
# Table must exist
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return Response.error([f"Table not found: {table_name}"], 404)
return Response.error(["Table not found: {}".format(table_name)], 404)
if not await self.ds.allowed(
action="drop-table",
resource=TableResource(database=database_name, table=table_name),
@ -1399,9 +1398,7 @@ class TableDropView(BaseView):
"database": database_name,
"table": table_name,
"row_count": (
await db.execute(
f"select count(*) from {escape_sqlite(table_name)}"
)
await db.execute("select count(*) from [{}]".format(table_name))
).single_value(),
"message": 'Pass "confirm": true to confirm',
},
@ -1410,9 +1407,7 @@ class TableDropView(BaseView):
# Drop table
def drop_table(conn):
table = sqlite_utils.Database(conn)[table_name]
table.disable_fts()
table.drop()
sqlite_utils.Database(conn)[table_name].drop()
await db.execute_write_fn(drop_table, request=request)
await self.ds.track_event(
@ -1422,7 +1417,7 @@ class TableDropView(BaseView):
)
self.ds.add_message(
request,
f"Table {table_name} dropped",
"Table {} dropped".format(table_name),
self.ds.WARNING,
)
return Response.json({"ok": True}, status=200)
@ -1482,28 +1477,32 @@ def _prefix_range_end(value):
def _autocomplete_like(column):
return f"{escape_sqlite(column)} like :like escape char(92)"
return "{} like :like escape char(92)".format(escape_sqlite(column))
def _autocomplete_prefix_like(column):
return f"{escape_sqlite(column)} like :prefix escape char(92)"
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
clauses = []
if exact_pk:
clauses.append(
f"case when cast({escape_sqlite(pks[0])} as text) = :q then 0 else 1 end"
"case when cast({} as text) = :q then 0 else 1 end".format(
escape_sqlite(pks[0])
)
)
if label_column:
label_like = _autocomplete_like(label_column)
if label_matches_first:
clauses.append(f"case when {label_like} then 0 else 1 end")
clauses.append("case when {} then 0 else 1 end".format(label_like))
clauses.append(
f"case when {label_like} then length(cast({escape_sqlite(label_column)} as text)) end"
"case when {} then length(cast({} as text)) end".format(
label_like, escape_sqlite(label_column)
)
)
else:
clauses.append(f"length(cast({escape_sqlite(pks[0])} as text))")
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
clauses.extend(escape_sqlite(pk) for pk in pks)
return ", ".join(clauses)
@ -1570,8 +1569,8 @@ class TableAutocompleteView(BaseView):
return Response.json({"ok": True, "rows": []})
params = {
"q": q,
"like": f"%{_escape_like(q)}%",
"prefix": f"{_escape_like(q)}%",
"like": "%{}%".format(_escape_like(q)),
"prefix": "{}%".format(_escape_like(q)),
}
like_columns = pks[:]
@ -1585,13 +1584,18 @@ class TableAutocompleteView(BaseView):
where_sql = "1 = 1"
order_by = _autocomplete_initial_order_by(pks)
sql = f"""
sql = """
select {select_sql}
from {escape_sqlite(table_name)}
where {where_sql}
from {table}
where {where}
order by {order_by}
limit 10
"""
""".format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=where_sql,
order_by=order_by,
)
try:
results = await db.execute(
@ -1603,14 +1607,21 @@ class TableAutocompleteView(BaseView):
if prefix_end:
params["prefix_end"] = prefix_end
first_pk = escape_sqlite(pks[0])
fallback_where = f"{first_pk} >= :q and {first_pk} < :prefix_end and {fallback_where}"
fallback_sql = f"""
fallback_where = (
"{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
).format(first_pk=first_pk, like=fallback_where)
fallback_sql = """
select {select_sql}
from {escape_sqlite(table_name)}
where {fallback_where}
order by {_autocomplete_pk_order_by(pks)}
from {table}
where {where}
order by {order_by}
limit 10
"""
""".format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=fallback_where,
order_by=_autocomplete_pk_order_by(pks),
)
try:
results = await db.execute(
fallback_sql,
@ -1714,22 +1725,13 @@ async def table_view(datasette, request):
if ttl is None or not ttl.isdigit():
ttl = datasette.setting("default_cache_ttl")
private = getattr(request, "_datasette_private_response", False)
if datasette.cache_headers and response.status == 200:
if private:
# This response is only visible to the current actor (denied to
# anonymous requests), so it must never be stored by a shared
# cache/CDN - and ?_ttl= must not be able to override that.
response.headers["Cache-Control"] = "private, no-store"
response.headers["Vary"] = "Cookie"
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
# Referrer policy
response.headers["Referrer-Policy"] = "no-referrer"
@ -1775,7 +1777,7 @@ async def table_view_traced(datasette, request):
)
if isinstance(view_data, Response):
return view_data
data, rows, columns, _expanded_columns, sql, next_url = view_data
data, rows, columns, expanded_columns, sql, next_url = view_data
# Handle formats from plugins
if format_ == "csv":
@ -1786,8 +1788,8 @@ async def table_view_traced(datasette, request):
rows,
columns,
expanded_columns,
_sql,
_next_url,
sql,
next_url,
) = await table_view_data(
datasette,
request,
@ -1804,7 +1806,7 @@ async def table_view_traced(datasette, request):
return data, None, None
return await stream_csv(datasette, fetch_data, request, resolved.db.name)
elif format_ in datasette.renderers:
elif format_ in datasette.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
@ -1862,7 +1864,9 @@ async def table_view_traced(datasette, request):
)
headers.update(
{
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}
)
table_context = TableContext(
@ -1947,7 +1951,7 @@ async def table_view_traced(datasette, request):
headers=headers,
)
else:
assert False, f"Invalid format: {format_}"
assert False, "Invalid format: {}".format(format_)
if next_url:
r.headers["link"] = f'<{next_url}>; rel="next"'
return r
@ -1977,10 +1981,6 @@ async def table_view_data(
)
if not visible:
raise Forbidden("You do not have permission to view this table")
# Record whether this response is private (visible to this actor only)
# so the outer table_view() can set appropriate Cache-Control headers,
# regardless of which output format ends up being rendered.
request._datasette_private_response = private
# Redirect based on request.args, if necessary
redirect_response = await _redirect_if_needed(datasette, request, resolved)
@ -2142,7 +2142,9 @@ async def table_view_data(
extra_desc_only=(
""
if sort
else f" or {escape_sqlite(sort or sort_desc)} is null"
else " or {column2} is null".format(
column2=escape_sqlite(sort or sort_desc)
)
),
next_clauses=" and ".join(next_by_pk_clauses),
)
@ -2184,11 +2186,22 @@ async def table_view_data(
# Facets are calculated against SQL without order by or limit
sql_no_order_no_limit = (
f"select {select_all_columns} from {escape_sqlite(table_name)} {where_clause}"
"select {select_all_columns} from {table_name} {where}".format(
select_all_columns=select_all_columns,
table_name=escape_sqlite(table_name),
where=where_clause,
)
)
# This is the SQL that populates the main table on the page
sql = f"select {select_specified_columns} from {escape_sqlite(table_name)} {where_clause}{order_by} limit {page_size + 1}{offset}"
sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format(
select_specified_columns=select_specified_columns,
table_name=escape_sqlite(table_name),
where=where_clause,
order_by=order_by,
page_size=page_size + 1,
offset=offset,
)
if request.args.get("_timelimit"):
extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
@ -2199,6 +2212,9 @@ async def table_view_data(
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
columns = [r[0] for r in results.description]
rows = list(results.rows)
@ -2245,8 +2261,7 @@ async def table_view_data(
new_rows = []
for row in rows:
new_row = CustomRow(columns)
# CustomRow/sqlite3.Row iterate over values, so .keys() is required
for column in row.keys(): # noqa: SIM118
for column in row.keys():
value = row[column]
if (column, value) in expanded_labels and value is not None:
new_row[column] = {
@ -2283,7 +2298,7 @@ async def table_view_data(
# Data formats reject unknown extras; the HTML path (which passes
# extra_extras={"_html"}) resolves internal extras of its own
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
if any(k for k in request.args if k == "_facet" or k.startswith("_facet_")):
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
extras.add("facet_results")
if request.args.get("_shape") == "object":
extras.add("primary_keys")
@ -2464,16 +2479,20 @@ async def _next_value_and_url(
except IndexError:
# sort/sort_desc column missing from SELECT - look up value by PK instead
prefix_where_clause = " and ".join(
f"{escape_sqlite(pk)} = :pk{i}" for i, pk in enumerate(pks)
"[{}] = :pk{}".format(pk, i) for i, pk in enumerate(pks)
)
prefix_lookup_sql = (
f"select {escape_sqlite(sort or sort_desc)} "
f"from {escape_sqlite(table_name)} where {prefix_where_clause}"
prefix_lookup_sql = "select [{}] from [{}] where {}".format(
sort or sort_desc, table_name, prefix_where_clause
)
prefix = (
await db.execute(
prefix_lookup_sql,
{**{f"pk{i}": rows[-2][pk] for i, pk in enumerate(pks)}},
{
**{
"pk{}".format(i): rows[-2][pk]
for i, pk in enumerate(pks)
}
},
)
).single_value()
if isinstance(prefix, dict) and "value" in prefix:

View file

@ -1,9 +1,9 @@
import json
import re
import time
from typing import Annotated, Any, Literal
from typing import Annotated, Any, Literal, Union
import sqlite_utils
from datasette.database import QueryInterrupted
from pydantic import (
BaseModel,
ConfigDict,
@ -13,29 +13,21 @@ from pydantic import (
model_validator,
)
from pydantic_core import PydanticCustomError
import sqlite_utils
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
WriteJsonValueError,
decode_write_json_rows,
escape_sqlite,
get_outbound_foreign_keys,
table_column_details,
WriteJsonValueError,
)
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.permissions import (
SKIP_PERMISSION_CHECKS,
gather_permission_sql_from_hooks,
resolve_permissions_with_candidates,
)
from datasette.utils.sqlite import (
check_structured_write_table,
sqlite_hidden_table_names,
)
from datasette.utils.sqlite import sqlite_hidden_table_names
from .base import BaseView
@ -130,30 +122,6 @@ def _public_foreign_key_target(target):
}
async def _filter_visible_foreign_key_targets(datasette, actor, database_name, targets):
if not targets:
return []
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action="view-table",
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return targets
candidate_tables = list(dict.fromkeys(target["fk_table"] for target in targets))
permission_rows = await resolve_permissions_with_candidates(
datasette.get_internal_database(),
actor,
permission_sqls,
[(database_name, table_name) for table_name in candidate_tables],
"view-table",
)
visible_tables = {row["child"] for row in permission_rows if bool(row["allow"])}
return [target for target in targets if target["fk_table"] in visible_tables]
def _singular(name):
if name.endswith("ies") and len(name) > 3:
return name[:-3] + "y"
@ -168,14 +136,14 @@ def _foreign_key_name_reasons(source_column, target):
singular_table = _singular(table)
column = target["fk_column"].lower()
possible_names = {
f"{table}_{column}",
f"{singular_table}_{column}",
"{}_{}".format(table, column),
"{}_{}".format(singular_table, column),
}
if column == "id":
possible_names.update(
{
f"{table}_id",
f"{singular_table}_id",
"{}_id".format(table),
"{}_id".format(singular_table),
}
)
return ["name_match"] if source in possible_names else []
@ -294,8 +262,10 @@ async def _create_table_ui_context(
if not database_action_permissions.get("create-table"):
return None
data = {
"path": f"{datasette.urls.database(database_name)}/-/create",
"foreignKeyTargetsPath": f"{datasette.urls.database(database_name)}/-/foreign-key-targets",
"path": "{}/-/create".format(datasette.urls.database(database_name)),
"foreignKeyTargetsPath": "{}/-/foreign-key-targets".format(
datasette.urls.database(database_name)
),
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
@ -428,15 +398,15 @@ def default_expr_for_sql(expression):
def _quoted_options(options):
if len(options) == 1:
return f"'{options[0]}'"
return "'{}'".format(options[0])
return "{} or '{}'".format(
", ".join(f"'{option}'" for option in options[:-1]),
", ".join("'{}'".format(option) for option in options[:-1]),
options[-1],
)
def _default_expr_error_message():
return f"Input should be {_quoted_options(list(DEFAULT_EXPRESSIONS))}"
return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS)))
def default_expression_options():
@ -745,16 +715,18 @@ class SetForeignKeysOperation(_StrictPydanticModel):
AlterTableOperation = Annotated[
AddColumnOperation
| RenameColumnOperation
| RenameTableOperation
| AlterColumnOperation
| DropColumnOperation
| SetPrimaryKeyOperation
| ReorderColumnsOperation
| AddForeignKeyOperation
| DropForeignKeyOperation
| SetForeignKeysOperation,
Union[
AddColumnOperation,
RenameColumnOperation,
RenameTableOperation,
AlterColumnOperation,
DropColumnOperation,
SetPrimaryKeyOperation,
ReorderColumnsOperation,
AddForeignKeyOperation,
DropForeignKeyOperation,
SetForeignKeysOperation,
],
Field(discriminator="op"),
]
@ -768,7 +740,7 @@ def _pydantic_errors(validation_error):
for error in validation_error.errors():
location = ".".join(str(item) for item in error["loc"])
message = error["msg"]
errors.append(f"{location}: {message}" if location else message)
errors.append("{}: {}".format(location, message) if location else message)
return errors
@ -789,7 +761,7 @@ def _create_table_pydantic_errors(validation_error):
output.append(message)
continue
location = ".".join(str(item) for item in error["loc"])
output.append(f"{location}: {message}" if location else message)
output.append("{}: {}".format(location, message) if location else message)
return output
@ -838,7 +810,7 @@ class TableCreateView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"])
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
@ -853,18 +825,17 @@ class TableCreateView(BaseView):
ignore = create_request.ignore
replace = create_request.replace
if replace:
# Must have update-row permission
if not await self.ds.allowed(
action="update-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-row"], 403)
table_name = create_request.table
table_exists = await db.table_exists(table_name)
table_resource = TableResource(database=database_name, table=table_name)
# Replacing rows requires update-row permission
if replace and not await self.ds.allowed(
action="update-row",
resource=table_resource,
actor=request.actor,
):
return Response.error(["Permission denied: need update-row"], 403)
columns = create_request.columns
rows = create_request.rows_list
@ -872,7 +843,7 @@ class TableCreateView(BaseView):
# Must have insert-row permission
if not await self.ds.allowed(
action="insert-row",
resource=table_resource,
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(["Permission denied: need insert-row"], 403)
@ -891,7 +862,7 @@ class TableCreateView(BaseView):
if create_request.alter:
if not await self.ds.allowed(
action="alter-table",
resource=table_resource,
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(
@ -907,14 +878,9 @@ class TableCreateView(BaseView):
actual_pks = await db.primary_keys(table_name)
# if pk passed and table already exists check it does not change
bad_pks = False
if (
len(actual_pks) == 1
and pk
and pk != actual_pks[0]
or len(actual_pks) > 1
and pks
and set(pks) != set(actual_pks)
):
if len(actual_pks) == 1 and pk and pk != actual_pks[0]:
bad_pks = True
elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
bad_pks = True
if bad_pks:
return Response.error(["pk cannot be changed for existing table"])
@ -927,7 +893,6 @@ class TableCreateView(BaseView):
)
def create_table(conn):
check_structured_write_table(conn, table_name, allow_missing=True)
db_for_write = sqlite_utils.Database(conn)
table = db_for_write[table_name]
if rows:
@ -960,8 +925,7 @@ class TableCreateView(BaseView):
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e: # noqa: BLE001
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
except Exception as e:
return Response.error([str(e)])
if initial_schema is not None and initial_schema != schema:
@ -1047,9 +1011,6 @@ class DatabaseForeignKeyTargetsView(BaseView):
for target in (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts()
if target["fk_table"] not in hidden_tables
]
targets = await _filter_visible_foreign_key_targets(
self.ds, request.actor, database_name, targets
)
return Response.json(
{
"ok": True,
@ -1088,15 +1049,6 @@ class TableForeignKeySuggestionsView(BaseView):
source_columns, targets, current_by_column = await db.execute_fn(
lambda conn: _foreign_key_suggestion_metadata(conn, table_name)
)
targets = await _filter_visible_foreign_key_targets(
self.ds, request.actor, database_name, targets
)
visible_target_tables = {target["fk_table"] for target in targets}
current_by_column = {
column: current
for column, current in current_by_column.items()
if current["fk_table"] in visible_target_tables
}
columns = []
options_by_column = {}
@ -1219,7 +1171,7 @@ class TableAlterView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"], 400)
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
@ -1359,7 +1311,10 @@ class TableAlterView(BaseView):
and rename_table_to != current_table_name
):
operation_conn.execute(
f"alter table {escape_sqlite(current_table_name)} rename to {escape_sqlite(rename_table_to)}"
"alter table {} rename to {}".format(
escape_sqlite(current_table_name),
escape_sqlite(rename_table_to),
)
)
current_table_name = rename_table_to
@ -1374,8 +1329,7 @@ class TableAlterView(BaseView):
before_schema, after_schema, after_table_name = await db.execute_write_fn(
alter_table, request=request
)
except Exception as e: # noqa: BLE001
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
except Exception as e:
return Response.error([str(e)], 400)
altered = before_schema != after_schema

View file

@ -1,6 +1,5 @@
import itertools
from dataclasses import dataclass
from typing import ClassVar
from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
@ -102,7 +101,7 @@ class QueryExtraContext:
class CountSqlExtra(Extra):
description = "SQL query string used to calculate the total count for the current table view, including active filters."
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return context.count_sql
@ -111,7 +110,7 @@ class CountSqlExtra(Extra):
class CountExtra(Extra):
description = "Total count of rows matching these filters"
example = ExtraExample("/fixtures/facetable.json?_extra=count")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
expensive = True
async def resolve(self, context):
@ -129,7 +128,9 @@ class CountExtra(Extra):
pass
if context.count_sql and count is None and not context.nocount:
count_sql_limited = f"select count(*) from (select * {context.from_sql} limit {context.db.count_limit + 1})"
count_sql_limited = "select count(*) from (select * {} limit {})".format(
context.from_sql, context.db.count_limit + 1
)
try:
count_rows = list(
await context.db.execute(count_sql_limited, context.from_sql_params)
@ -159,7 +160,7 @@ def count_is_truncated(datasette, db, database_name, table_name, count_sql, coun
class CountTruncatedExtra(Extra):
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
expensive = True
async def resolve(self, context, count):
@ -174,7 +175,7 @@ class CountTruncatedExtra(Extra):
class FacetInstancesProvider(Provider):
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context, count):
facet_instances = []
@ -215,7 +216,7 @@ class FacetResultsExtra(Extra):
},
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
expensive = True
docs_note = "See :ref:`facets` for details of how facets work."
@ -258,7 +259,7 @@ class FacetsTimedOutExtra(Extra):
"if every facet calculation completed."
),
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context, facet_results):
return facet_results["timed_out"]
@ -275,7 +276,7 @@ class SuggestedFacetsExtra(Extra):
],
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
expensive = True
docs_note = (
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
@ -303,7 +304,7 @@ class HumanDescriptionEnExtra(Extra):
example = ExtraExample(
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
human_description_en = context.filters.human_description_en(
@ -323,7 +324,7 @@ class HumanDescriptionEnExtra(Extra):
class ColumnsExtra(Extra):
description = "List of column names returned by this table, row or query."
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=columns"
),
@ -331,11 +332,7 @@ class ColumnsExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return context.columns
@ -344,7 +341,7 @@ class ColumnsExtra(Extra):
class AllColumnsExtra(Extra):
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return list(context.table_columns)
@ -353,12 +350,12 @@ class AllColumnsExtra(Extra):
class PrimaryKeysExtra(Extra):
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
)
}
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
return context.pks
@ -396,12 +393,12 @@ class ColumnDetailsExtra(Extra):
"virtual generated columns and ``3`` for stored generated columns."
)
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/binary_data/1.json?_extra=column_details"
)
}
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
column_details = await context.datasette._get_resource_column_details(
@ -415,7 +412,7 @@ class ColumnDetailsExtra(Extra):
class ActionsExtra(Extra):
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
# Returns an async function for the HTML templates - not JSON serializable
public = False
@ -487,7 +484,7 @@ async def precompute_database_action_permissions(datasette, actor, database_name
class IsViewExtra(Extra):
description = "Whether this resource is a view instead of a table"
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return context.is_view
@ -500,7 +497,7 @@ class DebugExtra(Extra):
"API and may change without warning."
)
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=debug"
),
@ -508,11 +505,7 @@ class DebugExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
debug = {
@ -536,7 +529,7 @@ class DebugExtra(Extra):
class RequestExtra(Extra):
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
example = ExtraExample("/fixtures/facetable.json?_extra=request")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=request"
),
@ -544,11 +537,7 @@ class RequestExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return {
@ -561,7 +550,7 @@ class RequestExtra(Extra):
class DisplayColumnsAndRowsProvider(Provider):
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
display_columns, display_rows = await context.display_columns_and_rows(
@ -605,7 +594,7 @@ class DisplayColumnsExtra(Extra):
],
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context, display_columns_and_rows):
return display_columns_and_rows["columns"]
@ -613,7 +602,7 @@ class DisplayColumnsExtra(Extra):
class DisplayRowsExtra(Extra):
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
# Contains markupsafe/sqlite3.Row values - not JSON serializable
public = False
@ -644,7 +633,7 @@ class RenderCellExtra(Extra):
"whose rendered value differs from the default are included."
),
)
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
value={
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
@ -669,11 +658,7 @@ class RenderCellExtra(Extra):
),
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
table_name = context.table_name
@ -727,7 +712,7 @@ class RenderCellExtra(Extra):
class QueryExtra(Extra):
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=query"
),
@ -736,11 +721,7 @@ class QueryExtra(Extra):
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
],
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return {
@ -764,7 +745,7 @@ class ColumnTypesExtra(Extra):
"been assigned the ``json`` column type."
),
)
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/facetable/1.json?_extra=column_types",
note=(
@ -773,7 +754,7 @@ class ColumnTypesExtra(Extra):
),
)
}
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
ct_map = await context.datasette.get_column_types(
@ -823,7 +804,7 @@ class SetColumnTypeUiExtra(Extra):
"types that could be assigned to it."
),
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
if context.is_view:
@ -865,7 +846,9 @@ class SetColumnTypeUiExtra(Extra):
],
}
return {
"path": f"{context.datasette.urls.table(context.database_name, context.table_name)}/-/set-column-type",
"path": "{}/-/set-column-type".format(
context.datasette.urls.table(context.database_name, context.table_name)
),
"columns": columns,
}
@ -883,7 +866,7 @@ class MetadataExtra(Extra):
"descriptions."
),
)
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=metadata",
note=(
@ -901,11 +884,7 @@ class MetadataExtra(Extra):
),
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
if context.scope == ExtraScope.QUERY:
@ -934,7 +913,7 @@ class MetadataExtra(Extra):
class DatabaseExtra(Extra):
description = "Database name"
example = ExtraExample("/fixtures/facetable.json?_extra=database")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database"
),
@ -942,11 +921,7 @@ class DatabaseExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return context.database_name
@ -955,10 +930,10 @@ class DatabaseExtra(Extra):
class TableExtra(Extra):
description = "Table name"
example = ExtraExample("/fixtures/facetable.json?_extra=table")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
}
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
return context.table_name
@ -971,7 +946,7 @@ class DatabaseColorExtra(Extra):
"a hash of the database name and used in the Datasette interface."
)
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database_color"
),
@ -979,11 +954,7 @@ class DatabaseColorExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return context.db.color
@ -994,7 +965,7 @@ class FormHiddenArgsExtra(Extra):
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
form_hidden_args = []
@ -1011,7 +982,7 @@ class FormHiddenArgsExtra(Extra):
class FiltersExtra(Extra):
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
# Returns a Filters instance for the HTML templates - not JSON serializable
public = False
@ -1027,7 +998,7 @@ class CustomTableTemplatesExtra(Extra):
":ref:`customization_custom_templates`."
)
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return [
@ -1048,7 +1019,7 @@ class SortedFacetResultsExtra(Extra):
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context, facet_results):
facet_configs = context.table_metadata.get("facets", [])
@ -1058,7 +1029,7 @@ class SortedFacetResultsExtra(Extra):
if isinstance(fc, str):
metadata_facet_names.append(fc)
elif isinstance(fc, dict):
metadata_facet_names.append(next(iter(fc.values())))
metadata_facet_names.append(list(fc.values())[0])
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
metadata_facets = []
request_facets = []
@ -1084,7 +1055,7 @@ class SortedFacetResultsExtra(Extra):
class TableDefinitionExtra(Extra):
description = "SQL definition for this table"
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return await context.db.get_table_definition(context.table_name)
@ -1093,7 +1064,7 @@ class TableDefinitionExtra(Extra):
class ViewDefinitionExtra(Extra):
description = "SQL definition for this view"
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return await context.db.get_view_definition(context.table_name)
@ -1110,7 +1081,7 @@ class RenderersExtra(Extra):
"<plugin_register_output_renderer>`."
),
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context, expandable_columns, query):
renderers = {}
@ -1152,7 +1123,7 @@ class PrivateExtra(Extra):
"anonymous user could not. See :ref:`authentication_permissions`."
)
example = ExtraExample("/fixtures/facetable.json?_extra=private")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=private"
),
@ -1160,11 +1131,7 @@ class PrivateExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
),
}
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
return context.private
@ -1181,7 +1148,7 @@ class ExpandableColumnsExtra(Extra):
"that would be used as the label for each expanded value."
),
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
expandables = []
@ -1201,15 +1168,12 @@ class ForeignKeyTablesExtra(Extra):
"reference this row, and ``link`` is a URL to browse those rows."
),
)
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.ROW}
scopes = {ExtraScope.ROW}
expensive = True
async def resolve(self, context):
return await context.foreign_key_tables(
context.database_name,
context.table_name,
context.pk_values,
actor=context.request.actor,
context.database_name, context.table_name, context.pk_values
)
@ -1238,11 +1202,7 @@ class ExtrasExtra(Extra):
"the current request."
),
)
scopes: ClassVar[set[ExtraScope]] = {
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):
all_extras = [

View file

@ -83,22 +83,6 @@ def decision_for_write_sql_operation(
)
if operation.operation == "function":
return IgnoreWriteSqlOperation("SQL function")
if (
operation.operation == "read"
and operation.target_type == "table"
and operation.table is not None
and operation.table_kind is None
and operation.table.lower().startswith("pragma_")
):
# Eponymous table-valued PRAGMA functions (e.g. pragma_table_info("secret"))
# report a read of the synthetic "pragma_table_info" table, not of the
# table passed as an argument. That means a view-table denial on the real
# table is never consulted, so these could otherwise be used to read
# schema metadata (column names, table lists, ...) for tables the actor
# is not allowed to view. Reject them outright in untrusted write SQL,
# including inside CREATE VIEW bodies (whose reads are discovered here
# via the rolled-back dependency-read analysis above).
return UnsupportedWriteSqlOperation(unsupported_message)
if (
operation.operation == "read"
and operation.target_type == "table"

View file

@ -45,7 +45,7 @@ Using the "root" actor
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
Permissions
===========
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
The key question the permissions system answers is this:
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
**Actors** are :ref:`described above <authentication_actor>`.
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
@ -138,72 +138,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
How permissions are resolved
----------------------------
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
``resource``
A specific child resource, such as the ``analytics/sales`` table.
``parent``
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
``global``
Every resource for that action.
Datasette resolves matching rules from most specific to least specific:
#. Resource rules take precedence over parent and global rules.
#. Parent rules take precedence over global rules.
#. If both allow and deny rules match at the same level, deny takes precedence.
#. If no rule matches, access is denied.
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
For table and view permissions, resource names use SQLite's case-insensitive
identifier matching: ``Secret``, ``secret`` and ``SECRET`` identify the same
table. This applies to configuration rules, plugin rules and token restrictions.
Only ASCII letters are case-insensitive; non-ASCII characters remain distinct.
Conflicting rules for different spellings of the same name follow the usual
deny-wins rule at the same scope. Names retain their original spelling in
resource listings and permission explanations. Database names, stored query
names and other resource types remain case-sensitive.
.. list-table:: Permission rule examples
:header-rows: 1
* - Matching rules
- Result
- Explanation
* - Global allow
- Allow
- The global rule is the most specific matching rule.
* - Global allow, parent deny
- Deny
- The parent rule is more specific.
* - Parent deny, resource allow
- Allow
- The resource rule is more specific.
* - Resource allow and resource deny
- Deny
- Deny takes precedence at the same level.
* - No matching rules
- Deny
- Permissions default to deny when no rule applies.
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
The built-in ``datasette.default_permissions.sqlite_statistics`` plugin denies
``view-table`` for ``sqlite_stat1``, ``sqlite_stat2``, ``sqlite_stat3`` and
``sqlite_stat4``. These table-level denials also apply to root users and take
precedence over configuration or plugin allow rules at the same scope.
This controls table access and listings, without changing ``execute-sql`` or
SQLite's internal use of statistics.
A plugin can replace this policy by unregistering
``datasette.default_permissions.sqlite_statistics`` through ``datasette.pm``
and registering its own permission hook. Plugin registration is process-wide:
replacing this policy affects every Datasette instance in that process.
Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
@ -214,12 +149,12 @@ resources were allowed or denied. The combined sources are:
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
Datasette evaluates the SQL to determine if the requested ``resource`` is
included. Explicit deny rules returned by configuration or plugins will block
access even if other rules allowed it.
.. _authentication_permissions_allow:
@ -792,8 +727,6 @@ Datasette defaults to allowing any site visitor to execute their own custom SQL
Access to this ability is controlled by the :ref:`actions_execute_sql` permission.
This permission does not apply to structured table-browsing operations where Datasette constructs the SQL, such as sorting, column filters and :ref:`facets`. Faceting is controlled separately by the :ref:`setting_allow_facet` setting.
The easiest way to disable arbitrary SQL queries is using the :ref:`default_allow_sql setting <setting_default_allow_sql>` when you first start Datasette running.
You can alternatively use an ``"allow_sql"`` block to control who is allowed to execute arbitrary SQL queries.
@ -1212,21 +1145,11 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
datasette -s permissions.permissions-debug true data.db
The permission debug tools answer four different questions:
The page shows the permission checks that have been carried out by the Datasette instance.
Why was this decision allowed or denied?
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
Which resources can the current actor access?
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
Which raw rules did Datasette and its plugins contribute?
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
Which checks has this Datasette instance performed recently?
Use ``/-/permissions`` to view recent permission activity.
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
@ -1261,20 +1184,11 @@ This endpoint requires the ``permissions-debug`` permission.
Permission check view
---------------------
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
* Every matching allow and deny rule, with its source and reason.
* The winning resource, parent or global scope.
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
* Actor restriction allowlists that included or excluded the resource.
* Additional actions required by the requested action.
* An explicit default-deny explanation when no rule matched.
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
.. _authentication_ds_actor:
@ -1382,12 +1296,6 @@ view-table
Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.io/fixtures/complex_foreign_keys
Derived implementation tables require access to their immediate source: FTS and RTree shadow tables require access to their virtual table, external-content FTS tables require access to their content table, and FTS vocabulary tables (``fts5vocab`` and ``fts4aux``) require access to their FTS table. The derived table's own permission rules also apply.
Access is always denied if the source table is itself derived, or if a vocabulary table's source cannot be identified.
The same rules apply to individual permission checks and table listings, including whether they are private. If a database error prevents dependency discovery, the check or listing fails with an error instead of ignoring the dependencies. Failed discovery results are not cached, so later checks can retry.
``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string)
@ -1550,8 +1458,6 @@ execute-sql
Actor is allowed to run arbitrary read-only SQL queries against a specific database using the :ref:`custom SQL query page <pages_custom_sql_queries>`, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
This action also controls raw SQL supplied using ``?_where=``. It does not control structured table-browsing features such as :ref:`facets`, which use SQL generated by Datasette and are controlled by :ref:`setting_allow_facet`.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)

View file

@ -4,80 +4,6 @@
Changelog
=========
.. _unreleased:
Unreleased
----------
- Datasette now uses `httpx2 <https://httpx2.pydantic.dev/>`__, the Pydantic-maintained continuation of `httpx <https://www.python-httpx.org/>`__, in place of ``httpx``. The public API is the same, but responses returned by :ref:`internals_datasette_client` are now ``httpx2.Response`` objects rather than ``httpx.Response``. Plugins that use ``isinstance()`` checks against ``httpx.Response`` should be updated to use ``httpx2``. **Plugins that use httpx without explicitly depending on it** will need to add an explicit dependency or switch to `httpx2`.
.. _v1_0_a39:
1.0a39 (2026-09-10)
-------------------
This alpha release includes security fixes for permissions, SQL construction, HTML rendering, authentication and caching, plus improvements to application startup and write execution.
See `0.65.4 <https://docs.datasette.io/en/stable/changelog.html#v0-65-4>`__ for fixes that have been backported to the stable 0.65.x branch.
The Datasette blog `has more details on these releases <https://datasette.io/blog/2026/september-security-releases/>`__.
Some of the security fixes include:
- Table and view permission checks now take SQLite's case-insensitive names into account. See :ref:`authentication_permissions_explained`.
- Viewing a full-text search index table now checks you have permission to view the table from which it draws its content.
- Viewing SQLite statistics tables (``sqlite_stat1`` through ``sqlite_stat4``) is now denied by a default.
- Table schema display now obeys the ``view-table`` permission.
- Table filters using ``?_through=`` require permission to view the intermediate table.
- Foreign-key target and suggestion APIs, incoming foreign-key relationships and their row counts now respect ``view-table`` permission.
- Row endpoints check permissions before resolving primary keys, to avoid revealing the existence of an otherwise invisible primary key.
- Improved permission checks for the create-table API. See :ref:`json_api_write`.
- The write SQL interface now checks ``view-table`` permission for tables referenced by ``CREATE VIEW`` statements.
- Fixed SQL identifier escaping for column names from untrusted database schemas.
- Fixed HTML escaping for column names from untrusted database schemas.
- URL columns now render links only for validated HTTP or HTTPS URLs.
- Private and personalized dynamic responses now use ``Cache-Control: private, no-store``. Anonymous dynamic responses vary by ``Cookie`` and ``Authorization``.
- Actor cookies now respect ``expire_after``.
- Restricted actors can no longer create API tokens.
- Stored-query create, edit and delete forms now block framing to prevent clickjacking.
- Configuration secret redaction now matches key names case-insensitively.
- SQLite extension loading is disabled after extensions supplied using ``--load-extension`` have been loaded.
Other improvements and fixes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- :ref:`db.execute_write() <database_execute_write>` now has a default execution time limit of 2,000ms. Plugins can override this using ``time_limit_ms=`` or disable it using ``time_limit_ms=None``. This limit is independent of the ``sql_time_limit_ms`` setting for read queries.
- Application startup now runs through ASGI lifespan events before requests are accepted, with a first-request fallback for hosts without lifespan support. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:pr:`2887`)
- ``datasette serve`` now runs startup hooks and Uvicorn on the same event loop, preserving background tasks started by plugins. The minimum Uvicorn version is now 0.29. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:pr:`2886`)
- Non-blocking writes using ``execute_write_fn(..., block=False)`` now return a distinct task UUID for every call and work correctly with ``num_sql_threads=0``. Thanks, `Zain Dana Harper <https://github.com/HarperZ9>`__. (:issue:`2860`, :issue:`2859`)
- Dropping a table now disables its full-text search index first. (:issue:`2874`)
- Fixed ``CREATE VIEW`` SQL analysis on Python 3.10.
.. _v1_0_a38:
1.0a38 (2026-08-06)
-------------------
This release fixes a **SQL injection** security issue that affects Datasette instances that serve a **mixture of public and private tables** in the same database, with access configured using the :ref:`Datasette permissions system <authentication>`.
Site administrators who serve private tables in this way are advised to disable the :ref:`execute-sql permission <actions_execute_sql>` on that database to prevent users from accessing private tables using raw SQL queries. The bug that has been fixed would have allowed users with access to any public table to execute SQL injection attacks despite that restriction, giving them read-only access to data in private tables in the same database.
This fix is also available in Datasette 0.65.3.
.. _v1_0_a37:
1.0a37 (2026-07-14)
-------------------
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
.. _v1_0_a36:
1.0a36 (2026-07-07)

View file

@ -1,3 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Datasette documentation build configuration file, created by
# sphinx-quickstart on Thu Nov 16 06:50:13 2017.

View file

@ -434,13 +434,10 @@ Datasette bundles `CodeMirror <https://codemirror.net/>`__ for the SQL editing i
npm i codemirror @codemirror/lang-sql
* Build the bundle using the version number from package.json with::
* Build the bundle with::
node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \
-f iife \
-n cm \
-o datasette/static/cm-editor-6.0.1.bundle.js \
-p @rollup/plugin-node-resolve \
-p @rollup/plugin-terser
npm install && npm run build:codemirror
* Update the version reference in the ``codemirror.html`` template.
This runs ``rollup -c`` against the ``rollup.config.mjs`` file at the root of the repository, which reads ``datasette/static/cm-editor.js`` and writes the bundled, minified output to ``datasette/static/cm-editor.bundle.js``. The bundle filename does not include the CodeMirror version number, so no template needs to be updated.
* Commit the rebuilt ``datasette/static/cm-editor.bundle.js`` - the bundle is checked into the repository.

View file

@ -14,8 +14,6 @@ Here's `an example <https://congress-legislators.datasettes.com/legislators/legi
Facets can be specified in two ways: using query string parameters, or in ``metadata.json`` configuration for the table.
Facet queries are generated by Datasette and summarize rows the actor already has permission to view. They do not require the :ref:`actions_execute_sql` permission. Use the :ref:`setting_allow_facet` setting to control whether users can request facets using query string parameters.
Facets in query strings
-----------------------

View file

@ -52,11 +52,11 @@ Configuring full-text search for a table or view
If a table has a corresponding FTS table set up using the ``content=`` argument to ``CREATE VIRTUAL TABLE`` shown below, Datasette will detect it automatically and add a search interface to the table page for that table.
You can also manually configure which table should be used for full-text search using table configuration in ``datasette.yaml`` (see :ref:`table_configuration_fts`). You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option.
You can also manually configure which table should be used for full-text search using query string parameters or table configuration in ``datasette.yaml`` (see :ref:`table_configuration_fts`). You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option.
The legacy ``?_fts_table=x`` and ``?_fts_pk=col`` query string parameters are accepted only if they exactly match the configured or automatically detected FTS mapping. They cannot be used to select a different FTS table or primary key. This prevents a public table from being used to probe the contents of a private FTS table.
Use ``?_fts_table=x`` to over-ride the FTS table for a specific page. If the primary key was something other than ``rowid`` you can use ``?_fts_pk=col`` to set that as well. This is particularly useful for views, for example:
Searching also requires the current actor to have ``view-table`` permission for the FTS table itself, in addition to permission to view the table or view being searched.
https://latest.datasette.io/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk
The ``fts_table`` metadata property can be used to specify an associated FTS table. If the primary key column in your table which was used to populate the FTS table is something other than ``rowid``, you can specify the column to use with the ``fts_pk`` property.

View file

@ -1594,32 +1594,32 @@ datasette.client
Plugins can make internal simulated HTTP requests to the Datasette instance within which they are running. This ensures that all of Datasette's external JSON APIs are also available to plugins, while avoiding the overhead of making an external HTTP call to access those APIs.
The ``datasette.client`` object is a wrapper around the `HTTPX2 Python library <https://httpx2.pydantic.dev/>`__, providing an async-friendly API that is similar to the widely used `Requests library <https://requests.readthedocs.io/>`__.
The ``datasette.client`` object is a wrapper around the `HTTPX Python library <https://www.python-httpx.org/>`__, providing an async-friendly API that is similar to the widely used `Requests library <https://requests.readthedocs.io/>`__.
It offers the following methods:
``await datasette.client.get(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.get(path, **kwargs)`` - returns HTTPX Response
Execute an internal GET request against that path.
``await datasette.client.post(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.post(path, **kwargs)`` - returns HTTPX Response
Execute an internal POST request. Use ``data={"name": "value"}`` to pass form parameters.
``await datasette.client.options(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.options(path, **kwargs)`` - returns HTTPX Response
Execute an internal OPTIONS request.
``await datasette.client.head(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.head(path, **kwargs)`` - returns HTTPX Response
Execute an internal HEAD request.
``await datasette.client.put(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.put(path, **kwargs)`` - returns HTTPX Response
Execute an internal PUT request.
``await datasette.client.patch(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.patch(path, **kwargs)`` - returns HTTPX Response
Execute an internal PATCH request.
``await datasette.client.delete(path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.delete(path, **kwargs)`` - returns HTTPX Response
Execute an internal DELETE request.
``await datasette.client.request(method, path, **kwargs)`` - returns HTTPX2 Response
``await datasette.client.request(method, path, **kwargs)`` - returns HTTPX Response
Execute an internal request with the given HTTP method against that path.
These methods can be used with :ref:`internals_datasette_urls` - for example:
@ -1636,7 +1636,7 @@ These methods can be used with :ref:`internals_datasette_urls` - for example:
``datasette.client`` methods automatically take the current :ref:`setting_base_url` setting into account, whether or not you use the ``datasette.urls`` family of methods to construct the path.
For documentation on available ``**kwargs`` options and the shape of the HTTPX2 Response object refer to the `HTTPX2 Async documentation <https://httpx2.pydantic.dev/async/>`__.
For documentation on available ``**kwargs`` options and the shape of the HTTPX Response object refer to the `HTTPX Async documentation <https://www.python-httpx.org/async/>`__.
.. _internals_datasette_client_actor:
@ -2023,8 +2023,8 @@ Example usage:
.. _database_execute_write:
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True, time_limit_ms=2000)
----------------------------------------------------------------------------------------------------------------------------------------------
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
--------------------------------------------------------------------------------------------------------
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
@ -2059,16 +2059,7 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
Each call to ``execute_write()`` will be executed inside a transaction. Pass
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
a transaction.
Write statements have a default time limit of 2,000ms. Pass a different value
using ``time_limit_ms=`` or use ``time_limit_ms=None`` to allow the statement to
run without a time limit.
This write limit is independent of the ``sql_time_limit_ms`` setting used for
read queries. Changing that setting does not change the default write limit.
Each call to ``execute_write()`` will be executed inside a transaction.
.. _database_execute_write_script:
@ -2630,12 +2621,12 @@ This example uses trace to record the start, end and duration of any HTTP GET re
.. code-block:: python
from datasette.tracer import trace
import httpx2
import httpx
async def fetch_url(url):
with trace("fetch-url", url=url):
async with httpx2.AsyncClient() as client:
async with httpx.AsyncClient() as client:
return await client.get(url)
.. _internals_tracer_trace_child_tasks:

View file

@ -80,15 +80,18 @@ Shows a list of currently installed plugins and their versions. `Plugins example
.. code-block:: json
[
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
{
"ok": true,
"plugins": [
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
}
Add ``?all=1`` to include details of the default plugins baked into Datasette.

View file

@ -1661,8 +1661,6 @@ The request body is always parsed as JSON, regardless of the request's ``Content
The row-based write APIs can write :ref:`binary values in JSON <binary_json_format>` using Datasette's Base64 representation for BLOB data.
Structured inserts, upserts, updates and deletes only support ordinary SQLite tables. Virtual tables and their internal shadow tables are rejected, including when adding rows to an existing table through the create-table API. Writes to ordinary content tables can still update full-text search indexes through configured triggers.
.. _ExecuteWriteView:
Executing write SQL

View file

@ -46,7 +46,7 @@ def table_extras(cog):
cog.out("\n")
for scope, heading, intro, classes in classes_by_scope:
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
cog.out(f"{intro}\n\n")
cog.out("{}\n\n".format(intro))
for cls in classes:
examples = _examples_for_scope(cls, scope)
description = cls.description or ""
@ -58,16 +58,16 @@ def table_extras(cog):
if notes:
description = "{} ({})".format(description, " ".join(notes)).strip()
cog.out(f"``{cls.key()}``\n")
cog.out(f" {description}\n\n")
cog.out("``{}``\n".format(cls.key()))
cog.out(" {}\n\n".format(description))
for example in examples:
if example.path:
value = live_examples[(example.path, example.key or cls.key())]
cog.out(f" ``GET {example.path}``\n\n")
cog.out(" ``GET {}``\n\n".format(example.path))
else:
value = example.value
if example.note:
cog.out(f" {example.note}\n\n")
cog.out(" {}\n\n".format(example.note))
cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
cog.out("\n\n")
@ -139,7 +139,7 @@ async def _fetch_live_examples(scoped_classes):
response = await datasette.client.get(example.path)
assert response.status_code == 200, example.path
data = response.json()
assert key in data, f"{key} missing from {example.path}"
assert key in data, "{} missing from {}".format(key, example.path)
examples[(example.path, key)] = data[key]
finally:
for db in datasette.databases.values():

View file

@ -1,8 +1,7 @@
import json
import textwrap
from ruamel.yaml import YAML
from yaml import safe_dump
from ruamel.yaml import YAML
def metadata_example(cog, data=None, yaml=None):
@ -34,10 +33,10 @@ def config_example(
else:
data = input
output_yaml = safe_dump(input, sort_keys=False)
cog.out(f"\n.. tab:: {yaml_title}\n\n")
cog.out("\n.. tab:: {}\n\n".format(yaml_title))
cog.out(" .. code-block:: yaml\n\n")
cog.out(textwrap.indent(output_yaml, " "))
cog.out(f"\n\n.. tab:: {json_title}\n\n")
cog.out("\n\n.. tab:: {}\n\n".format(json_title))
cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(data, indent=2), " "))
cog.out("\n")
@ -45,10 +44,8 @@ def config_example(
def internal_schema(cog):
import asyncio
from sqlite_utils import Database
from datasette.app import Datasette
from sqlite_utils import Database
ds = Datasette()
db = ds.get_internal_database()

View file

@ -261,15 +261,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"permission_resources_sql"
]
},
{
"name": "datasette.default_permissions.sqlite_statistics",
"static": false,
"templates": false,
"version": null,
"hooks": [
"permission_resources_sql"
]
},
{
"name": "datasette.default_permissions.tokens",
"static": false,

View file

@ -71,8 +71,6 @@ Should users be able to execute arbitrary SQL queries by default?
Setting this to ``off`` causes permission checks for :ref:`actions_execute_sql` to fail by default.
This setting controls the ability to submit arbitrary SQL. It does not disable structured table-browsing features that use SQL generated by Datasette, such as sorting, column filters and :ref:`facets`. Use :ref:`setting_allow_facet` to control whether users can request facets.
::
datasette mydatabase.db --setting default_allow_sql off
@ -256,8 +254,6 @@ Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-ag
datasette mydatabase.db --setting default_cache_ttl 60
Dynamic responses for authenticated actors, requests with cookies or an ``Authorization`` header, and responses that set cookies use ``Cache-Control: private, no-store``. This takes precedence over ``default_cache_ttl`` and ``?_ttl=``, even when cache headers are otherwise disabled. Anonymous dynamic responses vary by ``Cookie`` and ``Authorization``. Static assets retain their own cache policy.
.. _setting_cache_size_kb:
cache_size_kb

View file

@ -21,12 +21,14 @@ def template_context(cog):
),
)
for name, doc in TEMPLATE_BASE_CONTEXT.items():
cog.out(f"``{name}``\n")
cog.out(f" {doc}\n\n")
cog.out("``{}``\n".format(name))
cog.out(" {}\n\n".format(doc))
for klass in PAGES.values():
title = "{} page".format(klass.__name__.removesuffix("Context"))
intro = f"{klass.__doc__} Rendered using the ``{klass.documented_template}`` template."
intro = "{} Rendered using the ``{}`` template.".format(
klass.__doc__, klass.documented_template
)
_section(cog, title, intro)
if klass.extras_scope is not None:
cog.out(
@ -34,10 +36,10 @@ def template_context(cog):
"<json_api>` for this page.\n\n"
)
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
cog.out(f"``{f.name}`` - ``{f.type_name}``\n")
cog.out(f" {f.help}\n\n")
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
cog.out(" {}\n\n".format(f.help))
def _section(cog, title, intro):
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
cog.out(f"{intro}\n\n")
cog.out("{}\n\n".format(intro))

View file

@ -25,7 +25,7 @@ If you use the template described in :ref:`writing_plugins_cookiecutter` your pl
)
This test uses the :ref:`internals_datasette_client` object to exercise a test instance of Datasette. ``datasette.client`` is a wrapper around the `HTTPX2 <https://httpx2.pydantic.dev/>`__ Python library which can imitate HTTP requests using ASGI. This is the recommended way to write tests against a Datasette instance.
This test uses the :ref:`internals_datasette_client` object to exercise a test instance of Datasette. ``datasette.client`` is a wrapper around the `HTTPX <https://www.python-httpx.org/>`__ Python library which can imitate HTTP requests using ASGI. This is the recommended way to write tests against a Datasette instance.
This test also uses the `pytest-asyncio <https://pypi.org/project/pytest-asyncio/>`__ package to add support for ``async def`` test functions running under pytest.
@ -154,7 +154,7 @@ If you need to opt out of this behavior, add the following to your ``pytest.ini`
Using datasette.client in tests
-------------------------------
The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX2 async client <https://httpx2.pydantic.dev/async/>`__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test.
The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX async client <https://www.python-httpx.org/async/>`__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test.
A simple test looks like this:
@ -273,22 +273,22 @@ If you want to create that test database repeatedly for every individual test fu
.. _testing_plugins_pytest_httpx:
Testing outbound HTTP calls with pytest-httpx2
----------------------------------------------
Testing outbound HTTP calls with pytest-httpx
---------------------------------------------
If your plugin makes outbound HTTP calls - for example datasette-auth-github or datasette-import-table - you may need to mock those HTTP requests in your tests.
The `pytest-httpx2 <https://pypi.org/project/pytest-httpx2/>`__ package provides a ``httpx2_mock`` fixture, built on `respx <https://lundberg.github.io/respx/>`__, for mocking outbound calls made using HTTPX2.
The `pytest-httpx <https://pypi.org/project/pytest-httpx/>`__ package is a useful library for mocking calls. It can be tricky to use with Datasette though since it mocks all HTTPX requests, and Datasette's own testing mechanism uses HTTPX internally.
Datasette's own ``datasette.client`` mechanism uses HTTPX2 internally too, but those requests are passed directly to the ASGI application rather than being sent over the network, so they are not affected by the mock.
To avoid breaking your tests, you can return ``["localhost"]`` from the ``non_mocked_hosts()`` fixture.
As an example, here's a very simple plugin which executes an HTTP request and returns the resulting content:
As an example, here's a very simple plugin which executes an HTTP response and returns the resulting content:
.. code-block:: python
from datasette import hookimpl
from datasette.utils.asgi import Response
import httpx2
import httpx
@hookimpl
@ -306,18 +306,27 @@ As an example, here's a very simple plugin which executes an HTTP request and re
</form>""")
vars = await request.post_vars()
url = vars["url"]
return Response.text(httpx2.get(url).text)
return Response.text(httpx.get(url).text)
Here's a test for that plugin that mocks the HTTPX2 outbound request:
Here's a test for that plugin that mocks the HTTPX outbound request:
.. code-block:: python
from datasette.app import Datasette
import pytest
async def test_outbound_http_call(httpx2_mock):
httpx2_mock.get("https://www.example.com/").respond(
text="Hello world"
@pytest.fixture
def non_mocked_hosts():
# This ensures httpx-mock will not affect Datasette's own
# httpx calls made in the tests by datasette.client:
return ["localhost"]
async def test_outbound_http_call(httpx_mock):
httpx_mock.add_response(
url="https://www.example.com/",
text="Hello world",
)
datasette = Datasette([], memory=True)
response = await datasette.client.post(
@ -326,13 +335,11 @@ Here's a test for that plugin that mocks the HTTPX2 outbound request:
)
assert response.text == "Hello world"
outbound_request = httpx2_mock.calls.last.request
outbound_request = httpx_mock.get_request()
assert (
outbound_request.url == "https://www.example.com/"
)
If your plugin still makes its outbound calls using the original ``httpx`` library you can continue to mock those using `pytest-httpx <https://pypi.org/project/pytest-httpx/>`__.
.. _testing_plugins_register_in_test:
Registering a plugin for the duration of a test

740
package-lock.json generated
View file

@ -1,15 +1,15 @@
{
"name": "datasette",
"lockfileVersion": 2,
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datasette",
"dependencies": {
"@codemirror/lang-sql": "^6.3.3",
"@codemirror/lang-sql": "^6.10.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^0.1.0",
"codemirror": "^6.0.1",
"codemirror": "^6.0.2",
"rollup": "^3.30.0"
},
"devDependencies": {
@ -17,175 +17,184 @@
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.3.2.tgz",
"integrity": "sha512-+VzxrHWkuvSSt0fw4I57SULo/NMrLnNgm6JHrkbIYfDw9jZJNTruCwkv32TCqSeC8xIXhYWMuxawwr/xOoHr8w==",
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.5.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.1.2.tgz",
"integrity": "sha512-sO3jdX1s0pam6lIdeSJLMN3DQ6mPEbM4yLvyKkdqtmd/UDwhXA5+AwFJ89rRXm6vTeOXBsE5cAmlos/t7MJdgg==",
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.3.3.tgz",
"integrity": "sha512-VNsHju8500fkiDyDU8jZyGQ8M0iXU0SmfeCoCeAYkACcEFlX63BOT8311pICXyw43VYRbS23w54RgSEQmixGjQ==",
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.3.1.tgz",
"integrity": "sha512-MK+G1QKaGfSEUg9YEFaBkMBI6j1ge4VMBPZv9fDYotw7w695c42x5Ba1mmwBkesYnzYFBfte6Hh9TDcKa6xORQ==",
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.1.0.tgz",
"integrity": "sha512-mdvDQrjRmYPvQ3WrzF6Ewaao+NWERYtpthJvoQ3tK3t/44Ynhk8ZGjTSL9jMEv8CgSMogmt75X8ceOZRDSXHtQ==",
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.2.3.tgz",
"integrity": "sha512-V9n9233lopQhB1dyjsBK2Wc1i+8hcCqxl1wQ46c5HWWLePoe4FluV3TGHoZ04rBRlGjNyz9DTmpJErig8UE4jw==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.1.4.tgz",
"integrity": "sha512-g+3OJuRylV5qsXuuhrc6Cvs1NQluNioepYMM2fhnpYkNk7NgX+j0AFuevKSVKzTDmDyt9+Puju+zPdHNECzCNQ=="
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.5.1.tgz",
"integrity": "sha512-xBKP8N3AXOs06VcKvIuvIQoUlGs7Hb78ftJWahLaRX909jKPMgGxR5XjvrawzTTZMSTU3DzdjDNPwG6fPM/ypQ==",
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.1.4",
"style-mod": "^4.0.0",
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
},
"engines": {
"node": ">=6.0.0"
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.17",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
"integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "3.1.0",
"@jridgewell/sourcemap-codec": "1.4.14"
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.1.tgz",
"integrity": "sha512-8TR5++Q/F//tpDsLd5zkrvEX5xxeemafEaek7mUp7Y+bI8cKQXdSqhzTOBaOogETcMOVr0pT3BBPXp13477ciw=="
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.2.tgz",
"integrity": "sha512-CAun1WR1glxG9ZdOokTZwXbcwB7PXkIEyZRUMFBVwSrhTcogWq634/ByNImrkUnQhjju6xsIaOBIxvcRJtplXQ==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.2.4.tgz",
"integrity": "sha512-L/52/oMJBFXXx8qBYF4UgktLP2geQ/qn5Fd8+5L/mqlLLCB9+qdKktFAtejd9FdFMaFx6lrP5rmLz4sN3Kplcg==",
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.1.tgz",
"integrity": "sha512-ReY88T7JhJjeRVbfCyNj+NXAG3IIsVMsX9b5/9jC98dRP8/yxlZdz7mHZbHk5zHr24wZZICS5AcXsFZAXYUQEg==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.0",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
@ -193,7 +202,7 @@
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0"
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
@ -205,6 +214,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.1.0.tgz",
"integrity": "sha512-N2KK+qUfHX2hBzVzM41UWGLrEmcjVC37spC8R3c9mt3oEDFKh3N2e12/lLp9aVSt86veR0TQiCNQXrm8C6aiUQ==",
"license": "MIT",
"dependencies": {
"terser": "^5.15.1"
},
@ -221,19 +231,20 @@
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0"
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
@ -242,19 +253,22 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
"integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==",
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@ -265,23 +279,14 @@
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
@ -295,31 +300,45 @@
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@ -329,41 +348,36 @@
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dependencies": {
"function-bind": "^1.1.1"
},
"engines": {
"node": ">= 0.4.0"
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-builtin-module": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.0.tgz",
"integrity": "sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==",
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"builtin-modules": "^3.3.0"
"function-bind": "^1.1.2"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"license": "MIT",
"dependencies": {
"has": "^1.0.3"
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -372,28 +386,31 @@
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=8.6"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
@ -407,17 +424,22 @@
}
},
"node_modules/resolve": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.9.0",
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -426,6 +448,8 @@
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
"integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -441,6 +465,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@ -449,20 +474,23 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/style-mod": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz",
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@ -471,12 +499,13 @@
}
},
"node_modules/terser": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.15.1.tgz",
"integrity": "sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==",
"version": "5.49.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz",
"integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@ -488,361 +517,10 @@
}
},
"node_modules/w3c-keyname": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz",
"integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg=="
}
},
"dependencies": {
"@codemirror/autocomplete": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.3.2.tgz",
"integrity": "sha512-+VzxrHWkuvSSt0fw4I57SULo/NMrLnNgm6JHrkbIYfDw9jZJNTruCwkv32TCqSeC8xIXhYWMuxawwr/xOoHr8w==",
"requires": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.5.0",
"@lezer/common": "^1.0.0"
}
},
"@codemirror/commands": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.1.2.tgz",
"integrity": "sha512-sO3jdX1s0pam6lIdeSJLMN3DQ6mPEbM4yLvyKkdqtmd/UDwhXA5+AwFJ89rRXm6vTeOXBsE5cAmlos/t7MJdgg==",
"requires": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"@codemirror/lang-sql": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.3.3.tgz",
"integrity": "sha512-VNsHju8500fkiDyDU8jZyGQ8M0iXU0SmfeCoCeAYkACcEFlX63BOT8311pICXyw43VYRbS23w54RgSEQmixGjQ==",
"requires": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"@codemirror/language": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.3.1.tgz",
"integrity": "sha512-MK+G1QKaGfSEUg9YEFaBkMBI6j1ge4VMBPZv9fDYotw7w695c42x5Ba1mmwBkesYnzYFBfte6Hh9TDcKa6xORQ==",
"requires": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"@codemirror/lint": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.1.0.tgz",
"integrity": "sha512-mdvDQrjRmYPvQ3WrzF6Ewaao+NWERYtpthJvoQ3tK3t/44Ynhk8ZGjTSL9jMEv8CgSMogmt75X8ceOZRDSXHtQ==",
"requires": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"@codemirror/search": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.2.3.tgz",
"integrity": "sha512-V9n9233lopQhB1dyjsBK2Wc1i+8hcCqxl1wQ46c5HWWLePoe4FluV3TGHoZ04rBRlGjNyz9DTmpJErig8UE4jw==",
"requires": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"@codemirror/state": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.1.4.tgz",
"integrity": "sha512-g+3OJuRylV5qsXuuhrc6Cvs1NQluNioepYMM2fhnpYkNk7NgX+j0AFuevKSVKzTDmDyt9+Puju+zPdHNECzCNQ=="
},
"@codemirror/view": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.5.1.tgz",
"integrity": "sha512-xBKP8N3AXOs06VcKvIuvIQoUlGs7Hb78ftJWahLaRX909jKPMgGxR5XjvrawzTTZMSTU3DzdjDNPwG6fPM/ypQ==",
"requires": {
"@codemirror/state": "^6.1.4",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"requires": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
},
"@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw=="
},
"@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"requires": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"@jridgewell/trace-mapping": {
"version": "0.3.17",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
"integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
"requires": {
"@jridgewell/resolve-uri": "3.1.0",
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
"@lezer/common": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.1.tgz",
"integrity": "sha512-8TR5++Q/F//tpDsLd5zkrvEX5xxeemafEaek7mUp7Y+bI8cKQXdSqhzTOBaOogETcMOVr0pT3BBPXp13477ciw=="
},
"@lezer/highlight": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.2.tgz",
"integrity": "sha512-CAun1WR1glxG9ZdOokTZwXbcwB7PXkIEyZRUMFBVwSrhTcogWq634/ByNImrkUnQhjju6xsIaOBIxvcRJtplXQ==",
"requires": {
"@lezer/common": "^1.0.0"
}
},
"@lezer/lr": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.2.4.tgz",
"integrity": "sha512-L/52/oMJBFXXx8qBYF4UgktLP2geQ/qn5Fd8+5L/mqlLLCB9+qdKktFAtejd9FdFMaFx6lrP5rmLz4sN3Kplcg==",
"requires": {
"@lezer/common": "^1.0.0"
}
},
"@rollup/plugin-node-resolve": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.1.tgz",
"integrity": "sha512-ReY88T7JhJjeRVbfCyNj+NXAG3IIsVMsX9b5/9jC98dRP8/yxlZdz7mHZbHk5zHr24wZZICS5AcXsFZAXYUQEg==",
"requires": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.0",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
}
},
"@rollup/plugin-terser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.1.0.tgz",
"integrity": "sha512-N2KK+qUfHX2hBzVzM41UWGLrEmcjVC37spC8R3c9mt3oEDFKh3N2e12/lLp9aVSt86veR0TQiCNQXrm8C6aiUQ==",
"requires": {
"terser": "^5.15.1"
}
},
"@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
"requires": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
}
},
"@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
},
"acorn": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
"integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA=="
},
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="
},
"codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"requires": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
},
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"requires": {
"function-bind": "^1.1.1"
}
},
"is-builtin-module": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.0.tgz",
"integrity": "sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==",
"requires": {
"builtin-modules": "^3.3.0"
}
},
"is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
"requires": {
"has": "^1.0.3"
}
},
"is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
},
"path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="
},
"prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true
},
"resolve": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
"requires": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
}
},
"rollup": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
"integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==",
"requires": {
"fsevents": "~2.3.2"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"style-mod": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz",
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
},
"supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
},
"terser": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.15.1.tgz",
"integrity": "sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==",
"requires": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
}
},
"w3c-keyname": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz",
"integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg=="
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}

Some files were not shown because too many files have changed in this diff Show more