mirror of
https://github.com/simonw/datasette.git
synced 2026-09-25 11:24:08 +02:00
Compare commits
No commits in common. "main" and "1.0a38" have entirely different histories.
136 changed files with 2615 additions and 14536 deletions
54
.github/workflows/deploy-latest.yml
vendored
54
.github/workflows/deploy-latest.yml
vendored
|
|
@ -14,46 +14,24 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- name: Check out datasette
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: pip
|
cache: pip
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install . --group dev
|
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
|
- name: Run tests
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
run: |
|
run: |
|
||||||
pytest -n auto -m "not serial"
|
pytest -n auto -m "not serial"
|
||||||
pytest -m "serial"
|
pytest -m "serial"
|
||||||
- name: Build fixtures.db and other files needed to deploy the demo
|
- name: Build fixtures.db and other files needed to deploy the demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |-
|
run: |-
|
||||||
python tests/fixtures.py \
|
python tests/fixtures.py \
|
||||||
fixtures.db \
|
fixtures.db \
|
||||||
|
|
@ -61,18 +39,14 @@ jobs:
|
||||||
fixtures-metadata.json \
|
fixtures-metadata.json \
|
||||||
plugins \
|
plugins \
|
||||||
--extra-db-filename extra_database.db
|
--extra-db-filename extra_database.db
|
||||||
# Package the config with the plugins, excluding test-only plugin secrets
|
|
||||||
# that reference temporary files outside the deployed container.
|
|
||||||
jq 'del(.plugins)' fixtures-config.json > plugins/fixtures-config.json
|
|
||||||
- name: Build docs.db
|
- name: Build docs.db
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
run: |-
|
run: |-
|
||||||
cd docs
|
cd docs
|
||||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||||
sphinx-to-sqlite ../docs.db _build
|
sphinx-to-sqlite ../docs.db _build
|
||||||
cd ..
|
cd ..
|
||||||
- name: Set up the alternate-route demo
|
- name: Set up the alternate-route demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
echo '
|
echo '
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -84,7 +58,6 @@ jobs:
|
||||||
' > plugins/alternative_route.py
|
' > plugins/alternative_route.py
|
||||||
cp fixtures.db fixtures2.db
|
cp fixtures.db fixtures2.db
|
||||||
- name: And the counters writable stored query demo
|
- name: And the counters writable stored query demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
cat > plugins/counters.py <<EOF
|
cat > plugins/counters.py <<EOF
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -124,15 +97,12 @@ jobs:
|
||||||
# cat metadata.json
|
# cat metadata.json
|
||||||
- id: auth
|
- id: auth
|
||||||
name: Authenticate to Google Cloud
|
name: Authenticate to Google Cloud
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: google-github-actions/auth@v3
|
uses: google-github-actions/auth@v3
|
||||||
with:
|
with:
|
||||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
- name: Set up Cloud SDK
|
- name: Set up Cloud SDK
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: google-github-actions/setup-gcloud@v3
|
uses: google-github-actions/setup-gcloud@v3
|
||||||
- name: Deploy to Cloud Run
|
- name: Deploy to Cloud Run
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
env:
|
env:
|
||||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
||||||
run: |-
|
run: |-
|
||||||
|
|
@ -147,16 +117,16 @@ jobs:
|
||||||
--plugins-dir=plugins \
|
--plugins-dir=plugins \
|
||||||
--branch=$GITHUB_SHA \
|
--branch=$GITHUB_SHA \
|
||||||
--version-note=$GITHUB_SHA \
|
--version-note=$GITHUB_SHA \
|
||||||
--extra-options="--config plugins/fixtures-config.json --setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
||||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||||
--service "datasette-latest$SUFFIX" \
|
--service "datasette-latest$SUFFIX" \
|
||||||
--secret $LATEST_DATASETTE_SECRET
|
--secret $LATEST_DATASETTE_SECRET
|
||||||
- name: Upload latest documentation database to S3 (only for main)
|
- name: Deploy to docs as well (only for main)
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ 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 }}
|
|
||||||
run: |-
|
run: |-
|
||||||
# Keep development documentation separate from the stable release database.
|
# Deploy docs.db to a different service
|
||||||
s3-credentials put-object datasette-docs latest/docs.db docs.db \
|
datasette publish cloudrun docs.db \
|
||||||
--content-type application/octet-stream
|
--branch=$GITHUB_SHA \
|
||||||
|
--version-note=$GITHUB_SHA \
|
||||||
|
--extra-options="--setting template_debug 1" \
|
||||||
|
--service=datasette-docs-latest
|
||||||
|
|
|
||||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
|
|
@ -2,15 +2,9 @@ name: Playwright
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
|
||||||
11
.github/workflows/prettier.yml
vendored
11
.github/workflows/prettier.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Check JavaScript for conformance with Prettier
|
name: Check JavaScript for conformance with Prettier
|
||||||
|
|
||||||
on:
|
on: [push]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
24
.github/workflows/publish.yml
vendored
24
.github/workflows/publish.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Publish Python Package
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [created]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -51,8 +51,6 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
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:
|
deploy_static_docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
|
|
@ -68,20 +66,26 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install . --group dev
|
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
|
- name: Build docs.db
|
||||||
run: |-
|
run: |-
|
||||||
cd docs
|
cd docs
|
||||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||||
sphinx-to-sqlite ../docs.db _build
|
sphinx-to-sqlite ../docs.db _build
|
||||||
cd ..
|
cd ..
|
||||||
- name: Upload stable documentation database to S3
|
- id: auth
|
||||||
env:
|
name: Authenticate to Google Cloud
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
|
uses: google-github-actions/auth@v2
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
|
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: |-
|
run: |-
|
||||||
s3-credentials put-object datasette-docs docs.db docs.db \
|
gcloud config set run/region us-central1
|
||||||
--content-type application/octet-stream
|
gcloud config set project datasette-222320
|
||||||
|
datasette publish cloudrun docs.db \
|
||||||
|
--service=datasette-docs-stable
|
||||||
|
|
||||||
deploy_docker:
|
deploy_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
11
.github/workflows/spellcheck.yml
vendored
11
.github/workflows/spellcheck.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Check spelling in documentation
|
name: Check spelling in documentation
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
40
.github/workflows/test-coverage.yml
vendored
Normal file
40
.github/workflows/test-coverage.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
name: Calculate test coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out datasette
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: '**/pyproject.toml'
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install . --group dev
|
||||||
|
python -m pip install pytest-cov
|
||||||
|
- name: Run tests
|
||||||
|
run: |-
|
||||||
|
ls -lah
|
||||||
|
cat .coveragerc
|
||||||
|
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
|
||||||
|
ls -lah
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: codecov/codecov-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
file: coverage.xml
|
||||||
6
.github/workflows/test-pyodide.yml
vendored
6
.github/workflows/test-pyodide.yml
vendored
|
|
@ -2,15 +2,9 @@ name: Test in Pyodide with shot-scraper
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
|
||||||
15
.github/workflows/test-sqlite-support.yml
vendored
15
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Test SQLite versions
|
name: Test SQLite versions
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -21,10 +12,10 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest]
|
platform: [ubuntu-latest]
|
||||||
python-version: ["3.13"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
sqlite-version: [
|
sqlite-version: [
|
||||||
#"3", # latest version
|
#"3", # latest version
|
||||||
#"3.46",
|
"3.46",
|
||||||
#"3.45",
|
#"3.45",
|
||||||
#"3.27",
|
#"3.27",
|
||||||
#"3.26",
|
#"3.26",
|
||||||
|
|
|
||||||
34
.github/workflows/test.yml
vendored
34
.github/workflows/test.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -20,20 +11,16 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
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"]
|
||||||
include:
|
|
||||||
- python-version: "3.14"
|
|
||||||
coverage: true
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v7
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
allow-prereleases: true
|
allow-prereleases: true
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
check-latest: true
|
|
||||||
- name: Build extension for --load-extension test
|
- name: Build extension for --load-extension test
|
||||||
run: |-
|
run: |-
|
||||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||||
|
|
@ -41,27 +28,12 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
pip install . --group dev
|
pip install . --group dev
|
||||||
pip freeze
|
pip freeze
|
||||||
- name: Install pytest-cov
|
|
||||||
if: ${{ matrix.coverage }}
|
|
||||||
run: pip install pytest-cov
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ matrix.coverage }}" = "true" ]; then
|
|
||||||
COV="--cov=datasette --cov-config=.coveragerc"
|
|
||||||
pytest -n auto -m "not serial" $COV --cov-report=
|
|
||||||
pytest -m "serial" $COV --cov-append --cov-report xml:coverage.xml --cov-report term
|
|
||||||
else
|
|
||||||
pytest -n auto -m "not serial"
|
pytest -n auto -m "not serial"
|
||||||
pytest -m "serial"
|
pytest -m "serial"
|
||||||
fi
|
|
||||||
# And the test that exceeds a localhost HTTPS server
|
# And the test that exceeds a localhost HTTPS server
|
||||||
tests/test_datasette_https_server.sh
|
tests/test_datasette_https_server.sh
|
||||||
- name: Upload coverage report
|
|
||||||
if: ${{ matrix.coverage }}
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
files: coverage.xml
|
|
||||||
- name: Black
|
- name: Black
|
||||||
run: |
|
run: |
|
||||||
black --version
|
black --version
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Version of Datasette to install, e.g. 0.55
|
||||||
# docker build . -t datasette --build-arg VERSION=0.55
|
# docker build . -t datasette --build-arg VERSION=0.55
|
||||||
|
|
|
||||||
7
Justfile
7
Justfile
|
|
@ -49,18 +49,13 @@ export DATASETTE_SECRET := "not_a_secret"
|
||||||
uv run cog -r README.md docs/*.rst
|
uv run cog -r README.md docs/*.rst
|
||||||
|
|
||||||
# Serve live docs on localhost:8000
|
# Serve live docs on localhost:8000
|
||||||
@docs: shots cog blacken-docs
|
@docs: cog blacken-docs
|
||||||
uv run make -C docs livehtml
|
uv run make -C docs livehtml
|
||||||
|
|
||||||
# Build docs as static HTML
|
# Build docs as static HTML
|
||||||
@docs-build: cog blacken-docs
|
@docs-build: cog blacken-docs
|
||||||
rm -rf docs/_build && cd docs && uv run make html
|
rm -rf docs/_build && cd docs && uv run make html
|
||||||
|
|
||||||
# Take any missing documentation screenshots defined in docs/shots.yml
|
|
||||||
@shots:
|
|
||||||
uv run --group shots shot-scraper install
|
|
||||||
cd docs && uv run --group shots shot-scraper multi shots.yml --no-clobber --reduced-motion --retina
|
|
||||||
|
|
||||||
# Apply Black
|
# Apply Black
|
||||||
@black:
|
@black:
|
||||||
uv run black datasette tests
|
uv run black datasette tests
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
|
||||||
|
|
||||||
pip install datasette
|
pip install datasette
|
||||||
|
|
||||||
Datasette requires Python 3.10 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||||
|
|
||||||
## Basic usage
|
## Basic usage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from datasette.permissions import Permission # noqa
|
from datasette.permissions import Permission # noqa
|
||||||
from datasette.version import __version_info__, __version__ # noqa
|
from datasette.version import __version_info__, __version__ # noqa
|
||||||
from datasette.events import Event # noqa
|
from datasette.events import Event # noqa
|
||||||
from datasette.background_tasks import BackgroundTask, BackgroundTaskSupervisor # noqa
|
|
||||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||||
from datasette.utils.asgi import ( # noqa
|
from datasette.utils.asgi import ( # noqa
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
|
|
||||||
465
datasette/app.py
465
datasette/app.py
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextvars
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Sequence
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ import urllib.parse
|
||||||
from concurrent import futures
|
from concurrent import futures
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx2
|
import httpx
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
from jinja2 import (
|
from jinja2 import (
|
||||||
ChoiceLoader,
|
ChoiceLoader,
|
||||||
|
|
@ -41,7 +42,6 @@ from jinja2.exceptions import TemplateNotFound
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
from . import stored_queries, write_sql
|
from . import stored_queries, write_sql
|
||||||
from .background_tasks import BackgroundTask, BackgroundTaskSupervisor
|
|
||||||
from .column_types import SQLiteType
|
from .column_types import SQLiteType
|
||||||
from .csrf import CrossOriginProtectionMiddleware
|
from .csrf import CrossOriginProtectionMiddleware
|
||||||
from .database import Database, QueryInterrupted
|
from .database import Database, QueryInterrupted
|
||||||
|
|
@ -49,16 +49,6 @@ from .events import Event
|
||||||
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
||||||
from .renderer import json_renderer
|
from .renderer import json_renderer
|
||||||
from .resources import DatabaseResource, TableResource
|
from .resources import DatabaseResource, TableResource
|
||||||
from .telemetry import (
|
|
||||||
TelemetryMiddleware,
|
|
||||||
_in_datasette_client,
|
|
||||||
clamp_http_method,
|
|
||||||
register_datasette,
|
|
||||||
request_span,
|
|
||||||
tracer,
|
|
||||||
unregister_datasette,
|
|
||||||
)
|
|
||||||
from .telemetry_registry import HTTP_ROUTE, STARTUP
|
|
||||||
from .tokens import TokenInvalid
|
from .tokens import TokenInvalid
|
||||||
from .tracer import AsgiTracer
|
from .tracer import AsgiTracer
|
||||||
from .url_builder import Urls
|
from .url_builder import Urls
|
||||||
|
|
@ -155,7 +145,6 @@ from .views.stored_queries import (
|
||||||
)
|
)
|
||||||
from .views.table import (
|
from .views.table import (
|
||||||
TableAutocompleteView,
|
TableAutocompleteView,
|
||||||
TableCountView,
|
|
||||||
TableDropView,
|
TableDropView,
|
||||||
TableFragmentView,
|
TableFragmentView,
|
||||||
TableInsertView,
|
TableInsertView,
|
||||||
|
|
@ -175,7 +164,8 @@ app_root = Path(__file__).parent.parent
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# _in_datasette_client is defined in telemetry.py to avoid a circular import
|
# Context variable to track when code is executing within a datasette.client request
|
||||||
|
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
||||||
|
|
||||||
|
|
||||||
class _DatasetteClientContext:
|
class _DatasetteClientContext:
|
||||||
|
|
@ -325,7 +315,7 @@ def _permission_cache_key(actor, action, parent, child):
|
||||||
actor_key = (
|
actor_key = (
|
||||||
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
|
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
|
||||||
)
|
)
|
||||||
return (actor_key, action.name, parent, action.normalize_child(child))
|
return (actor_key, action, parent, child)
|
||||||
|
|
||||||
|
|
||||||
async def favicon(request, send):
|
async def favicon(request, send):
|
||||||
|
|
@ -432,7 +422,6 @@ class Datasette:
|
||||||
default_deny=False,
|
default_deny=False,
|
||||||
):
|
):
|
||||||
self._startup_invoked = False
|
self._startup_invoked = False
|
||||||
self._shutdown_invoked = False
|
|
||||||
self._closed = False
|
self._closed = False
|
||||||
assert config_dir is None or isinstance(
|
assert config_dir is None or isinstance(
|
||||||
config_dir, Path
|
config_dir, Path
|
||||||
|
|
@ -464,11 +453,8 @@ class Datasette:
|
||||||
self.databases = collections.OrderedDict()
|
self.databases = collections.OrderedDict()
|
||||||
self.actions = {} # .invoke_startup() will populate this
|
self.actions = {} # .invoke_startup() will populate this
|
||||||
self._column_types = {} # .invoke_startup() will populate this
|
self._column_types = {} # .invoke_startup() will populate this
|
||||||
self._setup_db_done = False
|
|
||||||
self._suppress_background_tasks = False
|
|
||||||
try:
|
try:
|
||||||
self._refresh_schemas_lock = asyncio.Lock()
|
self._refresh_schemas_lock = asyncio.Lock()
|
||||||
self._startup_lock = asyncio.Lock()
|
|
||||||
except RuntimeError as rex:
|
except RuntimeError as rex:
|
||||||
# Workaround for intermittent test failure, see:
|
# Workaround for intermittent test failure, see:
|
||||||
# https://github.com/simonw/datasette/issues/1802
|
# https://github.com/simonw/datasette/issues/1802
|
||||||
|
|
@ -476,10 +462,8 @@ class Datasette:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._refresh_schemas_lock = asyncio.Lock()
|
self._refresh_schemas_lock = asyncio.Lock()
|
||||||
self._startup_lock = asyncio.Lock()
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
self._background_tasks = BackgroundTaskSupervisor(self)
|
|
||||||
self.crossdb = crossdb
|
self.crossdb = crossdb
|
||||||
self.nolock = nolock
|
self.nolock = nolock
|
||||||
if memory or crossdb or not self.files:
|
if memory or crossdb or not self.files:
|
||||||
|
|
@ -651,8 +635,6 @@ class Datasette:
|
||||||
self.root_enabled = False
|
self.root_enabled = False
|
||||||
self.default_deny = default_deny
|
self.default_deny = default_deny
|
||||||
self.client = DatasetteClient(self)
|
self.client = DatasetteClient(self)
|
||||||
# Last, so metric callbacks never see a partially initialized instance
|
|
||||||
register_datasette(self)
|
|
||||||
|
|
||||||
async def apply_metadata_json(self):
|
async def apply_metadata_json(self):
|
||||||
# Apply any metadata entries from metadata.json to the internal tables
|
# Apply any metadata entries from metadata.json to the internal tables
|
||||||
|
|
@ -793,8 +775,6 @@ class Datasette:
|
||||||
# This must be called for Datasette to be in a usable state
|
# This must be called for Datasette to be in a usable state
|
||||||
if self._startup_invoked:
|
if self._startup_invoked:
|
||||||
return
|
return
|
||||||
# Group spans created during startup under a single parent span
|
|
||||||
with tracer.start_as_current_span(STARTUP):
|
|
||||||
# Register event classes
|
# Register event classes
|
||||||
event_classes = []
|
event_classes = []
|
||||||
for hook in pm.hook.register_events(datasette=self):
|
for hook in pm.hook.register_events(datasette=self):
|
||||||
|
|
@ -831,9 +811,7 @@ class Datasette:
|
||||||
if hook:
|
if hook:
|
||||||
for ct_cls in hook:
|
for ct_cls in hook:
|
||||||
if ct_cls.name in self._column_types:
|
if ct_cls.name in self._column_types:
|
||||||
raise StartupError(
|
raise StartupError(f"Duplicate column type name: {ct_cls.name}")
|
||||||
f"Duplicate column type name: {ct_cls.name}"
|
|
||||||
)
|
|
||||||
self._column_types[ct_cls.name] = ct_cls
|
self._column_types[ct_cls.name] = ct_cls
|
||||||
|
|
||||||
for hook in pm.hook.prepare_jinja2_environment(
|
for hook in pm.hook.prepare_jinja2_environment(
|
||||||
|
|
@ -980,8 +958,6 @@ class Datasette:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
# Stop reporting metrics before closing databases
|
|
||||||
unregister_datasette(self)
|
|
||||||
first_exception = None
|
first_exception = None
|
||||||
dbs = list(self.databases.values()) + [self._internal_database]
|
dbs = list(self.databases.values()) + [self._internal_database]
|
||||||
for db in dbs:
|
for db in dbs:
|
||||||
|
|
@ -1553,28 +1529,15 @@ class Datasette:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.text_factory = lambda x: str(x, "utf-8", "replace")
|
conn.text_factory = lambda x: str(x, "utf-8", "replace")
|
||||||
if self.sqlite_extensions and database != INTERNAL_DB_NAME:
|
if self.sqlite_extensions and database != INTERNAL_DB_NAME:
|
||||||
# Extension loading is only enabled for as long as it takes to
|
|
||||||
# load the configured extensions. Leaving it enabled would let
|
|
||||||
# anyone who can execute SQL call load_extension() themselves.
|
|
||||||
conn.enable_load_extension(True)
|
conn.enable_load_extension(True)
|
||||||
try:
|
|
||||||
for extension in self.sqlite_extensions:
|
for extension in self.sqlite_extensions:
|
||||||
# "extension" is either a string path to the extension
|
# "extension" is either a string path to the extension
|
||||||
# or a 2-item tuple that specifies which entrypoint to load.
|
# or a 2-item tuple that specifies which entrypoint to load.
|
||||||
if isinstance(extension, tuple):
|
if isinstance(extension, tuple):
|
||||||
path, entrypoint = extension
|
path, entrypoint = extension
|
||||||
if sys.version_info >= (3, 12):
|
conn.execute("SELECT load_extension(?, ?)", [path, entrypoint])
|
||||||
conn.load_extension(path, entrypoint=entrypoint)
|
|
||||||
else:
|
else:
|
||||||
# Connection.load_extension() only gained the
|
conn.execute("SELECT load_extension(?)", [extension])
|
||||||
# entrypoint argument in Python 3.12
|
|
||||||
conn.execute(
|
|
||||||
"SELECT load_extension(?, ?)", [path, entrypoint]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.load_extension(extension)
|
|
||||||
finally:
|
|
||||||
conn.enable_load_extension(False)
|
|
||||||
if self.setting("cache_size_kb"):
|
if self.setting("cache_size_kb"):
|
||||||
conn.execute(f"PRAGMA cache_size=-{self.setting('cache_size_kb')}")
|
conn.execute(f"PRAGMA cache_size=-{self.setting('cache_size_kb')}")
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
@ -1767,145 +1730,8 @@ class Datasette:
|
||||||
sql, params = await build_allowed_resources_sql(
|
sql, params = await build_allowed_resources_sql(
|
||||||
self, actor, action, parent=parent, include_is_private=include_is_private
|
self, actor, action, parent=parent, include_is_private=include_is_private
|
||||||
)
|
)
|
||||||
if action == "view-table":
|
|
||||||
sql, params = await self._apply_derived_table_permissions_to_sql(
|
|
||||||
sql,
|
|
||||||
params,
|
|
||||||
actor=actor,
|
|
||||||
parent=parent,
|
|
||||||
include_is_private=include_is_private,
|
|
||||||
)
|
|
||||||
return ResourcesSQL(sql, params)
|
return ResourcesSQL(sql, params)
|
||||||
|
|
||||||
async def _allowed_derived_table_source(
|
|
||||||
self, database, source, *, actor, dependencies
|
|
||||||
):
|
|
||||||
"""Check an immediate source, denying sources that are themselves derived."""
|
|
||||||
if any(
|
|
||||||
TableResource.normalize_child(table)
|
|
||||||
== TableResource.normalize_child(source)
|
|
||||||
for table in dependencies
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
# The source has no dependency in this map. Evaluate its own permission
|
|
||||||
# and prerequisites without starting another dependency check.
|
|
||||||
verdicts = await self._allowed_many(
|
|
||||||
actions=["view-table"],
|
|
||||||
resource=TableResource(database, source),
|
|
||||||
actor=actor,
|
|
||||||
check_derived=False,
|
|
||||||
)
|
|
||||||
return verdicts["view-table"]
|
|
||||||
|
|
||||||
async def _apply_derived_table_permissions_to_sql(
|
|
||||||
self,
|
|
||||||
sql,
|
|
||||||
params,
|
|
||||||
*,
|
|
||||||
actor,
|
|
||||||
parent,
|
|
||||||
include_is_private,
|
|
||||||
):
|
|
||||||
databases = (
|
|
||||||
[(parent, self.databases[parent])]
|
|
||||||
if parent in self.databases
|
|
||||||
else ([] if parent is not None else list(self.databases.items()))
|
|
||||||
)
|
|
||||||
dependency_maps = dict(
|
|
||||||
zip(
|
|
||||||
(name for name, _ in databases),
|
|
||||||
await asyncio.gather(
|
|
||||||
*(db.derived_table_dependencies() for _, db in databases)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
dependencies = [
|
|
||||||
(database_name, child, source)
|
|
||||||
for database_name, dependency_map in dependency_maps.items()
|
|
||||||
for child, source in dependency_map.items()
|
|
||||||
]
|
|
||||||
if not dependencies:
|
|
||||||
return sql, params
|
|
||||||
|
|
||||||
sources = sorted(
|
|
||||||
{(database_name, source) for database_name, _, source in dependencies}
|
|
||||||
)
|
|
||||||
actor_verdicts = await asyncio.gather(
|
|
||||||
*(
|
|
||||||
self._allowed_derived_table_source(
|
|
||||||
database_name,
|
|
||||||
source,
|
|
||||||
actor=actor,
|
|
||||||
dependencies=dependency_maps[database_name],
|
|
||||||
)
|
|
||||||
for database_name, source in sources
|
|
||||||
)
|
|
||||||
)
|
|
||||||
actor_allowed = dict(zip(sources, actor_verdicts))
|
|
||||||
|
|
||||||
anonymous_allowed = {}
|
|
||||||
if include_is_private:
|
|
||||||
anonymous_verdicts = await asyncio.gather(
|
|
||||||
*(
|
|
||||||
self._allowed_derived_table_source(
|
|
||||||
database_name,
|
|
||||||
source,
|
|
||||||
actor=None,
|
|
||||||
dependencies=dependency_maps[database_name],
|
|
||||||
)
|
|
||||||
for database_name, source in sources
|
|
||||||
)
|
|
||||||
)
|
|
||||||
anonymous_allowed = dict(zip(sources, anonymous_verdicts))
|
|
||||||
|
|
||||||
wrapped_params = dict(params)
|
|
||||||
derived_rows = [
|
|
||||||
[
|
|
||||||
database_name,
|
|
||||||
child,
|
|
||||||
int(actor_allowed[(database_name, source)]),
|
|
||||||
*(
|
|
||||||
[int(anonymous_allowed[(database_name, source)])]
|
|
||||||
if include_is_private
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for database_name, child, source in dependencies
|
|
||||||
]
|
|
||||||
derived_param = "_datasette_derived_permissions"
|
|
||||||
while derived_param in wrapped_params:
|
|
||||||
derived_param += "_"
|
|
||||||
wrapped_params[derived_param] = json.dumps(derived_rows)
|
|
||||||
|
|
||||||
derived_columns = "parent, child, source_allowed"
|
|
||||||
select_columns = "allowed.parent, allowed.child, allowed.reason"
|
|
||||||
if include_is_private:
|
|
||||||
derived_columns += ", source_anonymous_allowed"
|
|
||||||
select_columns += (
|
|
||||||
", CASE WHEN derived.source_anonymous_allowed = 0 "
|
|
||||||
"THEN 1 ELSE allowed.is_private END AS is_private"
|
|
||||||
)
|
|
||||||
wrapped_sql = f"""
|
|
||||||
WITH derived_permissions({derived_columns}) AS (
|
|
||||||
SELECT
|
|
||||||
json_extract(value, '$[0]'),
|
|
||||||
json_extract(value, '$[1]'),
|
|
||||||
json_extract(value, '$[2]')
|
|
||||||
{", json_extract(value, '$[3]')" if include_is_private else ""}
|
|
||||||
FROM json_each(:{derived_param})
|
|
||||||
),
|
|
||||||
allowed AS (
|
|
||||||
{sql}
|
|
||||||
)
|
|
||||||
SELECT {select_columns}
|
|
||||||
FROM allowed
|
|
||||||
LEFT JOIN derived_permissions AS derived
|
|
||||||
ON allowed.parent = derived.parent AND allowed.child = derived.child COLLATE NOCASE
|
|
||||||
WHERE COALESCE(derived.source_allowed, 1) = 1
|
|
||||||
ORDER BY allowed.parent, allowed.child
|
|
||||||
""".strip()
|
|
||||||
return wrapped_sql, wrapped_params
|
|
||||||
|
|
||||||
async def allowed_resources(
|
async def allowed_resources(
|
||||||
self,
|
self,
|
||||||
action: str,
|
action: str,
|
||||||
|
|
@ -2108,12 +1934,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
)
|
)
|
||||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
||||||
"""
|
"""
|
||||||
return await self._allowed_many(
|
|
||||||
actions=actions, resource=resource, actor=actor, check_derived=True
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _allowed_many(self, *, actions, resource, actor, check_derived):
|
|
||||||
"""Evaluate permissions, optionally applying the one-hop source policy."""
|
|
||||||
from datasette.permissions import (
|
from datasette.permissions import (
|
||||||
_permission_check_cache,
|
_permission_check_cache,
|
||||||
_skip_permission_checks,
|
_skip_permission_checks,
|
||||||
|
|
@ -2151,7 +1971,7 @@ ORDER BY allowed.parent, allowed.child
|
||||||
to_check = []
|
to_check = []
|
||||||
for name in expanded:
|
for name in expanded:
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
key = _permission_cache_key(actor, self.actions[name], parent, child)
|
key = _permission_cache_key(actor, name, parent, child)
|
||||||
if key in cache:
|
if key in cache:
|
||||||
final[name] = cache[key]
|
final[name] = cache[key]
|
||||||
continue
|
continue
|
||||||
|
|
@ -2167,28 +1987,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
child=child,
|
child=child,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
|
||||||
check_derived
|
|
||||||
and "view-table" in to_check
|
|
||||||
and raw.get("view-table")
|
|
||||||
and isinstance(resource, TableResource)
|
|
||||||
and parent in self.databases
|
|
||||||
):
|
|
||||||
dependencies = await self.databases[parent].derived_table_dependencies()
|
|
||||||
source = next(
|
|
||||||
(
|
|
||||||
source
|
|
||||||
for table, source in dependencies.items()
|
|
||||||
if TableResource.normalize_child(table)
|
|
||||||
== TableResource.normalize_child(child)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if source is not None:
|
|
||||||
raw["view-table"] = await self._allowed_derived_table_source(
|
|
||||||
parent, source, actor=actor, dependencies=dependencies
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve(name):
|
def resolve(name):
|
||||||
# final verdict = own rules AND verdict of also_requires chain
|
# final verdict = own rules AND verdict of also_requires chain
|
||||||
if name in final:
|
if name in final:
|
||||||
|
|
@ -2206,9 +2004,7 @@ ORDER BY allowed.parent, allowed.child
|
||||||
# Cache the freshly computed checks
|
# Cache the freshly computed checks
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
for name in to_check:
|
for name in to_check:
|
||||||
cache[
|
cache[_permission_cache_key(actor, name, parent, child)] = final[name]
|
||||||
_permission_cache_key(actor, self.actions[name], parent, child)
|
|
||||||
] = final[name]
|
|
||||||
|
|
||||||
# Log every check (including cache hits) for the debug page,
|
# Log every check (including cache hits) for the debug page,
|
||||||
# dependencies before the actions that required them
|
# dependencies before the actions that required them
|
||||||
|
|
@ -2299,18 +2095,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
from datasette.resources import TableResource
|
from datasette.resources import TableResource
|
||||||
|
|
||||||
other_table = fk["other_table"]
|
other_table = fk["other_table"]
|
||||||
# Foreign key declarations can spell the target with different casing.
|
|
||||||
target_table = (
|
|
||||||
await db.execute(
|
|
||||||
"select name from sqlite_master where type='table' and name=? collate nocase",
|
|
||||||
[other_table],
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if target_table is None:
|
|
||||||
# SQLite accepts a foreign key to a table that does not exist, and
|
|
||||||
# linking to it would only lead to a 404
|
|
||||||
return {}
|
|
||||||
other_table = target_table[0]
|
|
||||||
other_column = fk["other_column"]
|
other_column = fk["other_column"]
|
||||||
if other_column is None:
|
if other_column is None:
|
||||||
other_pks = await db.primary_keys(other_table)
|
other_pks = await db.primary_keys(other_table)
|
||||||
|
|
@ -2494,21 +2278,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
)
|
)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _tasks(self):
|
|
||||||
return {
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"name": t.name,
|
|
||||||
"state": t.state,
|
|
||||||
"function": t.function,
|
|
||||||
"started_at": t.started_at,
|
|
||||||
"exception": repr(t.exception) if t.exception else None,
|
|
||||||
}
|
|
||||||
for t in self._background_tasks.tasks()
|
|
||||||
],
|
|
||||||
"launched": self._background_tasks.launched,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _actor(self, request):
|
def _actor(self, request):
|
||||||
return {"actor": request.actor}
|
return {"actor": request.actor}
|
||||||
|
|
||||||
|
|
@ -2615,8 +2384,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
datasette=self,
|
datasette=self,
|
||||||
):
|
):
|
||||||
extra_vars = await await_me_maybe(extra_vars)
|
extra_vars = await await_me_maybe(extra_vars)
|
||||||
if extra_vars is None:
|
|
||||||
continue
|
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
extra_vars, dict
|
extra_vars, dict
|
||||||
), f"extra_vars is of type {type(extra_vars)}"
|
), f"extra_vars is of type {type(extra_vars)}"
|
||||||
|
|
@ -2679,7 +2446,7 @@ ORDER BY allowed.parent, allowed.child
|
||||||
):
|
):
|
||||||
data = {"a": actor}
|
data = {"a": actor}
|
||||||
if expire_after:
|
if expire_after:
|
||||||
expires_at = int(time.time()) + expire_after
|
expires_at = int(time.time()) + (24 * 60 * 60)
|
||||||
data["e"] = baseconv.base62.encode(expires_at)
|
data["e"] = baseconv.base62.encode(expires_at)
|
||||||
response.set_cookie("ds_actor", self.sign(data, "actor"))
|
response.set_cookie("ds_actor", self.sign(data, "actor"))
|
||||||
|
|
||||||
|
|
@ -2799,12 +2566,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
),
|
),
|
||||||
r"/-/threads(\.(?P<format>json))?$",
|
r"/-/threads(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
JsonDataView.as_view(
|
|
||||||
self, "tasks.json", self._tasks, permission="permissions-debug"
|
|
||||||
),
|
|
||||||
r"/-/tasks(\.(?P<format>json))?$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(
|
||||||
self,
|
self,
|
||||||
|
|
@ -2979,10 +2740,6 @@ ORDER BY allowed.parent, allowed.child
|
||||||
TableSetColumnTypeView.as_view(self),
|
TableSetColumnTypeView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
TableCountView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/count$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
TableFragmentView.as_view(self),
|
TableFragmentView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
||||||
|
|
@ -3046,130 +2803,26 @@ ORDER BY allowed.parent, allowed.child
|
||||||
raise RowNotFound(db.name, table_name, pk_values)
|
raise RowNotFound(db.name, table_name, pk_values)
|
||||||
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
||||||
|
|
||||||
async def _startup_sequence(self):
|
|
||||||
"""Idempotently run the full startup sequence: table counts for
|
|
||||||
immutable databases, then invoke_startup(). Safe to call more than
|
|
||||||
once and safe to call concurrently - callers block until whichever
|
|
||||||
call got there first has finished.
|
|
||||||
|
|
||||||
This is the single entry point used by both AsgiLifespan (so
|
|
||||||
real deployments finish startup before accepting requests) and
|
|
||||||
AsgiRunOnFirstRequest (the fallback for hosts that never send
|
|
||||||
lifespan events, e.g. DatasetteClient's httpx2.ASGITransport), and
|
|
||||||
`datasette serve` (cli.py) calls it too. The fast path below checks
|
|
||||||
both `_startup_invoked` and `_setup_db_done` - not just the former -
|
|
||||||
so that a bare `await ds.invoke_startup()` made by a caller ahead of
|
|
||||||
`_startup_sequence()` (which only sets `_startup_invoked`) can't
|
|
||||||
make this method skip the immutable-database table-count precompute.
|
|
||||||
"""
|
|
||||||
if self._startup_invoked and self._setup_db_done:
|
|
||||||
return
|
|
||||||
async with self._startup_lock:
|
|
||||||
if self._startup_invoked and self._setup_db_done:
|
|
||||||
return
|
|
||||||
if not self._setup_db_done:
|
|
||||||
# First time server starts up, calculate table counts for
|
|
||||||
# immutable databases
|
|
||||||
for database in self.databases.values():
|
|
||||||
if not database.is_mutable:
|
|
||||||
await database.table_counts(limit=60 * 60 * 1000)
|
|
||||||
self._setup_db_done = True
|
|
||||||
await self.invoke_startup()
|
|
||||||
|
|
||||||
def add_background_task(self, func, name=None) -> BackgroundTask:
|
|
||||||
"""Register a piece of supervised background work, typically from
|
|
||||||
a plugin's ``startup`` hook.
|
|
||||||
|
|
||||||
``func`` must be a coroutine function taking one positional
|
|
||||||
argument, the ``Datasette`` instance - core calls ``func(self)``.
|
|
||||||
Callable any time after ``__init__``: if background tasks haven't
|
|
||||||
launched yet (the common case - most callers are ``startup`` hooks,
|
|
||||||
which run before launch), this buffers the registration until they
|
|
||||||
do; if they've already launched (e.g. called from a request
|
|
||||||
handler after the server is up), the task starts immediately.
|
|
||||||
|
|
||||||
Returns a :class:`~datasette.background_tasks.BackgroundTask`
|
|
||||||
handle (``.name``, ``.state``, ``.task``, ``.exception``,
|
|
||||||
``.started_at``, ``.function``, ``.cancel()``).
|
|
||||||
|
|
||||||
``name`` defaults to ``func.__qualname__``; on a name collision a
|
|
||||||
``-2``, ``-3``, ... suffix is appended, since names are how
|
|
||||||
``/-/tasks`` and log messages identify work.
|
|
||||||
"""
|
|
||||||
return self._background_tasks.add(func, name=name)
|
|
||||||
|
|
||||||
async def start_background_tasks(self):
|
|
||||||
"""Run startup (if it hasn't run yet) and launch every registered
|
|
||||||
background task.
|
|
||||||
|
|
||||||
Public entry point for tests, embedders, and headless CLIs (the
|
|
||||||
``datasette-rss``-style ``fetch --due`` shape) that want supervised
|
|
||||||
background tasks without running a server - equivalent to what
|
|
||||||
happens automatically via ASGI lifespan / the first-request
|
|
||||||
fallback in a served deployment.
|
|
||||||
"""
|
|
||||||
await self.invoke_startup()
|
|
||||||
await self._background_tasks.launch_all()
|
|
||||||
|
|
||||||
async def _launch_background_tasks(self):
|
|
||||||
"""Idempotently launch every registered background task. Private:
|
|
||||||
this is the entry point wired into the lifecycle trigger lists
|
|
||||||
(the second entry in both ``AsgiLifespan`` and
|
|
||||||
``AsgiRunOnFirstRequest``'s ``on_startup``, after
|
|
||||||
``_startup_sequence``) - not something plugins or embedders should
|
|
||||||
call directly; use ``add_background_task`` /
|
|
||||||
``start_background_tasks`` instead.
|
|
||||||
|
|
||||||
Positioned after ``_startup_sequence`` in both trigger lists so
|
|
||||||
launch always happens once every plugin's ``startup`` hook has had
|
|
||||||
a chance to register work - the ordering guarantee that makes
|
|
||||||
``add_background_task`` useful. No-ops when
|
|
||||||
``_suppress_background_tasks`` is set (the ``--get`` CLI path: its
|
|
||||||
one-shot TestClient request flows through the full ASGI stack,
|
|
||||||
including the first-request fallback, but must never launch
|
|
||||||
long-lived background work).
|
|
||||||
"""
|
|
||||||
if self._suppress_background_tasks:
|
|
||||||
return
|
|
||||||
await self._background_tasks.launch_all()
|
|
||||||
|
|
||||||
async def invoke_shutdown(self):
|
|
||||||
"""Run the graceful teardown sequence: plugin ``shutdown`` hooks,
|
|
||||||
then cancel and drain supervised background tasks, then close
|
|
||||||
every database.
|
|
||||||
"""
|
|
||||||
if self._shutdown_invoked:
|
|
||||||
return
|
|
||||||
self._shutdown_invoked = True
|
|
||||||
for hook in pm.hook.shutdown(datasette=self):
|
|
||||||
try:
|
|
||||||
await await_me_maybe(hook)
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger("datasette").exception("shutdown hook failed")
|
|
||||||
await self._background_tasks.cancel_all(grace=5.0)
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def app(self):
|
def app(self):
|
||||||
"""Returns an ASGI app function that serves the whole of Datasette"""
|
"""Returns an ASGI app function that serves the whole of Datasette"""
|
||||||
routes = self._routes()
|
routes = self._routes()
|
||||||
|
|
||||||
|
async def setup_db():
|
||||||
|
# First time server starts up, calculate table counts for immutable databases
|
||||||
|
for database in self.databases.values():
|
||||||
|
if not database.is_mutable:
|
||||||
|
await database.table_counts(limit=60 * 60 * 1000)
|
||||||
|
|
||||||
|
async def _close_on_shutdown():
|
||||||
|
self.close()
|
||||||
|
|
||||||
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
||||||
if self.setting("trace_debug"):
|
if self.setting("trace_debug"):
|
||||||
asgi = AsgiTracer(asgi)
|
asgi = AsgiTracer(asgi)
|
||||||
asgi = AsgiLifespan(
|
asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown])
|
||||||
asgi,
|
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
|
||||||
on_startup=[self._startup_sequence, self._launch_background_tasks],
|
|
||||||
on_shutdown=[self.invoke_shutdown],
|
|
||||||
)
|
|
||||||
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
||||||
asgi = wrapper(asgi)
|
asgi = wrapper(asgi)
|
||||||
asgi = AsgiRunOnFirstRequest(
|
|
||||||
asgi,
|
|
||||||
on_startup=[self._startup_sequence, self._launch_background_tasks],
|
|
||||||
)
|
|
||||||
# Outermost, so spans from plugin middleware and first-request
|
|
||||||
# startup are children of the request span
|
|
||||||
asgi = TelemetryMiddleware(asgi)
|
|
||||||
return asgi
|
return asgi
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3207,50 +2860,6 @@ class DatasetteRouter:
|
||||||
receive,
|
receive,
|
||||||
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
|
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
|
||||||
)
|
)
|
||||||
match, view = resolve_routes(self.routes, path)
|
|
||||||
is_static = view is favicon or getattr(view, "_datasette_static", False)
|
|
||||||
original_send = send
|
|
||||||
|
|
||||||
async def send(message):
|
|
||||||
if message["type"] == "http.response.start" and not (
|
|
||||||
is_static and message["status"] in (200, 304)
|
|
||||||
):
|
|
||||||
# Decide privacy after rendering, including for streaming responses
|
|
||||||
# and error handlers. A public primary resource can still include
|
|
||||||
# private labels, actor navigation, or cookie-dependent content.
|
|
||||||
headers = list(message.get("headers", []))
|
|
||||||
personalized = (
|
|
||||||
request.actor is not None
|
|
||||||
or "cookie" in request.headers
|
|
||||||
or "authorization" in request.headers
|
|
||||||
or any(key.lower() == b"set-cookie" for key, _ in headers)
|
|
||||||
)
|
|
||||||
if personalized:
|
|
||||||
headers = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in headers
|
|
||||||
if key.lower() != b"cache-control"
|
|
||||||
]
|
|
||||||
headers.append((b"cache-control", b"private, no-store"))
|
|
||||||
|
|
||||||
# Anonymous responses must not be reused for credentialed requests.
|
|
||||||
# Preserve any additional variation specified by views or plugins.
|
|
||||||
vary = [
|
|
||||||
part.strip()
|
|
||||||
for key, value in headers
|
|
||||||
if key.lower() == b"vary"
|
|
||||||
for part in value.split(b",")
|
|
||||||
if part.strip()
|
|
||||||
]
|
|
||||||
if b"*" not in vary:
|
|
||||||
for name in (b"Cookie", b"Authorization"):
|
|
||||||
if name.lower() not in {part.lower() for part in vary}:
|
|
||||||
vary.append(name)
|
|
||||||
headers = [(k, v) for k, v in headers if k.lower() != b"vary"]
|
|
||||||
headers.append((b"vary", b", ".join(vary)))
|
|
||||||
message = dict(message, headers=headers)
|
|
||||||
await original_send(message)
|
|
||||||
|
|
||||||
# Populate request_messages if ds_messages cookie is present
|
# Populate request_messages if ds_messages cookie is present
|
||||||
try:
|
try:
|
||||||
request._messages = self.ds.unsign(
|
request._messages = self.ds.unsign(
|
||||||
|
|
@ -3290,18 +2899,12 @@ class DatasetteRouter:
|
||||||
return await self.handle_401(request, send, token_error)
|
return await self.handle_401(request, send, token_error)
|
||||||
scope_modifications["actor"] = actor or default_actor
|
scope_modifications["actor"] = actor or default_actor
|
||||||
scope = dict(scope, **scope_modifications)
|
scope = dict(scope, **scope_modifications)
|
||||||
request.scope = scope
|
|
||||||
|
match, view = resolve_routes(self.routes, path)
|
||||||
|
|
||||||
if match is None:
|
if match is None:
|
||||||
return await self.handle_404(request, send)
|
return await self.handle_404(request, send)
|
||||||
|
|
||||||
# Now the route is known, add it to the request span
|
|
||||||
span = request_span(scope)
|
|
||||||
if span is not None:
|
|
||||||
route = match.re.pattern
|
|
||||||
span.set_attribute(HTTP_ROUTE, route)
|
|
||||||
span.update_name(f"{clamp_http_method(request.method)} {route}")
|
|
||||||
|
|
||||||
new_scope = dict(scope, url_route={"kwargs": match.groupdict()})
|
new_scope = dict(scope, url_route={"kwargs": match.groupdict()})
|
||||||
request.scope = new_scope
|
request.scope = new_scope
|
||||||
try:
|
try:
|
||||||
|
|
@ -3612,14 +3215,14 @@ class DatasetteClient:
|
||||||
with _DatasetteClientContext():
|
with _DatasetteClientContext():
|
||||||
if skip_permission_checks:
|
if skip_permission_checks:
|
||||||
with SkipPermissions():
|
with SkipPermissions():
|
||||||
async with httpx2.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx2.ASGITransport(app=self.app),
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await getattr(client, method)(self._fix(path), **kwargs)
|
return await getattr(client, method)(self._fix(path), **kwargs)
|
||||||
else:
|
else:
|
||||||
async with httpx2.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx2.ASGITransport(app=self.app),
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await getattr(client, method)(self._fix(path), **kwargs)
|
return await getattr(client, method)(self._fix(path), **kwargs)
|
||||||
|
|
@ -3666,10 +3269,10 @@ class DatasetteClient:
|
||||||
method: HTTP method (e.g., "GET", "POST", "PUT")
|
method: HTTP method (e.g., "GET", "POST", "PUT")
|
||||||
path: The path to request
|
path: The path to request
|
||||||
skip_permission_checks: If True, bypass all permission checks for this request
|
skip_permission_checks: If True, bypass all permission checks for this request
|
||||||
**kwargs: Additional arguments to pass to httpx2
|
**kwargs: Additional arguments to pass to httpx
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
httpx2.Response: The response from the request
|
httpx.Response: The response from the request
|
||||||
"""
|
"""
|
||||||
from datasette.permissions import SkipPermissions
|
from datasette.permissions import SkipPermissions
|
||||||
|
|
||||||
|
|
@ -3678,16 +3281,16 @@ class DatasetteClient:
|
||||||
with _DatasetteClientContext():
|
with _DatasetteClientContext():
|
||||||
if skip_permission_checks:
|
if skip_permission_checks:
|
||||||
with SkipPermissions():
|
with SkipPermissions():
|
||||||
async with httpx2.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx2.ASGITransport(app=self.app),
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await client.request(
|
return await client.request(
|
||||||
method, self._fix(path, avoid_path_rewrites), **kwargs
|
method, self._fix(path, avoid_path_rewrites), **kwargs
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
async with httpx2.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx2.ASGITransport(app=self.app),
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
cookies=kwargs.pop("cookies", None),
|
cookies=kwargs.pop("cookies", None),
|
||||||
) as client:
|
) as client:
|
||||||
return await client.request(
|
return await client.request(
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
"""
|
|
||||||
Supervised background-task registration for Datasette core.
|
|
||||||
|
|
||||||
Plugins that need long-lived background work (a polling loop, a queue
|
|
||||||
consumer, a scheduled job runner) register it with
|
|
||||||
``datasette.add_background_task(func, name=None)`` - typically from a
|
|
||||||
``startup`` plugin hook - instead of fire-and-forgetting their own
|
|
||||||
``asyncio.create_task()``. Core owns:
|
|
||||||
|
|
||||||
- **references**: every launched ``asyncio.Task`` is kept alive on a
|
|
||||||
:class:`BackgroundTaskSupervisor`, so it can never be silently garbage
|
|
||||||
collected the way an unreferenced ``create_task()`` call can be;
|
|
||||||
- **launch timing**: registered work is buffered until
|
|
||||||
:meth:`BackgroundTaskSupervisor.launch_all` runs, which core arranges to
|
|
||||||
happen only after *every* plugin's ``startup`` hook has finished - so
|
|
||||||
a task that depends on another plugin having registered something first
|
|
||||||
doesn't need ``tryfirst=True`` ordering tricks;
|
|
||||||
- **crash surfacing**: an unhandled exception in a background task is
|
|
||||||
logged with its full traceback to the ``datasette.background_tasks``
|
|
||||||
logger and recorded on the handle, instead of becoming an "Task
|
|
||||||
exception was never retrieved" warning nobody sees;
|
|
||||||
- **cancellation**: :meth:`BackgroundTaskSupervisor.cancel_all` cancels
|
|
||||||
every task still running and waits (with a grace period) for them to
|
|
||||||
actually stop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger("datasette.background_tasks")
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow_iso() -> str:
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _function_path(func: Callable) -> str:
|
|
||||||
"""Describe the callable without guessing which plugin registered it."""
|
|
||||||
while isinstance(func, functools.partial):
|
|
||||||
func = func.func
|
|
||||||
if not hasattr(func, "__qualname__"):
|
|
||||||
func = type(func).__call__
|
|
||||||
return f"{func.__module__}.{func.__qualname__}"
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTask:
|
|
||||||
"""A handle to a single piece of supervised background work.
|
|
||||||
|
|
||||||
States: ``registered`` (added but not yet launched) -> ``running`` ->
|
|
||||||
one of ``completed`` (returned cleanly), ``crashed`` (raised an
|
|
||||||
exception other than ``CancelledError`` - see ``.exception``), or
|
|
||||||
``cancelled`` (``.cancel()`` was called, or it was still running at
|
|
||||||
shutdown).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
func: Callable[[object], Awaitable[None]],
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.state = "registered"
|
|
||||||
self.task: asyncio.Task | None = None
|
|
||||||
self.exception: BaseException | None = None
|
|
||||||
self.started_at: str | None = None
|
|
||||||
self.function = _function_path(func)
|
|
||||||
self._func = func
|
|
||||||
self._supervisor: BackgroundTaskSupervisor | None = None
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
"""Cancel this task.
|
|
||||||
|
|
||||||
If it has already been launched, cancels the underlying
|
|
||||||
``asyncio.Task`` - its state becomes ``cancelled`` once the
|
|
||||||
cancellation is observed (asynchronously, via the task's done
|
|
||||||
callback). If it has not been launched yet, this is a no-op as
|
|
||||||
far as asyncio is concerned (there's no task to cancel) but it
|
|
||||||
deregisters the handle from its supervisor so it never runs.
|
|
||||||
"""
|
|
||||||
if self.task is not None:
|
|
||||||
self.task.cancel()
|
|
||||||
elif self._supervisor is not None:
|
|
||||||
self._supervisor._deregister(self)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<BackgroundTask name={self.name!r} state={self.state!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTaskSupervisor:
|
|
||||||
"""Owns registration and launch of every :class:`BackgroundTask` for a
|
|
||||||
single ``Datasette`` instance.
|
|
||||||
|
|
||||||
Registration (:meth:`add`) is separate from launch
|
|
||||||
(:meth:`launch_all`): plugins register work whenever convenient
|
|
||||||
(typically from a ``startup`` hook, but request handlers can register
|
|
||||||
dynamic per-job work too), and it either sits buffered until
|
|
||||||
:meth:`launch_all` runs, or - if :meth:`launch_all` has already run -
|
|
||||||
starts immediately.
|
|
||||||
|
|
||||||
Strong references to every :class:`BackgroundTask` (and its
|
|
||||||
``asyncio.Task``) are kept for the life of the instance, by design -
|
|
||||||
that's what makes the enrichments-style "fire-and-forget task gets
|
|
||||||
garbage collected mid-flight" bug impossible here. There is currently
|
|
||||||
no pruning of completed/crashed/cancelled tasks, so a plugin that
|
|
||||||
dynamically registers many short-lived tasks over a long process
|
|
||||||
lifetime (a per-job registration pattern, e.g. one task per queued
|
|
||||||
job) will grow this list without bound. That's an accepted v1
|
|
||||||
trade-off in favour of full introspection (``/-/tasks``); revisit
|
|
||||||
with a pruning or capping policy if unbounded growth is reported in
|
|
||||||
practice.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, datasette):
|
|
||||||
self._datasette = datasette
|
|
||||||
self._tasks: list[BackgroundTask] = []
|
|
||||||
self._names = set()
|
|
||||||
self._launched = False
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
def add(self, func, name=None) -> BackgroundTask:
|
|
||||||
base_name = name or getattr(func, "__qualname__", None) or repr(func)
|
|
||||||
actual_name = self._unique_name(base_name)
|
|
||||||
handle = BackgroundTask(actual_name, func)
|
|
||||||
handle._supervisor = self
|
|
||||||
self._tasks.append(handle)
|
|
||||||
self._names.add(actual_name)
|
|
||||||
if self._launched:
|
|
||||||
self._launch_one(handle)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def _unique_name(self, base_name: str) -> str:
|
|
||||||
if base_name not in self._names:
|
|
||||||
return base_name
|
|
||||||
n = 2
|
|
||||||
while f"{base_name}-{n}" in self._names:
|
|
||||||
n += 1
|
|
||||||
return f"{base_name}-{n}"
|
|
||||||
|
|
||||||
def _deregister(self, handle: BackgroundTask) -> None:
|
|
||||||
try:
|
|
||||||
self._tasks.remove(handle)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
self._names.discard(handle.name)
|
|
||||||
|
|
||||||
def _launch_one(self, handle: BackgroundTask) -> None:
|
|
||||||
handle.state = "running"
|
|
||||||
handle.started_at = _utcnow_iso()
|
|
||||||
handle.task = asyncio.create_task(
|
|
||||||
handle._func(self._datasette), name=handle.name
|
|
||||||
)
|
|
||||||
handle.task.add_done_callback(functools.partial(_on_task_done, handle))
|
|
||||||
|
|
||||||
async def launch_all(self) -> None:
|
|
||||||
"""Launch every currently-registered task that hasn't launched
|
|
||||||
yet. Idempotent and safe to call concurrently: subsequent (or
|
|
||||||
racing) calls are no-ops once the first has set ``self._launched``.
|
|
||||||
"""
|
|
||||||
if self._launched:
|
|
||||||
return
|
|
||||||
async with self._lock:
|
|
||||||
if self._launched:
|
|
||||||
return
|
|
||||||
self._launched = True
|
|
||||||
for handle in list(self._tasks):
|
|
||||||
if handle.task is None:
|
|
||||||
self._launch_one(handle)
|
|
||||||
|
|
||||||
async def cancel_all(self, grace: float = 5.0) -> None:
|
|
||||||
"""Cancel every task that isn't already done, then wait up to
|
|
||||||
``grace`` seconds for them to actually finish. Stragglers still
|
|
||||||
running after that are logged by name (but left to finish or not
|
|
||||||
on their own - this does not forcibly kill them, asyncio has no
|
|
||||||
mechanism for that).
|
|
||||||
"""
|
|
||||||
handles_by_task = {
|
|
||||||
handle.task: handle for handle in self._tasks if handle.task is not None
|
|
||||||
}
|
|
||||||
pending = [task for task in handles_by_task if not task.done()]
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
_done, not_done = await asyncio.wait(pending, timeout=grace)
|
|
||||||
if not_done:
|
|
||||||
names = sorted(handles_by_task[task].name for task in not_done)
|
|
||||||
logger.warning(
|
|
||||||
"%d background task(s) did not finish within the %.1fs grace "
|
|
||||||
"period after cancellation: %s",
|
|
||||||
len(names),
|
|
||||||
grace,
|
|
||||||
", ".join(names),
|
|
||||||
)
|
|
||||||
|
|
||||||
def tasks(self) -> list[BackgroundTask]:
|
|
||||||
"""Return every registered :class:`BackgroundTask`, launched or
|
|
||||||
not, in registration order. Used by the ``/-/tasks`` debug
|
|
||||||
endpoint.
|
|
||||||
"""
|
|
||||||
return list(self._tasks)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def launched(self) -> bool:
|
|
||||||
"""Whether :meth:`launch_all` has run yet - lets ``/-/tasks``
|
|
||||||
distinguish "no tasks registered" from "tasks registered but
|
|
||||||
nothing has armed the launch yet" without reaching for the
|
|
||||||
private ``_launched`` attribute.
|
|
||||||
"""
|
|
||||||
return self._launched
|
|
||||||
|
|
||||||
|
|
||||||
def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None:
|
|
||||||
if task.cancelled():
|
|
||||||
handle.state = "cancelled"
|
|
||||||
return
|
|
||||||
exc = task.exception()
|
|
||||||
if exc is not None:
|
|
||||||
handle.state = "crashed"
|
|
||||||
handle.exception = exc
|
|
||||||
logger.error("Background task %r crashed", handle.name, exc_info=exc)
|
|
||||||
return
|
|
||||||
handle.state = "completed"
|
|
||||||
|
|
@ -157,11 +157,7 @@ async def inspect_(files, sqlite_extensions):
|
||||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||||
data = {}
|
data = {}
|
||||||
for name, database in app.databases.items():
|
for name, database in app.databases.items():
|
||||||
|
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
||||||
def _inspect_tables(conn):
|
|
||||||
return inspect_tables(conn, {})
|
|
||||||
|
|
||||||
tables = await database.execute_fn(_inspect_tables)
|
|
||||||
data[name] = {
|
data[name] = {
|
||||||
"hash": database.hash,
|
"hash": database.hash,
|
||||||
"size": database.size,
|
"size": database.size,
|
||||||
|
|
@ -501,7 +497,6 @@ def uninstall(packages, yes):
|
||||||
"--internal",
|
"--internal",
|
||||||
type=click.Path(),
|
type=click.Path(),
|
||||||
help="Path to a persistent Datasette internal SQLite database",
|
help="Path to a persistent Datasette internal SQLite database",
|
||||||
envvar="DATASETTE_INTERNAL",
|
|
||||||
)
|
)
|
||||||
def serve(
|
def serve(
|
||||||
files,
|
files,
|
||||||
|
|
@ -668,6 +663,16 @@ def serve(
|
||||||
# Private utility mechanism for writing unit tests
|
# Private utility mechanism for writing unit tests
|
||||||
return ds
|
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:
|
if headers and not get:
|
||||||
raise click.ClickException("--headers can only be used with --get")
|
raise click.ClickException("--headers can only be used with --get")
|
||||||
|
|
||||||
|
|
@ -675,19 +680,6 @@ def serve(
|
||||||
raise click.ClickException("--token can only be used with --get")
|
raise click.ClickException("--token can only be used with --get")
|
||||||
|
|
||||||
if 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])
|
|
||||||
|
|
||||||
# --get never launches background tasks: TestClient's request below
|
|
||||||
# flows through the full ASGI stack, including the
|
|
||||||
# AsgiRunOnFirstRequest fallback, which would otherwise launch them.
|
|
||||||
ds._suppress_background_tasks = True
|
|
||||||
|
|
||||||
client = TestClient(ds)
|
client = TestClient(ds)
|
||||||
request_headers = {}
|
request_headers = {}
|
||||||
if token:
|
if token:
|
||||||
|
|
@ -712,23 +704,6 @@ def serve(
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
return
|
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
|
# Start the server
|
||||||
url = None
|
url = None
|
||||||
if root:
|
if root:
|
||||||
|
|
@ -740,7 +715,7 @@ def serve(
|
||||||
if open_browser:
|
if open_browser:
|
||||||
if url is None:
|
if url is None:
|
||||||
# Figure out most convenient URL - to table, database or homepage
|
# Figure out most convenient URL - to table, database or homepage
|
||||||
path = await initial_path_for_datasette(ds)
|
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||||
url = f"http://{host}:{port}{path}"
|
url = f"http://{host}:{port}{path}"
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
uvicorn_kwargs = {
|
uvicorn_kwargs = {
|
||||||
|
|
@ -756,10 +731,7 @@ def serve(
|
||||||
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||||
if ssl_certfile:
|
if ssl_certfile:
|
||||||
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
||||||
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
|
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||||
await server.serve()
|
|
||||||
|
|
||||||
asyncio.run(_serve_async())
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import contextvars
|
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
from opentelemetry import context as otel_context_api
|
|
||||||
from opentelemetry.trace import Status, StatusCode
|
|
||||||
|
|
||||||
from .inspect import inspect_hash
|
from .inspect import inspect_hash
|
||||||
from .telemetry import (
|
|
||||||
callback_name,
|
|
||||||
linked_root_span_kwargs,
|
|
||||||
record_operation_duration,
|
|
||||||
record_query_interrupted,
|
|
||||||
record_write_queue_wait,
|
|
||||||
sql_attribute,
|
|
||||||
sql_operation_name,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
from .telemetry_registry import (
|
|
||||||
CALLBACK,
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_OPERATION_NAME,
|
|
||||||
DB_QUERY,
|
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
DB_QUERY_TEXT,
|
|
||||||
DB_SYSTEM,
|
|
||||||
DB_WRITE_EXECUTE,
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
EXECUTEMANY,
|
|
||||||
EXECUTESCRIPT,
|
|
||||||
INTERRUPTED,
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
PARAM_COUNT,
|
|
||||||
PARAM_SETS,
|
|
||||||
ROWS_RETURNED,
|
|
||||||
SQL_ERROR_SUPPRESSED,
|
|
||||||
TIME_LIMIT_MS,
|
|
||||||
TRANSACTION,
|
|
||||||
TRUNCATED,
|
|
||||||
)
|
|
||||||
from .tracer import trace
|
from .tracer import trace
|
||||||
from .utils import (
|
from .utils import (
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
|
|
@ -65,7 +29,7 @@ from .utils import (
|
||||||
table_columns,
|
table_columns,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
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
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
|
|
@ -121,7 +85,6 @@ class Database:
|
||||||
self.cached_hash = None
|
self.cached_hash = None
|
||||||
self.cached_size = None
|
self.cached_size = None
|
||||||
self._cached_table_counts = None
|
self._cached_table_counts = None
|
||||||
self._cached_derived_table_dependencies = None
|
|
||||||
self._write_thread = None
|
self._write_thread = None
|
||||||
self._write_queue = None
|
self._write_queue = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
@ -130,9 +93,8 @@ class Database:
|
||||||
# These are used when in non-threaded mode:
|
# These are used when in non-threaded mode:
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
# Track file and memory connections, including reads on worker threads,
|
# This is used to track all file connections so they can be closed
|
||||||
# so close() can release all of them from the calling thread.
|
self._all_file_connections = []
|
||||||
self._all_connections = []
|
|
||||||
if not is_temp_disk:
|
if not is_temp_disk:
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
|
|
@ -183,12 +145,9 @@ class Database:
|
||||||
)
|
)
|
||||||
if not write:
|
if not write:
|
||||||
conn.execute("PRAGMA query_only=1")
|
conn.execute("PRAGMA query_only=1")
|
||||||
self._all_connections.append(conn)
|
|
||||||
return conn
|
return conn
|
||||||
if self.is_memory:
|
if self.is_memory:
|
||||||
conn = sqlite3.connect(":memory:", uri=True, check_same_thread=False)
|
return sqlite3.connect(":memory:", uri=True)
|
||||||
self._all_connections.append(conn)
|
|
||||||
return conn
|
|
||||||
|
|
||||||
# mode=ro or immutable=1?
|
# mode=ro or immutable=1?
|
||||||
if self.is_mutable:
|
if self.is_mutable:
|
||||||
|
|
@ -205,7 +164,7 @@ class Database:
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
||||||
)
|
)
|
||||||
self._all_connections.append(conn)
|
self._all_file_connections.append(conn)
|
||||||
if self.is_temp_disk and not self._wal_enabled:
|
if self.is_temp_disk and not self._wal_enabled:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self._wal_enabled = True
|
self._wal_enabled = True
|
||||||
|
|
@ -242,13 +201,13 @@ class Database:
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception: # noqa: BLE001, S110
|
||||||
# Shutdown teardown - a failed pending write must not block close()
|
# Shutdown teardown - a failed pending write must not block close()
|
||||||
pass
|
pass
|
||||||
# Close anything still tracked in _all_connections
|
# Close anything still tracked in _all_file_connections
|
||||||
for connection in self._all_connections:
|
for connection in self._all_file_connections:
|
||||||
try:
|
try:
|
||||||
connection.close()
|
connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception: # noqa: BLE001, S110
|
||||||
pass
|
pass
|
||||||
self._all_connections = []
|
self._all_file_connections = []
|
||||||
# Drop per-thread cached read connections we can reach
|
# Drop per-thread cached read connections we can reach
|
||||||
try:
|
try:
|
||||||
delattr(connections, self._thread_local_id)
|
delattr(connections, self._thread_local_id)
|
||||||
|
|
@ -287,43 +246,19 @@ class Database:
|
||||||
return_all=False,
|
return_all=False,
|
||||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||||
transaction=True,
|
transaction=True,
|
||||||
time_limit_ms=2000,
|
|
||||||
):
|
):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if returning_limit < 0:
|
if returning_limit < 0:
|
||||||
raise ValueError("returning_limit must be >= 0")
|
raise ValueError("returning_limit must be >= 0")
|
||||||
|
|
||||||
def execute_sql(conn):
|
def _inner(conn):
|
||||||
cursor = conn.execute(sql, params or [])
|
cursor = conn.execute(sql, params or [])
|
||||||
return ExecuteWriteResult.from_cursor(
|
return ExecuteWriteResult.from_cursor(
|
||||||
cursor, return_all=return_all, returning_limit=returning_limit
|
cursor, return_all=return_all, returning_limit=returning_limit
|
||||||
)
|
)
|
||||||
|
|
||||||
def _inner(conn):
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
try:
|
results = await self.execute_write_fn(
|
||||||
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( # noqa: SIM117
|
|
||||||
"sql", database=self.name, sql=sql.strip(), params=params
|
|
||||||
):
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
if params:
|
|
||||||
span.set_attribute(PARAM_COUNT, len(params))
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results = await self._execute_write_fn(
|
|
||||||
_inner, block=block, request=request, transaction=transaction
|
_inner, block=block, request=request, transaction=transaction
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
@ -334,17 +269,8 @@ class Database:
|
||||||
def _inner(conn):
|
def _inner(conn):
|
||||||
return conn.executescript(sql)
|
return conn.executescript(sql)
|
||||||
|
|
||||||
with trace( # noqa: SIM117
|
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||||
"sql", database=self.name, sql=sql.strip(), executescript=True
|
results = await self.execute_write_fn(
|
||||||
):
|
|
||||||
# No db.operation.name, since the script can contain multiple statements
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(EXECUTESCRIPT, True)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results = await self._execute_write_fn(
|
|
||||||
_inner, block=block, transaction=False, request=request
|
_inner, block=block, transaction=False, request=request
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
@ -366,19 +292,9 @@ class Database:
|
||||||
with trace(
|
with trace(
|
||||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||||
) as kwargs:
|
) as kwargs:
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
results, count = await self.execute_write_fn(
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(EXECUTEMANY, True)
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results, count = await self._execute_write_fn(
|
|
||||||
_inner, block=block, request=request
|
_inner, block=block, request=request
|
||||||
)
|
)
|
||||||
span.set_attribute(PARAM_SETS, count)
|
|
||||||
kwargs["count"] = count
|
kwargs["count"] = count
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -395,27 +311,19 @@ class Database:
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
self._all_connections.remove(isolated_connection)
|
self._all_file_connections.remove(isolated_connection)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# May already have been cleared by close().
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, callback_name(fn))
|
|
||||||
# Immutable databases run this on the read pool, not the write queue
|
|
||||||
with record_operation_duration(self.name, "write" if write else "read"):
|
|
||||||
if self.ds.executor is None:
|
if self.ds.executor is None:
|
||||||
# non-threaded mode
|
# non-threaded mode
|
||||||
return _run()
|
return _run()
|
||||||
if not write:
|
if not write:
|
||||||
# Immutable database - no writes can ever occur, so there
|
# Immutable database - no writes can ever occur, so there is no
|
||||||
# is no write queue to block; run against a fresh
|
# write queue to block; run against a fresh read-only connection
|
||||||
# read-only connection
|
|
||||||
ctx = contextvars.copy_context()
|
|
||||||
return await asyncio.get_running_loop().run_in_executor(
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
self.ds.executor, ctx.run, _run
|
self.ds.executor, _run
|
||||||
)
|
)
|
||||||
# Threaded mode - send to write thread
|
# Threaded mode - send to write thread
|
||||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||||
|
|
@ -423,30 +331,11 @@ class Database:
|
||||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
|
|
||||||
def _analyze_sql(conn):
|
return await self.execute_isolated_fn(
|
||||||
return analyze_sql_tables(conn, sql, params, database_name=self.name)
|
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||||
|
|
||||||
return await self.execute_isolated_fn(_analyze_sql)
|
|
||||||
|
|
||||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
|
||||||
"""Run `fn(conn)` on the write connection, traced as a `db.query` span.
|
|
||||||
|
|
||||||
The SQL-string write methods call `_execute_write_fn()` directly to
|
|
||||||
avoid creating a second span.
|
|
||||||
"""
|
|
||||||
self._check_not_closed()
|
|
||||||
# Record the name before _wrap_fn_with_hooks() wraps fn
|
|
||||||
name = callback_name(fn)
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, name)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
return await self._execute_write_fn(
|
|
||||||
fn, block=block, transaction=transaction, request=request
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
pending_events = []
|
pending_events = []
|
||||||
|
|
||||||
|
|
@ -465,15 +354,6 @@ class Database:
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
else:
|
else:
|
||||||
result = fn(self._write_connection)
|
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:
|
else:
|
||||||
result = await self._send_to_write_thread(
|
result = await self._send_to_write_thread(
|
||||||
fn, block=block, transaction=transaction
|
fn, block=block, transaction=transaction
|
||||||
|
|
@ -545,22 +425,11 @@ class Database:
|
||||||
)
|
)
|
||||||
self._write_thread.name = f"_execute_writes for database {self.name}"
|
self._write_thread.name = f"_execute_writes for database {self.name}"
|
||||||
self._write_thread.start()
|
self._write_thread.start()
|
||||||
task_id = uuid.uuid4()
|
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
reply_future = loop.create_future()
|
reply_future = loop.create_future()
|
||||||
# Capture the OpenTelemetry context and enqueue time for the write thread
|
|
||||||
self._write_queue.put(
|
self._write_queue.put(
|
||||||
WriteTask(
|
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||||
fn,
|
|
||||||
task_id,
|
|
||||||
loop,
|
|
||||||
reply_future,
|
|
||||||
isolated_connection,
|
|
||||||
transaction,
|
|
||||||
otel_context_api.get_current(),
|
|
||||||
time.time_ns(),
|
|
||||||
block,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if block:
|
if block:
|
||||||
return await reply_future
|
return await reply_future
|
||||||
|
|
@ -574,8 +443,6 @@ class Database:
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = self.connect(write=True)
|
conn = self.connect(write=True)
|
||||||
# Threads do not inherit the caller's context, so any spans
|
|
||||||
# created by prepare_connection hooks here are root spans
|
|
||||||
self.ds._prepare_connection(conn, self.name)
|
self.ds._prepare_connection(conn, self.name)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
# Stored and re-raised to whoever queues the next write
|
# Stored and re-raised to whoever queues the next write
|
||||||
|
|
@ -590,49 +457,21 @@ class Database:
|
||||||
# Best-effort close as the write thread exits
|
# Best-effort close as the write thread exits
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
# block=True: the caller awaits the result, so the write spans
|
|
||||||
# are children of the caller's span. The token must be detached
|
|
||||||
# in the finally block or the context leaks into later writes.
|
|
||||||
# block=False: the caller may finish first, so the write spans
|
|
||||||
# are root spans with a link back to the caller's span.
|
|
||||||
token = None
|
|
||||||
write_span_kwargs = {}
|
|
||||||
if task.block:
|
|
||||||
token = otel_context_api.attach(task.otel_context)
|
|
||||||
else:
|
|
||||||
write_span_kwargs = linked_root_span_kwargs(task.otel_context)
|
|
||||||
try:
|
|
||||||
exception = None
|
exception = None
|
||||||
result = None
|
result = None
|
||||||
# Span covers the time from enqueue to dequeue
|
|
||||||
dequeued_at_ns = time.time_ns()
|
|
||||||
tracer.start_span(
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
start_time=task.enqueued_at_ns,
|
|
||||||
**write_span_kwargs,
|
|
||||||
).end(end_time=dequeued_at_ns)
|
|
||||||
record_write_queue_wait(self.name, dequeued_at_ns - task.enqueued_at_ns)
|
|
||||||
if conn_exception is not None:
|
if conn_exception is not None:
|
||||||
exception = conn_exception
|
exception = conn_exception
|
||||||
elif task.isolated_connection:
|
elif task.isolated_connection:
|
||||||
try:
|
try:
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
task.isolated_connection,
|
|
||||||
)
|
|
||||||
span.set_attribute(TRANSACTION, task.transaction)
|
|
||||||
isolated_connection = self.connect(write=True)
|
isolated_connection = self.connect(write=True)
|
||||||
try:
|
try:
|
||||||
result = task.fn(isolated_connection)
|
result = task.fn(isolated_connection)
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
self._all_connections.remove(isolated_connection)
|
self._all_file_connections.remove(isolated_connection)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# May already have been cleared by close().
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
# Write thread must survive any task failure or the database wedges
|
# Write thread must survive any task failure or the database wedges
|
||||||
|
|
@ -641,14 +480,6 @@ class Database:
|
||||||
exception = e
|
exception = e
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
task.isolated_connection,
|
|
||||||
)
|
|
||||||
span.set_attribute(TRANSACTION, task.transaction)
|
|
||||||
if task.transaction:
|
if task.transaction:
|
||||||
with conn:
|
with conn:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
|
@ -660,31 +491,8 @@ class Database:
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
_deliver_write_result(task, result, exception)
|
_deliver_write_result(task, result, exception)
|
||||||
finally:
|
|
||||||
if token is not None:
|
|
||||||
otel_context_api.detach(token)
|
|
||||||
|
|
||||||
async def execute_fn(self, fn):
|
async def execute_fn(self, fn):
|
||||||
"""Run `fn(conn)` on a read connection, traced as a `db.query` span.
|
|
||||||
|
|
||||||
`execute()` calls `_execute_fn()` directly to avoid creating a second
|
|
||||||
span.
|
|
||||||
"""
|
|
||||||
self._check_not_closed()
|
|
||||||
|
|
||||||
def fn_in_execute_span(conn):
|
|
||||||
# Runs on the worker thread
|
|
||||||
with tracer.start_as_current_span(DB_QUERY_EXECUTE):
|
|
||||||
return fn(conn)
|
|
||||||
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, callback_name(fn))
|
|
||||||
with record_operation_duration(self.name, "read"):
|
|
||||||
return await self._execute_fn(fn_in_execute_span)
|
|
||||||
|
|
||||||
async def _execute_fn(self, fn):
|
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if self.ds.executor is None:
|
if self.ds.executor is None:
|
||||||
# non-threaded mode
|
# non-threaded mode
|
||||||
|
|
@ -704,11 +512,7 @@ class Database:
|
||||||
|
|
||||||
with self._pending_execute_futures_lock:
|
with self._pending_execute_futures_lock:
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
# Run in a copy of the caller's context so spans created in the
|
future = self.ds.executor.submit(in_thread)
|
||||||
# thread have the correct parent. This needs a fresh copy for
|
|
||||||
# each submit, since a Context cannot be entered concurrently.
|
|
||||||
ctx = contextvars.copy_context()
|
|
||||||
future = self.ds.executor.submit(ctx.run, in_thread)
|
|
||||||
self._pending_execute_futures.add(future)
|
self._pending_execute_futures.add(future)
|
||||||
future.add_done_callback(self._remove_pending_execute_future)
|
future.add_done_callback(self._remove_pending_execute_future)
|
||||||
return await asyncio.wrap_future(future)
|
return await asyncio.wrap_future(future)
|
||||||
|
|
@ -725,22 +529,12 @@ class Database:
|
||||||
"""Executes sql against db_name in a thread"""
|
"""Executes sql against db_name in a thread"""
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
page_size = page_size or self.ds.page_size
|
page_size = page_size or self.ds.page_size
|
||||||
time_limit_ms = self.ds.sql_time_limit_ms
|
|
||||||
# Callers that pass a shorter custom_time_limit, such as table counts
|
|
||||||
# and facet suggestions, expect timeouts, so they are not span errors
|
|
||||||
timeout_expected = bool(custom_time_limit) and custom_time_limit < time_limit_ms
|
|
||||||
if timeout_expected:
|
|
||||||
time_limit_ms = custom_time_limit
|
|
||||||
|
|
||||||
def sql_operation_in_thread(conn):
|
def sql_operation_in_thread(conn):
|
||||||
# Expected timeouts and errors with log_sql_errors=False are not
|
time_limit_ms = self.ds.sql_time_limit_ms
|
||||||
# recorded as span errors, so exceptions are handled explicitly
|
if custom_time_limit and custom_time_limit < time_limit_ms:
|
||||||
with tracer.start_as_current_span(
|
time_limit_ms = custom_time_limit
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
record_exception=False,
|
|
||||||
set_status_on_exception=False,
|
|
||||||
) as execute_span:
|
|
||||||
try:
|
|
||||||
with sqlite_timelimit(conn, time_limit_ms):
|
with sqlite_timelimit(conn, time_limit_ms):
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -764,16 +558,6 @@ class Database:
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
raise
|
raise
|
||||||
except QueryInterrupted as e:
|
|
||||||
if not timeout_expected:
|
|
||||||
execute_span.record_exception(e)
|
|
||||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
if log_sql_errors:
|
|
||||||
execute_span.record_exception(e)
|
|
||||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
raise
|
|
||||||
|
|
||||||
if truncate:
|
if truncate:
|
||||||
return Results(rows, truncated, cursor.description)
|
return Results(rows, truncated, cursor.description)
|
||||||
|
|
@ -781,45 +565,8 @@ class Database:
|
||||||
else:
|
else:
|
||||||
return Results(rows, False, cursor.description)
|
return Results(rows, False, cursor.description)
|
||||||
|
|
||||||
with trace( # noqa: SIM117
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
"sql", database=self.name, sql=sql.strip(), params=params
|
results = await self.execute_fn(sql_operation_in_thread)
|
||||||
):
|
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_QUERY,
|
|
||||||
kind=DB_QUERY.kind,
|
|
||||||
record_exception=False,
|
|
||||||
set_status_on_exception=False,
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(TIME_LIMIT_MS, time_limit_ms)
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
if params:
|
|
||||||
span.set_attribute(PARAM_COUNT, len(params))
|
|
||||||
try:
|
|
||||||
with record_operation_duration(self.name, "read"):
|
|
||||||
results = await self._execute_fn(sql_operation_in_thread)
|
|
||||||
except QueryInterrupted as e:
|
|
||||||
span.set_attribute(INTERRUPTED, True)
|
|
||||||
if not timeout_expected:
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
span.record_exception(e)
|
|
||||||
record_query_interrupted(self.name)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# log_sql_errors=False callers, such as facet suggestion,
|
|
||||||
# expect some queries to fail
|
|
||||||
if log_sql_errors:
|
|
||||||
span.record_exception(e)
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
else:
|
|
||||||
span.set_attribute(SQL_ERROR_SUPPRESSED, True)
|
|
||||||
raise
|
|
||||||
span.set_attribute(TRUNCATED, results.truncated)
|
|
||||||
span.set_attribute(ROWS_RETURNED, len(results.rows))
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -910,32 +657,17 @@ class Database:
|
||||||
)
|
)
|
||||||
return [r[0] for r in results.rows]
|
return [r[0] for r in results.rows]
|
||||||
|
|
||||||
# Named functions rather than lambdas give more useful datasette.callback
|
|
||||||
# span attributes
|
|
||||||
|
|
||||||
async def table_columns(self, table):
|
async def table_columns(self, table):
|
||||||
def _table_columns(conn):
|
return await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||||
return table_columns(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_table_columns)
|
|
||||||
|
|
||||||
async def table_column_details(self, table):
|
async def table_column_details(self, table):
|
||||||
def _table_column_details(conn):
|
return await self.execute_fn(lambda conn: table_column_details(conn, table))
|
||||||
return table_column_details(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_table_column_details)
|
|
||||||
|
|
||||||
async def primary_keys(self, table):
|
async def primary_keys(self, table):
|
||||||
def _primary_keys(conn):
|
return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))
|
||||||
return detect_primary_keys(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_primary_keys)
|
|
||||||
|
|
||||||
async def fts_table(self, table):
|
async def fts_table(self, table):
|
||||||
def _fts_table(conn):
|
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||||
return detect_fts(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_fts_table)
|
|
||||||
|
|
||||||
async def label_column_for_table(self, table):
|
async def label_column_for_table(self, table):
|
||||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||||
|
|
@ -1027,17 +759,6 @@ class Database:
|
||||||
|
|
||||||
return hidden_tables
|
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):
|
async def view_names(self):
|
||||||
results = await self.execute("select name from sqlite_master where type='view'")
|
results = await self.execute("select name from sqlite_master where type='view'")
|
||||||
return [r[0] for r in results.rows]
|
return [r[0] for r in results.rows]
|
||||||
|
|
@ -1133,28 +854,16 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
|
|
||||||
class WriteTask:
|
class WriteTask:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"block",
|
|
||||||
"enqueued_at_ns",
|
|
||||||
"fn",
|
"fn",
|
||||||
"isolated_connection",
|
"isolated_connection",
|
||||||
"loop",
|
"loop",
|
||||||
"otel_context",
|
|
||||||
"reply_future",
|
"reply_future",
|
||||||
"task_id",
|
"task_id",
|
||||||
"transaction",
|
"transaction",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||||
fn,
|
|
||||||
task_id,
|
|
||||||
loop,
|
|
||||||
reply_future,
|
|
||||||
isolated_connection,
|
|
||||||
transaction,
|
|
||||||
otel_context,
|
|
||||||
enqueued_at_ns,
|
|
||||||
block,
|
|
||||||
):
|
):
|
||||||
self.fn = fn
|
self.fn = fn
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
|
|
@ -1162,9 +871,6 @@ class WriteTask:
|
||||||
self.reply_future = reply_future
|
self.reply_future = reply_future
|
||||||
self.isolated_connection = isolated_connection
|
self.isolated_connection = isolated_connection
|
||||||
self.transaction = transaction
|
self.transaction = transaction
|
||||||
self.otel_context = otel_context
|
|
||||||
self.enqueued_at_ns = enqueued_at_ns
|
|
||||||
self.block = block
|
|
||||||
|
|
||||||
|
|
||||||
def _deliver_write_result(task, result, exception):
|
def _deliver_write_result(task, result, exception):
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,6 @@ import markupsafe
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.column_types import ColumnType, SQLiteType
|
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):
|
class UrlColumnType(ColumnType):
|
||||||
name = "url"
|
name = "url"
|
||||||
|
|
@ -26,10 +15,7 @@ class UrlColumnType(ColumnType):
|
||||||
async def render_cell(self, value, column, table, database, datasette, request):
|
async def render_cell(self, value, column, table, database, datasette, request):
|
||||||
if not value or not isinstance(value, str):
|
if not value or not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
normalized = _normalize_http_url(value)
|
escaped = markupsafe.escape(value.strip())
|
||||||
if normalized is None:
|
|
||||||
return markupsafe.escape(value.strip())
|
|
||||||
escaped = markupsafe.escape(normalized)
|
|
||||||
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
||||||
|
|
||||||
async def validate(self, value, datasette):
|
async def validate(self, value, datasette):
|
||||||
|
|
@ -37,7 +23,7 @@ class UrlColumnType(ColumnType):
|
||||||
return None
|
return None
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return "URL must be a string"
|
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 "Invalid URL"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,6 @@ class ConfigPermissionProcessor:
|
||||||
# Tables implicitly reference their parent databases
|
# Tables implicitly reference their parent databases
|
||||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
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) -> bool | None:
|
||||||
"""Evaluate an allow block against the current actor."""
|
"""Evaluate an allow block against the current actor."""
|
||||||
if allow_block is None:
|
if allow_block is None:
|
||||||
|
|
@ -132,10 +125,8 @@ class ConfigPermissionProcessor:
|
||||||
if parent:
|
if parent:
|
||||||
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
||||||
if child:
|
if child:
|
||||||
child_key = (
|
table_actions = table_restrictions.get(child, [])
|
||||||
self.action_obj.normalize_child(child) if self.action_obj else child
|
if self.action_checks.intersection(table_actions):
|
||||||
)
|
|
||||||
if (parent, child_key) in self.restricted_table_keys:
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Parent query should proceed if any child in this database is allowlisted
|
# Parent query should proceed if any child in this database is allowlisted
|
||||||
|
|
|
||||||
|
|
@ -185,12 +185,8 @@ def restrictions_allow_action(
|
||||||
# Check table/resource level
|
# Check table/resource level
|
||||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||||
database, table = resource
|
database, table = resource
|
||||||
action_obj = datasette.actions.get(action)
|
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
||||||
normalize = action_obj.normalize_child if action_obj else lambda name: name
|
if table_allowed is not None:
|
||||||
for table_name, table_allowed in (
|
|
||||||
restrictions.get("r", {}).get(database, {}).items()
|
|
||||||
):
|
|
||||||
if normalize(table_name) == normalize(table):
|
|
||||||
assert isinstance(table_allowed, list)
|
assert isinstance(table_allowed, list)
|
||||||
if to_check.intersection(table_allowed):
|
if to_check.intersection(table_allowed):
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -39,7 +39,7 @@ def load_facet_configs(request, table_config):
|
||||||
)
|
)
|
||||||
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
||||||
for key, values in qs_pairs.items():
|
for key, values in qs_pairs.items():
|
||||||
if key == "_facet" or key.startswith("_facet_"):
|
if key.startswith("_facet"):
|
||||||
# Figure out the facet type
|
# Figure out the facet type
|
||||||
if key == "_facet":
|
if key == "_facet":
|
||||||
type = "column"
|
type = "column"
|
||||||
|
|
@ -264,15 +264,10 @@ class ColumnFacet(Facet):
|
||||||
column_qs = column
|
column_qs = column
|
||||||
if column.startswith("_"):
|
if column.startswith("_"):
|
||||||
column_qs = f"{column}__exact"
|
column_qs = f"{column}__exact"
|
||||||
selected_args = {
|
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||||
key: str(row["value"])
|
|
||||||
for key in (column_qs, f"{column}__exact")
|
|
||||||
if (key, str(row["value"])) in qs_pairs
|
|
||||||
}
|
|
||||||
selected = bool(selected_args)
|
|
||||||
if selected:
|
if selected:
|
||||||
toggle_path = path_with_removed_args(
|
toggle_path = path_with_removed_args(
|
||||||
self.request, selected_args
|
self.request, {column_qs: str(row["value"])}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
toggle_path = path_with_added_args(
|
toggle_path = path_with_added_args(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils.asgi import BadRequest
|
from datasette.utils.asgi import BadRequest
|
||||||
from datasette.views.base import DatasetteError
|
from datasette.views.base import DatasetteError
|
||||||
|
|
||||||
|
|
@ -52,20 +51,13 @@ def search_filters(request, database, table, datasette):
|
||||||
human_descriptions = []
|
human_descriptions = []
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
|
|
||||||
# Figure out which trusted fts_table to use. Query string parameters can
|
# Figure out which fts_table to use
|
||||||
# repeat this mapping (for backwards compatibility), but must not select
|
|
||||||
# a different table or primary key.
|
|
||||||
table_metadata = await datasette.table_config(database, table)
|
table_metadata = await datasette.table_config(database, table)
|
||||||
db = datasette.get_database(database)
|
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_table = fts_table or await db.fts_table(table)
|
||||||
fts_pk = table_metadata.get("fts_pk", "rowid")
|
fts_pk = request.args.get("_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")
|
|
||||||
search_args = {
|
search_args = {
|
||||||
key: request.args[key]
|
key: request.args[key]
|
||||||
for key in request.args
|
for key in request.args
|
||||||
|
|
@ -83,11 +75,6 @@ def search_filters(request, database, table, datasette):
|
||||||
extra_context["supports_search"] = bool(fts_table)
|
extra_context["supports_search"] = bool(fts_table)
|
||||||
|
|
||||||
if fts_table and search_args:
|
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:
|
if "_search" in search_args:
|
||||||
# Simple ?_search=xxx
|
# Simple ?_search=xxx
|
||||||
search = search_args["_search"]
|
search = search_args["_search"]
|
||||||
|
|
@ -148,11 +135,6 @@ def through_filters(request, database, table, datasette):
|
||||||
through_table = through_data["table"]
|
through_table = through_data["table"]
|
||||||
other_column = through_data["column"]
|
other_column = through_data["column"]
|
||||||
value = through_data["value"]
|
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)
|
db = datasette.get_database(database)
|
||||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||||
fk_to_us = next(
|
fk_to_us = next(
|
||||||
|
|
@ -203,17 +185,6 @@ class Filter:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def _coerce_numeric_filter_value(value):
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
converted = float(value)
|
|
||||||
except ValueError:
|
|
||||||
return value
|
|
||||||
return converted if math.isfinite(converted) else value
|
|
||||||
|
|
||||||
|
|
||||||
class TemplatedFilter(Filter):
|
class TemplatedFilter(Filter):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -235,8 +206,8 @@ class TemplatedFilter(Filter):
|
||||||
|
|
||||||
def where_clause(self, table, column, value, param_counter):
|
def where_clause(self, table, column, value, param_counter):
|
||||||
converted = self.format.format(value)
|
converted = self.format.format(value)
|
||||||
if self.numeric:
|
if self.numeric and converted.isdigit():
|
||||||
converted = _coerce_numeric_filter_value(converted)
|
converted = int(converted)
|
||||||
if self.no_argument:
|
if self.no_argument:
|
||||||
kwargs = {"c": _quote_sqlite_identifier(column)}
|
kwargs = {"c": _quote_sqlite_identifier(column)}
|
||||||
converted = None
|
converted = None
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,6 @@ def handle_exception(datasette, request, exception):
|
||||||
body = dict(info)
|
body = dict(info)
|
||||||
body.update(error_body(plain_message or message, status))
|
body.update(error_body(plain_message or message, status))
|
||||||
return Response.json(body, status=status, headers=headers)
|
return Response.json(body, status=status, headers=headers)
|
||||||
if request.path.split("?")[0].endswith(".csv"):
|
|
||||||
return Response.text(
|
|
||||||
plain_message or message, status=status, headers=headers
|
|
||||||
)
|
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,6 @@ def startup(datasette):
|
||||||
"""Fires directly after Datasette first starts running"""
|
"""Fires directly after Datasette first starts running"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
|
||||||
def shutdown(datasette):
|
|
||||||
"""Called once when the Datasette server is shutting down"""
|
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def asgi_wrapper(datasette):
|
def asgi_wrapper(datasette):
|
||||||
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
||||||
|
|
@ -50,7 +45,7 @@ def extra_body_script(
|
||||||
def extra_template_vars(
|
def extra_template_vars(
|
||||||
template, database, table, columns, view_name, request, datasette
|
template, database, table, columns, view_name, request, datasette
|
||||||
):
|
):
|
||||||
"""Extra template variables to be made available to the template - can return dict, None, callable or awaitable"""
|
"""Extra template variables to be made available to the template - can return dict or callable or awaitable"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@ from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
_SQLITE_IDENTIFIER_CASE = str.maketrans(
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Context variable to track when permission checks should be skipped
|
# Context variable to track when permission checks should be skipped
|
||||||
_skip_permission_checks = contextvars.ContextVar(
|
_skip_permission_checks = contextvars.ContextVar(
|
||||||
"skip_permission_checks", default=False
|
"skip_permission_checks", default=False
|
||||||
|
|
@ -53,15 +49,6 @@ class Resource(ABC):
|
||||||
# Class-level metadata (subclasses must define these)
|
# Class-level metadata (subclasses must define these)
|
||||||
name: str = None # e.g., "table", "database", "model"
|
name: str = None # e.g., "table", "database", "model"
|
||||||
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
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
|
# Instance-level optional extra attributes
|
||||||
reasons: list[str] | None = None
|
reasons: list[str] | None = None
|
||||||
|
|
@ -159,11 +146,6 @@ class Action:
|
||||||
resource_class: type[Resource] | None = None
|
resource_class: type[Resource] | None = None
|
||||||
also_requires: str | None = None # Optional action name that must also be allowed
|
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
|
@property
|
||||||
def takes_parent(self) -> bool:
|
def takes_parent(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.actor_auth_cookie",
|
"datasette.actor_auth_cookie",
|
||||||
"datasette.default_permissions",
|
"datasette.default_permissions",
|
||||||
"datasette.default_permissions.tokens",
|
"datasette.default_permissions.tokens",
|
||||||
"datasette.default_permissions.sqlite_statistics",
|
|
||||||
"datasette.default_actions",
|
"datasette.default_actions",
|
||||||
"datasette.default_column_types",
|
"datasette.default_column_types",
|
||||||
"datasette.default_magic_parameters",
|
"datasette.default_magic_parameters",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ class TableResource(Resource):
|
||||||
|
|
||||||
name = "table"
|
name = "table"
|
||||||
parent_class = DatabaseResource
|
parent_class = DatabaseResource
|
||||||
case_insensitive_child = True
|
|
||||||
|
|
||||||
def __init__(self, database: str, table: str):
|
def __init__(self, database: str, table: str):
|
||||||
super().__init__(parent=database, child=table)
|
super().__init__(parent=database, child=table)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,7 @@
|
||||||
let columnChooserInstanceCounter = 0;
|
|
||||||
|
|
||||||
class ColumnChooser extends HTMLElement {
|
class ColumnChooser extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`;
|
this.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
// State
|
// State
|
||||||
this._items = [];
|
this._items = [];
|
||||||
|
|
@ -28,60 +26,375 @@ class ColumnChooser extends HTMLElement {
|
||||||
// Bound handlers
|
// Bound handlers
|
||||||
this._onMove = this._onMove.bind(this);
|
this._onMove = this._onMove.bind(this);
|
||||||
this._onUp = this._onUp.bind(this);
|
this._onUp = this._onUp.bind(this);
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
--ink: #0f0f0f;
|
||||||
|
--paper: #eef6ff;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--rule: #d8e6f5;
|
||||||
|
--accent: #1a56db;
|
||||||
|
--accent-light: #e8effd;
|
||||||
|
--card: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
if (this._modal) return;
|
|
||||||
this.innerHTML = `
|
dialog {
|
||||||
<datasette-modal><dialog aria-labelledby="${this.titleId}">
|
border: none;
|
||||||
|
border-radius: var(--modal-border-radius, 0.75rem);
|
||||||
|
padding: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: min(640px, calc(100vh - 32px));
|
||||||
|
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||||
|
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--card);
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog[open] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: min(640px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||||
|
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-meta {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--paper);
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
padding: 6px 24px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar button {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 10px;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.list-toolbar button:hover { background: var(--accent); color: white; }
|
||||||
|
|
||||||
|
.list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-wrap::before,
|
||||||
|
.list-wrap::after {
|
||||||
|
content: '';
|
||||||
|
position: sticky;
|
||||||
|
display: block;
|
||||||
|
left: 0; right: 0;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.list-wrap::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
|
||||||
|
}
|
||||||
|
.list-wrap::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-zone {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0;
|
||||||
|
height: 72px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.scroll-zone-top { top: 0; }
|
||||||
|
.scroll-zone-bot { bottom: 0; }
|
||||||
|
|
||||||
|
.drag-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
|
color: #c8c4bc;
|
||||||
|
touch-action: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover { color: var(--accent); }
|
||||||
|
.drag-handle svg { pointer-events: none; display: block; }
|
||||||
|
|
||||||
|
.drag-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-check input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 48px;
|
||||||
|
padding-right: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item.is-dragging {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 99px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.drop-indicator.top { top: -1px; display: block; }
|
||||||
|
.drop-indicator.bottom { bottom: -1px; display: block; }
|
||||||
|
|
||||||
|
.drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1.5px solid var(--accent-light);
|
||||||
|
opacity: 0.97;
|
||||||
|
will-change: transform;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-pulse {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.scroll-pulse.top { top: 8px; }
|
||||||
|
.scroll-pulse.bot { bottom: 8px; }
|
||||||
|
.scroll-pulse.active {
|
||||||
|
opacity: 0.18;
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
|
||||||
|
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
flex: 1;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #1448c0; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
|
||||||
|
|
||||||
|
.list-wrap::-webkit-scrollbar { width: 5px; }
|
||||||
|
.list-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
|
||||||
|
|
||||||
|
input, textarea { -webkit-user-select: auto; user-select: auto; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<dialog aria-labelledby="modalTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title" id="${this.titleId}">Choose columns</span>
|
<span class="modal-title" id="modalTitle">Choose columns</span>
|
||||||
<span class="modal-meta"></span>
|
<span class="modal-meta" id="selectedCount"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-toolbar">
|
<div class="list-toolbar">
|
||||||
<button class="select-all">Select all</button>
|
<button id="selectAllBtn">Select all</button>
|
||||||
<button class="deselect-all">Deselect all</button>
|
<button id="deselectAllBtn">Deselect all</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body list-wrap">
|
<div class="list-wrap" id="listWrap">
|
||||||
<div class="scroll-pulse top"></div>
|
<div class="scroll-pulse top" id="pulseTop"></div>
|
||||||
<div class="scroll-pulse bot"></div>
|
<div class="scroll-pulse bot" id="pulseBot"></div>
|
||||||
<ul class="drag-list"></ul>
|
<ul class="drag-list" id="dragList"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info"></span>
|
<span class="footer-info" id="footerInfo"></span>
|
||||||
<button class="modal-btn modal-btn-ghost">Cancel</button>
|
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
|
||||||
<button class="modal-btn modal-btn-primary">Apply</button>
|
<button class="btn btn-primary" id="applyBtn">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</dialog></datasette-modal>
|
</dialog>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
this._modal = this.querySelector("datasette-modal");
|
this._dialog = this.shadowRoot.querySelector("dialog");
|
||||||
this._listWrap = this.querySelector(".list-wrap");
|
this._listWrap = this.shadowRoot.getElementById("listWrap");
|
||||||
this._dragList = this.querySelector(".drag-list");
|
this._dragList = this.shadowRoot.getElementById("dragList");
|
||||||
this._pulseTop = this.querySelector(".scroll-pulse.top");
|
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
|
||||||
this._pulseBot = this.querySelector(".scroll-pulse.bot");
|
this._pulseBot = this.shadowRoot.getElementById("pulseBot");
|
||||||
this._selectAllBtn = this.querySelector(".select-all");
|
this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn");
|
||||||
this._deselectAllBtn = this.querySelector(".deselect-all");
|
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
|
||||||
this._cancelBtn = this.querySelector(".modal-btn-ghost");
|
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
|
||||||
this._applyBtn = this.querySelector(".modal-btn-primary");
|
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
|
||||||
this._countEl = this.querySelector(".modal-meta");
|
this._countEl = this.shadowRoot.getElementById("selectedCount");
|
||||||
this._footerEl = this.querySelector(".footer-info");
|
this._footerEl = this.shadowRoot.getElementById("footerInfo");
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
this._selectAllBtn.addEventListener("click", () => this._selectAll());
|
this._selectAllBtn.addEventListener("click", () => this._selectAll());
|
||||||
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
|
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
|
||||||
this._cancelBtn.addEventListener("click", () =>
|
this._cancelBtn.addEventListener("click", () => this._close());
|
||||||
this._modal.requestClose("cancel"),
|
|
||||||
);
|
|
||||||
this._applyBtn.addEventListener("click", () => this._apply());
|
this._applyBtn.addEventListener("click", () => this._apply());
|
||||||
this._modal.beforeClose = () => {
|
this._dialog.addEventListener("click", (e) => {
|
||||||
this._items = this._savedItems ? [...this._savedItems] : this._items;
|
if (e.target === this._dialog) this._close();
|
||||||
this._checked = this._savedChecked
|
});
|
||||||
? new Set(this._savedChecked)
|
this._dialog.addEventListener("cancel", (e) => {
|
||||||
: this._checked;
|
e.preventDefault();
|
||||||
return true;
|
this._close();
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,11 +414,19 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._savedChecked = new Set(this._checked);
|
this._savedChecked = new Set(this._checked);
|
||||||
|
|
||||||
this._render();
|
this._render();
|
||||||
this._modal.show();
|
this._dialog.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal methods ──
|
// ── Internal methods ──
|
||||||
|
|
||||||
|
_close() {
|
||||||
|
this._items = this._savedItems ? [...this._savedItems] : this._items;
|
||||||
|
this._checked = this._savedChecked
|
||||||
|
? new Set(this._savedChecked)
|
||||||
|
: this._checked;
|
||||||
|
this._dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
_selectAll() {
|
_selectAll() {
|
||||||
this._items.forEach((col) => this._checked.add(col));
|
this._items.forEach((col) => this._checked.add(col));
|
||||||
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||||
|
|
@ -124,7 +445,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
|
|
||||||
_apply() {
|
_apply() {
|
||||||
const selected = this._items.filter((col) => this._checked.has(col));
|
const selected = this._items.filter((col) => this._checked.has(col));
|
||||||
this._modal.close();
|
this._dialog.close();
|
||||||
if (this._onApply) {
|
if (this._onApply) {
|
||||||
this._onApply(selected);
|
this._onApply(selected);
|
||||||
}
|
}
|
||||||
|
|
@ -151,13 +472,11 @@ class ColumnChooser extends HTMLElement {
|
||||||
<span class="drag-item-check">
|
<span class="drag-item-check">
|
||||||
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
||||||
</span>
|
</span>
|
||||||
<span class="drag-item-label"></span>
|
<span class="drag-item-label">${col}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-indicator"></div>
|
<div class="drop-indicator"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
li.querySelector(".drag-item-label").textContent = col;
|
|
||||||
|
|
||||||
li.querySelector("input").addEventListener("change", (e) => {
|
li.querySelector("input").addEventListener("change", (e) => {
|
||||||
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
|
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
|
||||||
this._updateCounts();
|
this._updateCounts();
|
||||||
|
|
@ -190,7 +509,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._ghostOffX = e.clientX - rect.left;
|
this._ghostOffX = e.clientX - rect.left;
|
||||||
this._ghostOffY = e.clientY - rect.top;
|
this._ghostOffY = e.clientY - rect.top;
|
||||||
|
|
||||||
// Keep the drag preview inside the dialog so it stays above the backdrop.
|
// Build ghost inside shadow DOM
|
||||||
this._ghost = document.createElement("div");
|
this._ghost = document.createElement("div");
|
||||||
this._ghost.className = "drag-ghost";
|
this._ghost.className = "drag-ghost";
|
||||||
this._ghost.style.width = rect.width + "px";
|
this._ghost.style.width = rect.width + "px";
|
||||||
|
|
@ -199,7 +518,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._ghost.querySelector(".drop-indicator")?.remove();
|
this._ghost.querySelector(".drop-indicator")?.remove();
|
||||||
const h = this._ghost.querySelector(".drag-handle");
|
const h = this._ghost.querySelector(".drag-handle");
|
||||||
if (h) h.style.color = "var(--accent)";
|
if (h) h.style.color = "var(--accent)";
|
||||||
this._modal.dialog.appendChild(this._ghost);
|
this.shadowRoot.appendChild(this._ghost);
|
||||||
|
|
||||||
srcEl.classList.add("is-dragging");
|
srcEl.classList.add("is-dragging");
|
||||||
this._positionGhost(e.clientX, e.clientY);
|
this._positionGhost(e.clientX, e.clientY);
|
||||||
|
|
|
||||||
|
|
@ -915,7 +915,6 @@ function showTableCreateDialogError(state, message) {
|
||||||
|
|
||||||
function setTableCreateDialogSaving(state, isSaving) {
|
function setTableCreateDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
state.columnList
|
state.columnList
|
||||||
.querySelectorAll("input, select, button")
|
.querySelectorAll("input, select, button")
|
||||||
.forEach(function (control) {
|
.forEach(function (control) {
|
||||||
|
|
@ -2044,7 +2043,8 @@ async function createTableFromDataPreview(state) {
|
||||||
var tableUrl =
|
var tableUrl =
|
||||||
responseData.table_url ||
|
responseData.table_url ||
|
||||||
fallbackTableUrl(responseData.table || payload.table);
|
fallbackTableUrl(responseData.table || payload.table);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableUrl) {
|
if (tableUrl) {
|
||||||
location.href = tableUrl;
|
location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2118,7 +2118,8 @@ async function saveTableCreateDialog(state) {
|
||||||
var tableUrl =
|
var tableUrl =
|
||||||
responseData.table_url ||
|
responseData.table_url ||
|
||||||
fallbackTableUrl(responseData.table || payload.table);
|
fallbackTableUrl(responseData.table || payload.table);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableUrl) {
|
if (tableUrl) {
|
||||||
location.href = tableUrl;
|
location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2140,6 +2141,18 @@ function confirmDiscardTableCreateChanges(state) {
|
||||||
return window.confirm("Discard this new table?");
|
return window.confirm("Discard this new table?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTableCreateDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardTableCreateChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTableCreateDialog(manager) {
|
function ensureTableCreateDialog(manager) {
|
||||||
if (tableCreateDialogState) {
|
if (tableCreateDialogState) {
|
||||||
return tableCreateDialogState;
|
return tableCreateDialogState;
|
||||||
|
|
@ -2148,8 +2161,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = TABLE_CREATE_DIALOG_ID;
|
dialog.id = TABLE_CREATE_DIALOG_ID;
|
||||||
dialog.className = "table-create-dialog";
|
dialog.className = "table-create-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "table-create-title");
|
dialog.setAttribute("aria-labelledby", "table-create-title");
|
||||||
|
|
@ -2159,7 +2171,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
<form class="table-create-form" method="post" novalidate>
|
<form class="table-create-form" method="post" novalidate>
|
||||||
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
|
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body table-create-fields">
|
<div class="table-create-fields">
|
||||||
<div class="table-create-field">
|
<div class="table-create-field">
|
||||||
<label class="table-create-label" for="table-create-name">Table name</label>
|
<label class="table-create-label" for="table-create-name">Table name</label>
|
||||||
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
|
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
|
||||||
|
|
@ -2186,15 +2198,14 @@ function ensureTableCreateDialog(manager) {
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<a href="#" class="table-create-mode-link table-create-from-data">Create table from data</a>
|
<a href="#" class="table-create-mode-link table-create-from-data">Create table from data</a>
|
||||||
<a href="#" class="table-create-mode-link table-create-manual" hidden>Create table manually</a>
|
<a href="#" class="table-create-mode-link table-create-manual" hidden>Create table manually</a>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-create-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary table-create-save">Create table</button>
|
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
tableCreateDialogState = {
|
tableCreateDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".table-create-form"),
|
form: dialog.querySelector(".table-create-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -2214,6 +2225,8 @@ function ensureTableCreateDialog(manager) {
|
||||||
manualCreateLink: dialog.querySelector(".table-create-manual"),
|
manualCreateLink: dialog.querySelector(".table-create-manual"),
|
||||||
cancelButton: dialog.querySelector(".table-create-cancel"),
|
cancelButton: dialog.querySelector(".table-create-cancel"),
|
||||||
saveButton: dialog.querySelector(".table-create-save"),
|
saveButton: dialog.querySelector(".table-create-save"),
|
||||||
|
currentButton: null,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
dataPreviewRows: null,
|
dataPreviewRows: null,
|
||||||
|
|
@ -2253,7 +2266,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
tableCreateDialogState.dataTextarea.focus();
|
tableCreateDialogState.dataTextarea.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.requestClose("cancel");
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
tableCreateDialogState.createFromDataLink.addEventListener(
|
tableCreateDialogState.createFromDataLink.addEventListener(
|
||||||
|
|
@ -2351,14 +2364,36 @@ function ensureTableCreateDialog(manager) {
|
||||||
updateTableCreateDialogButtons(tableCreateDialogState);
|
updateTableCreateDialogButtons(tableCreateDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return confirmDiscardTableCreateChanges(tableCreateDialogState);
|
if (ev.target === dialog) {
|
||||||
};
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = tableCreateDialogState;
|
var state = tableCreateDialogState;
|
||||||
clearTableCreateDialogError(state);
|
clearTableCreateDialogError(state);
|
||||||
setTableCreateDialogSaving(state, false);
|
setTableCreateDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tableCreateDialogState;
|
return tableCreateDialogState;
|
||||||
|
|
@ -2379,12 +2414,15 @@ function openTableCreateDialog(button, manager) {
|
||||||
menu.open = false;
|
menu.open = false;
|
||||||
}
|
}
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.title.textContent = "Create a table in " + data.databaseName;
|
state.title.textContent = "Create a table in " + data.databaseName;
|
||||||
clearTableCreateDialogError(state);
|
clearTableCreateDialogError(state);
|
||||||
resetTableCreateDialog(state);
|
resetTableCreateDialog(state);
|
||||||
loadTableCreateForeignKeyTargets(state);
|
loadTableCreateForeignKeyTargets(state);
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.tableName.focus();
|
state.tableName.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2410,7 +2448,6 @@ function initTableCreateActions(manager) {
|
||||||
|
|
||||||
function setRowDeleteDialogBusy(state, isBusy) {
|
function setRowDeleteDialogBusy(state, isBusy) {
|
||||||
state.isBusy = isBusy;
|
state.isBusy = isBusy;
|
||||||
state.modal.busy = isBusy;
|
|
||||||
state.confirmButton.disabled = isBusy;
|
state.confirmButton.disabled = isBusy;
|
||||||
state.cancelButton.disabled = isBusy;
|
state.cancelButton.disabled = isBusy;
|
||||||
state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row";
|
state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row";
|
||||||
|
|
@ -2657,7 +2694,6 @@ function showTableAlterDialogError(state, message) {
|
||||||
|
|
||||||
function setTableAlterDialogSaving(state, isSaving) {
|
function setTableAlterDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
state.cancelButton.disabled = isSaving;
|
state.cancelButton.disabled = isSaving;
|
||||||
state.addColumnButton.disabled = isSaving;
|
state.addColumnButton.disabled = isSaving;
|
||||||
state.backButton.disabled = isSaving;
|
state.backButton.disabled = isSaving;
|
||||||
|
|
@ -3793,7 +3829,8 @@ async function applyTableAlterChanges(state, result) {
|
||||||
result.columnTypeAssignments || [],
|
result.columnTypeAssignments || [],
|
||||||
tableUrl,
|
tableUrl,
|
||||||
);
|
);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableAlterResultRenamesTable(result) && tableUrl) {
|
if (tableAlterResultRenamesTable(result) && tableUrl) {
|
||||||
window.location.href = tableUrl;
|
window.location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3854,7 +3891,8 @@ async function dropTableFromAlterDialog(state) {
|
||||||
if (!response.ok || (responseData && responseData.ok === false)) {
|
if (!response.ok || (responseData && responseData.ok === false)) {
|
||||||
throw rowMutationRequestError(response, responseData);
|
throw rowMutationRequestError(response, responseData);
|
||||||
}
|
}
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
window.location.href = tableAlterDatabaseUrl() || "/";
|
window.location.href = tableAlterDatabaseUrl() || "/";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTableAlterDialogSaving(state, false);
|
setTableAlterDialogSaving(state, false);
|
||||||
|
|
@ -3890,6 +3928,27 @@ function confirmDiscardTableAlterChanges(state) {
|
||||||
return window.confirm("Discard table changes?");
|
return window.confirm("Discard table changes?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTableAlterDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardTableAlterChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTableAlterDialog(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTableAlterDialog(manager) {
|
function ensureTableAlterDialog(manager) {
|
||||||
if (tableAlterDialogState) {
|
if (tableAlterDialogState) {
|
||||||
return tableAlterDialogState;
|
return tableAlterDialogState;
|
||||||
|
|
@ -3898,8 +3957,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = TABLE_ALTER_DIALOG_ID;
|
dialog.id = TABLE_ALTER_DIALOG_ID;
|
||||||
dialog.className = "table-alter-dialog";
|
dialog.className = "table-alter-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "table-alter-title");
|
dialog.setAttribute("aria-labelledby", "table-alter-title");
|
||||||
|
|
@ -3909,7 +3967,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
<form class="table-alter-form" method="post" novalidate>
|
<form class="table-alter-form" method="post" novalidate>
|
||||||
<p class="table-alter-error" id="table-alter-error" role="alert" tabindex="-1" hidden></p>
|
<p class="table-alter-error" id="table-alter-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body table-alter-fields">
|
<div class="table-alter-fields">
|
||||||
<div class="table-alter-columns">
|
<div class="table-alter-columns">
|
||||||
<div class="table-alter-column-headings" aria-hidden="true">
|
<div class="table-alter-column-headings" aria-hidden="true">
|
||||||
<span>Column</span>
|
<span>Column</span>
|
||||||
|
|
@ -3928,19 +3986,18 @@ function ensureTableAlterDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body table-alter-review" hidden></div>
|
<div class="table-alter-review" hidden></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-btn modal-btn-danger table-alter-drop" hidden>Drop table</button>
|
<button type="button" class="btn btn-danger table-alter-drop" hidden>Drop table</button>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-alter-back" hidden>Back</button>
|
<button type="button" class="btn btn-ghost table-alter-back" hidden>Back</button>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-alter-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost table-alter-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary table-alter-save">Review changes</button>
|
<button type="submit" class="btn btn-primary table-alter-save">Review changes</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
tableAlterDialogState = {
|
tableAlterDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".table-alter-form"),
|
form: dialog.querySelector(".table-alter-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -3955,6 +4012,8 @@ function ensureTableAlterDialog(manager) {
|
||||||
dropButton: dialog.querySelector(".table-alter-drop"),
|
dropButton: dialog.querySelector(".table-alter-drop"),
|
||||||
cancelButton: dialog.querySelector(".table-alter-cancel"),
|
cancelButton: dialog.querySelector(".table-alter-cancel"),
|
||||||
saveButton: dialog.querySelector(".table-alter-save"),
|
saveButton: dialog.querySelector(".table-alter-save"),
|
||||||
|
currentButton: null,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
initialSignature: "",
|
initialSignature: "",
|
||||||
originalTableName: "",
|
originalTableName: "",
|
||||||
|
|
@ -3996,7 +4055,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
});
|
});
|
||||||
|
|
||||||
tableAlterDialogState.cancelButton.addEventListener("click", function () {
|
tableAlterDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
closeTableAlterDialog(tableAlterDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
tableAlterDialogState.dropButton.addEventListener("click", function () {
|
tableAlterDialogState.dropButton.addEventListener("click", function () {
|
||||||
|
|
@ -4017,17 +4076,36 @@ function ensureTableAlterDialog(manager) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return (
|
if (ev.target === dialog) {
|
||||||
source === "cancel" ||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
confirmDiscardTableAlterChanges(tableAlterDialogState)
|
}
|
||||||
);
|
});
|
||||||
};
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = tableAlterDialogState;
|
var state = tableAlterDialogState;
|
||||||
clearTableAlterDialogError(state);
|
clearTableAlterDialogError(state);
|
||||||
setTableAlterDialogSaving(state, false);
|
setTableAlterDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tableAlterDialogState;
|
return tableAlterDialogState;
|
||||||
|
|
@ -4048,7 +4126,8 @@ function openTableAlterDialog(button, manager) {
|
||||||
menu.open = false;
|
menu.open = false;
|
||||||
}
|
}
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.title.textContent = "Alter table " + data.tableName;
|
state.title.textContent = "Alter table " + data.tableName;
|
||||||
clearTableAlterDialogError(state);
|
clearTableAlterDialogError(state);
|
||||||
resetTableAlterDialog(state, data);
|
resetTableAlterDialog(state, data);
|
||||||
|
|
@ -4058,7 +4137,9 @@ function openTableAlterDialog(button, manager) {
|
||||||
tableAlterForeignKeyTargetsUrl(),
|
tableAlterForeignKeyTargetsUrl(),
|
||||||
{ filterByType: false },
|
{ filterByType: false },
|
||||||
);
|
);
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
var firstName = state.columnList.querySelector(".table-alter-column-name");
|
var firstName = state.columnList.querySelector(".table-alter-column-name");
|
||||||
if (firstName) {
|
if (firstName) {
|
||||||
firstName.focus();
|
firstName.focus();
|
||||||
|
|
@ -4361,8 +4442,7 @@ function ensureRowDeleteDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = ROW_DELETE_DIALOG_ID;
|
dialog.id = ROW_DELETE_DIALOG_ID;
|
||||||
dialog.className = "row-delete-dialog";
|
dialog.className = "row-delete-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "row-delete-title");
|
dialog.setAttribute("aria-labelledby", "row-delete-title");
|
||||||
|
|
@ -4374,14 +4454,13 @@ function ensureRowDeleteDialog(manager) {
|
||||||
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
|
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
|
||||||
<p class="row-delete-error" role="alert" hidden></p>
|
<p class="row-delete-error" role="alert" hidden></p>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-delete-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost row-delete-cancel">Cancel</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary row-delete-confirm">Delete row</button>
|
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
rowDeleteDialogState = {
|
rowDeleteDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
message: dialog.querySelector(".row-delete-message"),
|
message: dialog.querySelector(".row-delete-message"),
|
||||||
|
|
@ -4394,10 +4473,21 @@ function ensureRowDeleteDialog(manager) {
|
||||||
currentPkPath: null,
|
currentPkPath: null,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
isBusy: false,
|
isBusy: false,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowDeleteDialogState.cancelButton.addEventListener("click", function () {
|
rowDeleteDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
if (!rowDeleteDialogState.isBusy) {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog && !rowDeleteDialogState.isBusy) {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.addEventListener("keydown", function (ev) {
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
|
@ -4409,6 +4499,25 @@ function ensureRowDeleteDialog(manager) {
|
||||||
if (!rowDeleteDialogState.isBusy) {
|
if (!rowDeleteDialogState.isBusy) {
|
||||||
rowDeleteDialogState.confirmButton.click();
|
rowDeleteDialogState.confirmButton.click();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rowDeleteDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
if (rowDeleteDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
} else {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4416,6 +4525,13 @@ function ensureRowDeleteDialog(manager) {
|
||||||
var state = rowDeleteDialogState;
|
var state = rowDeleteDialogState;
|
||||||
clearRowDeleteDialogError(state);
|
clearRowDeleteDialogError(state);
|
||||||
setRowDeleteDialogBusy(state, false);
|
setRowDeleteDialogBusy(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rowDeleteDialogState.confirmButton.addEventListener(
|
rowDeleteDialogState.confirmButton.addEventListener(
|
||||||
|
|
@ -4442,7 +4558,8 @@ function ensureRowDeleteDialog(manager) {
|
||||||
throw rowMutationRequestError(response, data);
|
throw rowMutationRequestError(response, data);
|
||||||
}
|
}
|
||||||
if (data && data.redirect) {
|
if (data && data.redirect) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
location.href = data.redirect;
|
location.href = data.redirect;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4454,7 +4571,8 @@ function ensureRowDeleteDialog(manager) {
|
||||||
var statusMessage = state.currentPkPath
|
var statusMessage = state.currentPkPath
|
||||||
? "Deleted row " + state.currentPkPath + "."
|
? "Deleted row " + state.currentPkPath + "."
|
||||||
: "Deleted row.";
|
: "Deleted row.";
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
state.currentRow.remove();
|
state.currentRow.remove();
|
||||||
showRowMutationStatus(state.manager, statusMessage, false);
|
showRowMutationStatus(state.manager, statusMessage, false);
|
||||||
if (focusTarget && document.contains(focusTarget)) {
|
if (focusTarget && document.contains(focusTarget)) {
|
||||||
|
|
@ -4483,9 +4601,11 @@ function openRowDeleteDialog(button, manager) {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = row;
|
state.currentRow = row;
|
||||||
state.currentDeleteUrl = rowDeleteUrl(row);
|
state.currentDeleteUrl = rowDeleteUrl(row);
|
||||||
state.currentPkPath = rowDisplayLabel(row);
|
state.currentPkPath = rowDisplayLabel(row);
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
|
||||||
clearRowDeleteDialogError(state);
|
clearRowDeleteDialogError(state);
|
||||||
setRowDeleteDialogBusy(state, false);
|
setRowDeleteDialogBusy(state, false);
|
||||||
|
|
@ -4497,7 +4617,9 @@ function openRowDeleteDialog(button, manager) {
|
||||||
);
|
);
|
||||||
state.rowId.textContent = state.currentPkPath || "this row";
|
state.rowId.textContent = state.currentPkPath || "this row";
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.confirmButton.focus();
|
state.confirmButton.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5572,7 +5694,6 @@ function setRowEditDialogLoading(state, isLoading) {
|
||||||
|
|
||||||
function setRowEditDialogSaving(state, isSaving) {
|
function setRowEditDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
updateRowEditDialogButtons(state);
|
updateRowEditDialogButtons(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5790,6 +5911,18 @@ function confirmDiscardRowEditChanges(state) {
|
||||||
return window.confirm(message);
|
return window.confirm(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeRowEditDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardRowEditChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function setRowInsertDialogTitle(state) {
|
function setRowInsertDialogTitle(state) {
|
||||||
var insertData = tableInsertData() || {};
|
var insertData = tableInsertData() || {};
|
||||||
var title = rowEditIsMultipleInsert(state)
|
var title = rowEditIsMultipleInsert(state)
|
||||||
|
|
@ -6615,6 +6748,38 @@ async function insertBulkPreviewRows(state) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleCloseRowEditDialogIfConfirmed(state) {
|
||||||
|
// Fix for an issue in Safari where hitting Esc would show
|
||||||
|
// the confirm() prompt asking if state should be discarded
|
||||||
|
// but the Esc key press would then cancel that dialog too.
|
||||||
|
// Wait for keyup, then move the confirm() to a fresh timer tick.
|
||||||
|
if (!state || state.isSaving || state.isClosePending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!rowEditDialogHasChanges(state)) {
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state.isClosePending = true;
|
||||||
|
var closeAfterKeyup = function () {
|
||||||
|
if (!state.isClosePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isClosePending = false;
|
||||||
|
closeRowEditDialogIfConfirmed(state);
|
||||||
|
};
|
||||||
|
var onKeyup = function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.removeEventListener("keyup", onKeyup, true);
|
||||||
|
setTimeout(closeAfterKeyup, 0);
|
||||||
|
};
|
||||||
|
document.addEventListener("keyup", onKeyup, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function findDataRowElement(root, rowId) {
|
function findDataRowElement(root, rowId) {
|
||||||
var elements = root.querySelectorAll("[data-row]");
|
var elements = root.querySelectorAll("[data-row]");
|
||||||
for (var i = 0; i < elements.length; i += 1) {
|
for (var i = 0; i < elements.length; i += 1) {
|
||||||
|
|
@ -6704,8 +6869,9 @@ async function saveRowEditDialog(state) {
|
||||||
}
|
}
|
||||||
var formValues = collectRowFormValues(state);
|
var formValues = collectRowFormValues(state);
|
||||||
if (state.mode === "edit" && !Object.keys(formValues).length) {
|
if (state.mode === "edit" && !Object.keys(formValues).length) {
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
hideRowMutationStatus();
|
hideRowMutationStatus();
|
||||||
state.modal.close();
|
state.dialog.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var payload =
|
var payload =
|
||||||
|
|
@ -6738,8 +6904,9 @@ async function saveRowEditDialog(state) {
|
||||||
insertedRowData,
|
insertedRowData,
|
||||||
insertData.primaryKeys || [],
|
insertData.primaryKeys || [],
|
||||||
);
|
);
|
||||||
|
state.shouldRestoreFocus = false;
|
||||||
if (!insertedRowId) {
|
if (!insertedRowId) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var missingIdStatus = showRowMutationStatus(
|
var missingIdStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row. Refresh the page to see it.",
|
"Inserted row. Refresh the page to see it.",
|
||||||
|
|
@ -6755,7 +6922,7 @@ async function saveRowEditDialog(state) {
|
||||||
try {
|
try {
|
||||||
insertedRow = await fetchUpdatedRowElement(state);
|
insertedRow = await fetchUpdatedRowElement(state);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var refreshFailedStatus = showRowMutationStatus(
|
var refreshFailedStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row, but could not refresh the table row. Refresh the page to see it.",
|
"Inserted row, but could not refresh the table row. Refresh the page to see it.",
|
||||||
|
|
@ -6770,7 +6937,7 @@ async function saveRowEditDialog(state) {
|
||||||
rowTitleLabel(insertedRow),
|
rowTitleLabel(insertedRow),
|
||||||
);
|
);
|
||||||
var addedRow = addInsertedRowToPage(insertedRow);
|
var addedRow = addInsertedRowToPage(insertedRow);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
showRowMutationStatus(state.manager, insertedStatusMessage, false);
|
showRowMutationStatus(state.manager, insertedStatusMessage, false);
|
||||||
if (addedRow) {
|
if (addedRow) {
|
||||||
var insertedFocusTarget =
|
var insertedFocusTarget =
|
||||||
|
|
@ -6779,7 +6946,7 @@ async function saveRowEditDialog(state) {
|
||||||
insertedFocusTarget.focus();
|
insertedFocusTarget.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var filteredStatus = showRowMutationStatus(
|
var filteredStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row. It does not match the current filters.",
|
"Inserted row. It does not match the current filters.",
|
||||||
|
|
@ -6791,7 +6958,8 @@ async function saveRowEditDialog(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRowPage()) {
|
if (isRowPage()) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -6827,7 +6995,8 @@ async function saveRowEditDialog(state) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (focusTarget && document.contains(focusTarget)) {
|
if (focusTarget && document.contains(focusTarget)) {
|
||||||
focusTarget.focus();
|
focusTarget.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -6971,8 +7140,7 @@ function ensureRowEditDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = ROW_EDIT_DIALOG_ID;
|
dialog.id = ROW_EDIT_DIALOG_ID;
|
||||||
dialog.className = "row-edit-dialog";
|
dialog.className = "row-edit-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
||||||
|
|
@ -6984,8 +7152,8 @@ function ensureRowEditDialog(manager) {
|
||||||
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
|
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
|
||||||
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
|
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
|
||||||
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
|
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body row-edit-fields"></div>
|
<div class="row-edit-fields"></div>
|
||||||
<div class="modal-body row-edit-bulk" hidden>
|
<div class="row-edit-bulk" hidden>
|
||||||
<div class="row-edit-bulk-editor">
|
<div class="row-edit-bulk-editor">
|
||||||
<p class="row-edit-bulk-note"><label for="row-edit-bulk-textarea">Paste TSV, CSV, or JSON</label>. You can also <button type="button" class="button-as-link row-edit-bulk-open-file">open a file</button> or drop it onto this textarea</p>
|
<p class="row-edit-bulk-note"><label for="row-edit-bulk-textarea">Paste TSV, CSV, or JSON</label>. You can also <button type="button" class="button-as-link row-edit-bulk-open-file">open a file</button> or drop it onto this textarea</p>
|
||||||
<input class="row-edit-bulk-file-input" type="file" accept=".csv,.tsv,.json,.txt,text/csv,text/tab-separated-values,application/json,text/plain" hidden>
|
<input class="row-edit-bulk-file-input" type="file" accept=".csv,.tsv,.json,.txt,text/csv,text/tab-separated-values,application/json,text/plain" hidden>
|
||||||
|
|
@ -7002,7 +7170,7 @@ function ensureRowEditDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-edit-bulk-actions">
|
<div class="row-edit-bulk-actions">
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
|
<button type="button" class="btn btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
|
||||||
<span class="row-edit-bulk-template-note"><span class="row-edit-bulk-template-note-wide">You can paste the template into Google Sheets or Excel.</span><span class="row-edit-bulk-template-note-narrow">Paste into Google Sheets or Excel</span></span>
|
<span class="row-edit-bulk-template-note"><span class="row-edit-bulk-template-note-wide">You can paste the template into Google Sheets or Excel.</span><span class="row-edit-bulk-template-note-narrow">Paste into Google Sheets or Excel</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7015,15 +7183,14 @@ function ensureRowEditDialog(manager) {
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
|
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
|
||||||
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
|
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-edit-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary row-edit-save" disabled>Save</button>
|
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
rowEditDialogState = {
|
rowEditDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".row-edit-form"),
|
form: dialog.querySelector(".row-edit-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -7054,6 +7221,7 @@ function ensureRowEditDialog(manager) {
|
||||||
singleInsertLink: dialog.querySelector(".row-edit-single-insert"),
|
singleInsertLink: dialog.querySelector(".row-edit-single-insert"),
|
||||||
cancelButton: dialog.querySelector(".row-edit-cancel"),
|
cancelButton: dialog.querySelector(".row-edit-cancel"),
|
||||||
saveButton: dialog.querySelector(".row-edit-save"),
|
saveButton: dialog.querySelector(".row-edit-save"),
|
||||||
|
currentButton: null,
|
||||||
currentRow: null,
|
currentRow: null,
|
||||||
currentRowId: null,
|
currentRowId: null,
|
||||||
currentPkPath: null,
|
currentPkPath: null,
|
||||||
|
|
@ -7081,7 +7249,9 @@ function ensureRowEditDialog(manager) {
|
||||||
manager: manager,
|
manager: manager,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
|
isClosePending: false,
|
||||||
hasLoaded: false,
|
hasLoaded: false,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
||||||
|
|
@ -7101,7 +7271,10 @@ function ensureRowEditDialog(manager) {
|
||||||
rowEditDialogState.bulkInsertTextarea.focus();
|
rowEditDialogState.bulkInsertTextarea.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.requestClose("cancel");
|
if (!rowEditDialogState.isSaving) {
|
||||||
|
rowEditDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) {
|
rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) {
|
||||||
|
|
@ -7220,17 +7393,31 @@ function ensureRowEditDialog(manager) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return (
|
if (ev.target === dialog) {
|
||||||
source === "cancel" || confirmDiscardRowEditChanges(rowEditDialogState)
|
closeRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
);
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = rowEditDialogState;
|
var state = rowEditDialogState;
|
||||||
var shouldReloadOnClose = state.shouldReloadOnClose;
|
var shouldReloadOnClose = state.shouldReloadOnClose;
|
||||||
var redirectOnCloseUrl = state.redirectOnCloseUrl;
|
var redirectOnCloseUrl = state.redirectOnCloseUrl;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
|
state.isClosePending = false;
|
||||||
state.bulkInsertLiveValidationError = null;
|
state.bulkInsertLiveValidationError = null;
|
||||||
state.shouldReloadOnClose = false;
|
state.shouldReloadOnClose = false;
|
||||||
state.redirectOnCloseUrl = null;
|
state.redirectOnCloseUrl = null;
|
||||||
|
|
@ -7243,6 +7430,13 @@ function ensureRowEditDialog(manager) {
|
||||||
destroyRowEditFields(state);
|
destroyRowEditFields(state);
|
||||||
setRowEditDialogLoading(state, false);
|
setRowEditDialogLoading(state, false);
|
||||||
setRowEditDialogSaving(state, false);
|
setRowEditDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
if (shouldReloadOnClose) {
|
if (shouldReloadOnClose) {
|
||||||
if (redirectOnCloseUrl) {
|
if (redirectOnCloseUrl) {
|
||||||
location.href = redirectOnCloseUrl;
|
location.href = redirectOnCloseUrl;
|
||||||
|
|
@ -7267,6 +7461,7 @@ async function openRowEditDialog(button, manager) {
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
state.mode = "edit";
|
state.mode = "edit";
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = row;
|
state.currentRow = row;
|
||||||
state.currentRowId = row.getAttribute("data-row") || "";
|
state.currentRowId = row.getAttribute("data-row") || "";
|
||||||
state.currentPkPath = rowDisplayLabel(row);
|
state.currentPkPath = rowDisplayLabel(row);
|
||||||
|
|
@ -7283,7 +7478,7 @@ async function openRowEditDialog(button, manager) {
|
||||||
} else {
|
} else {
|
||||||
state.form.removeAttribute("action");
|
state.form.removeAttribute("action");
|
||||||
}
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.hasLoaded = false;
|
state.hasLoaded = false;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
var loadId = state.loadId;
|
var loadId = state.loadId;
|
||||||
|
|
@ -7302,7 +7497,9 @@ async function openRowEditDialog(button, manager) {
|
||||||
state.summary.textContent = "";
|
state.summary.textContent = "";
|
||||||
syncRowEditInsertModeUi(state);
|
syncRowEditInsertModeUi(state);
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.cancelButton.focus();
|
state.cancelButton.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -7342,6 +7539,7 @@ function openRowInsertDialog(button, manager) {
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
state.mode = "insert";
|
state.mode = "insert";
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = null;
|
state.currentRow = null;
|
||||||
state.currentRowId = null;
|
state.currentRowId = null;
|
||||||
state.currentPkPath = null;
|
state.currentPkPath = null;
|
||||||
|
|
@ -7356,7 +7554,7 @@ function openRowInsertDialog(button, manager) {
|
||||||
state.shouldReloadOnClose = false;
|
state.shouldReloadOnClose = false;
|
||||||
state.redirectOnCloseUrl = null;
|
state.redirectOnCloseUrl = null;
|
||||||
resetBulkInsertPreview(state);
|
resetBulkInsertPreview(state);
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.hasLoaded = false;
|
state.hasLoaded = false;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
|
|
||||||
|
|
@ -7378,7 +7576,9 @@ function openRowInsertDialog(button, manager) {
|
||||||
state.summary.textContent = "";
|
state.summary.textContent = "";
|
||||||
syncRowEditInsertModeUi(state);
|
syncRowEditInsertModeUi(state);
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
renderRowInsertFields(state, insertData);
|
renderRowInsertFields(state, insertData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
56
datasette/static/json-format-highlight-1.0.1.js
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -66,8 +66,7 @@ function initMobileColumnActions(manager) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.className = "mobile-column-actions-dialog";
|
dialog.className = "mobile-column-actions-dialog";
|
||||||
dialog.id = MOBILE_COLUMN_DIALOG_ID;
|
dialog.id = MOBILE_COLUMN_DIALOG_ID;
|
||||||
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
|
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
|
||||||
|
|
@ -76,13 +75,13 @@ function initMobileColumnActions(manager) {
|
||||||
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
||||||
<span class="modal-meta"></span>
|
<span class="modal-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body list-wrap mobile-column-list"></div>
|
<div class="list-wrap mobile-column-list"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info">Tap a column to reveal actions.</span>
|
<span class="footer-info">Tap a column to reveal actions.</span>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost mobile-column-actions-done">Done</button>
|
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
triggerButton.setAttribute("aria-haspopup", "dialog");
|
triggerButton.setAttribute("aria-haspopup", "dialog");
|
||||||
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
|
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
|
||||||
|
|
@ -92,6 +91,7 @@ function initMobileColumnActions(manager) {
|
||||||
var listWrap = dialog.querySelector(".mobile-column-list");
|
var listWrap = dialog.querySelector(".mobile-column-list");
|
||||||
var doneButton = dialog.querySelector(".mobile-column-actions-done");
|
var doneButton = dialog.querySelector(".mobile-column-actions-done");
|
||||||
var expandedSectionId = null;
|
var expandedSectionId = null;
|
||||||
|
var shouldRestoreFocus = true;
|
||||||
|
|
||||||
function updateExpandedSection() {
|
function updateExpandedSection() {
|
||||||
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
|
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
|
||||||
|
|
@ -128,7 +128,16 @@ function initMobileColumnActions(manager) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog(options) {
|
function closeDialog(options) {
|
||||||
modal.close(options);
|
options = options || {};
|
||||||
|
shouldRestoreFocus = options.restoreFocus !== false;
|
||||||
|
if (dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
triggerButton.setAttribute("aria-expanded", "false");
|
||||||
|
if (shouldRestoreFocus) {
|
||||||
|
triggerButton.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDialog() {
|
function renderDialog() {
|
||||||
|
|
@ -157,8 +166,7 @@ function initMobileColumnActions(manager) {
|
||||||
topActions.className = "mobile-column-top-actions";
|
topActions.className = "mobile-column-top-actions";
|
||||||
|
|
||||||
var showAllColumns = document.createElement("a");
|
var showAllColumns = document.createElement("a");
|
||||||
showAllColumns.className =
|
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
|
||||||
"modal-btn modal-btn-ghost mobile-column-top-action";
|
|
||||||
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
|
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
|
||||||
showAllColumns.textContent = "Show all columns";
|
showAllColumns.textContent = "Show all columns";
|
||||||
|
|
||||||
|
|
@ -257,7 +265,9 @@ function initMobileColumnActions(manager) {
|
||||||
if (!renderDialog()) {
|
if (!renderDialog()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.show({ returnFocusTo: triggerButton });
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
triggerButton.setAttribute("aria-expanded", "true");
|
triggerButton.setAttribute("aria-expanded", "true");
|
||||||
var focusTarget =
|
var focusTarget =
|
||||||
dialog.querySelector(".mobile-column-top-action") ||
|
dialog.querySelector(".mobile-column-top-action") ||
|
||||||
|
|
@ -278,8 +288,22 @@ function initMobileColumnActions(manager) {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
triggerButton.setAttribute("aria-expanded", "false");
|
triggerButton.setAttribute("aria-expanded", "false");
|
||||||
|
if (shouldRestoreFocus) {
|
||||||
|
triggerButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
// Shared lifecycle for native modal dialogs.
|
|
||||||
(() => {
|
|
||||||
class DatasetteModal extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.beforeClose = null;
|
|
||||||
this._busy = false;
|
|
||||||
this._restoreFocus = true;
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
this._escapeTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static create() {
|
|
||||||
const modal = document.createElement("datasette-modal");
|
|
||||||
modal.appendChild(document.createElement("dialog"));
|
|
||||||
return modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
get dialog() {
|
|
||||||
return this.querySelector(":scope > dialog");
|
|
||||||
}
|
|
||||||
|
|
||||||
get busy() {
|
|
||||||
return this._busy;
|
|
||||||
}
|
|
||||||
|
|
||||||
set busy(value) {
|
|
||||||
this._busy = !!value;
|
|
||||||
if (this.dialog) {
|
|
||||||
this.dialog.setAttribute("aria-busy", String(this._busy));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
const dialog = this.dialog;
|
|
||||||
if (!dialog) return;
|
|
||||||
dialog.classList.add("datasette-modal");
|
|
||||||
this._listeners?.abort();
|
|
||||||
this._listeners = new AbortController();
|
|
||||||
const options = { signal: this._listeners.signal };
|
|
||||||
let backdropPointerDown = false;
|
|
||||||
const outside = (event) => {
|
|
||||||
const rect = dialog.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
event.target === dialog &&
|
|
||||||
(event.clientX < rect.left ||
|
|
||||||
event.clientX > rect.right ||
|
|
||||||
event.clientY < rect.top ||
|
|
||||||
event.clientY > rect.bottom)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
dialog.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
(event) => {
|
|
||||||
backdropPointerDown = outside(event);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
if (backdropPointerDown && outside(event))
|
|
||||||
this.requestClose("backdrop");
|
|
||||||
backdropPointerDown = false;
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
(event) => {
|
|
||||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
||||||
// A nested native dialog or plugin picker gets first refusal.
|
|
||||||
if (event.target.closest("dialog") !== dialog) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.busy || this._escapeCleanup || this._escapeTimer !== null)
|
|
||||||
return;
|
|
||||||
// Safari can otherwise use this Escape press to cancel confirm() too.
|
|
||||||
// Only keyboard dismissals wait for keyup; native cancel events needn't.
|
|
||||||
const onKeyup = (up) => {
|
|
||||||
if (up.key !== "Escape") return;
|
|
||||||
this._escapeCleanup();
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
this._escapeTimer = setTimeout(() => {
|
|
||||||
this._escapeTimer = null;
|
|
||||||
this.requestClose("escape");
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
this.ownerDocument.addEventListener("keyup", onKeyup, true);
|
|
||||||
this._escapeCleanup = () =>
|
|
||||||
this.ownerDocument.removeEventListener("keyup", onKeyup, true);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"cancel",
|
|
||||||
(event) => {
|
|
||||||
if (event.target !== dialog) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (!this._escapeCleanup && this._escapeTimer === null)
|
|
||||||
this.requestClose("escape");
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"close",
|
|
||||||
(event) => {
|
|
||||||
if (event.target !== dialog || dialog.open) return;
|
|
||||||
this._clearPendingClose();
|
|
||||||
this.busy = false;
|
|
||||||
if (this._restoreFocus && this._returnFocusTo?.isConnected) {
|
|
||||||
// Menu actions may have become hidden while the dialog was open.
|
|
||||||
const details = this._returnFocusTo.closest("details:not([open])");
|
|
||||||
const target =
|
|
||||||
details?.querySelector("summary") || this._returnFocusTo;
|
|
||||||
target.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this._listeners?.abort();
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
if (this.dialog?.open) this.dialog.close();
|
|
||||||
this.busy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearPendingClose() {
|
|
||||||
this._escapeCleanup?.();
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
clearTimeout(this._escapeTimer);
|
|
||||||
this._escapeTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
show({ returnFocusTo, initialFocus } = {}) {
|
|
||||||
const dialog = this.dialog;
|
|
||||||
if (!dialog.open) {
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._returnFocusTo = returnFocusTo || this.ownerDocument.activeElement;
|
|
||||||
this._restoreFocus = true;
|
|
||||||
dialog.showModal();
|
|
||||||
}
|
|
||||||
if (typeof initialFocus === "function") initialFocus();
|
|
||||||
else initialFocus?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
requestClose(source = "cancel") {
|
|
||||||
if (!this.dialog.open || this.busy) return false;
|
|
||||||
if (this.beforeClose && this.beforeClose(source) === false) return false;
|
|
||||||
this.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
close({ restoreFocus = true } = {}) {
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._restoreFocus = restoreFocus;
|
|
||||||
this.dialog.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("datasette-modal", DatasetteModal);
|
|
||||||
window.DatasetteModal = DatasetteModal;
|
|
||||||
})();
|
|
||||||
|
|
@ -10,22 +10,277 @@ class NavigationSearch extends HTMLElement {
|
||||||
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
||||||
this.statusId = `navigation-search-status-${this.instanceId}`;
|
this.statusId = `navigation-search-status-${this.instanceId}`;
|
||||||
this.titleId = `navigation-search-title-${this.instanceId}`;
|
this.titleId = `navigation-search-title-${this.instanceId}`;
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
this.renderedMatches = [];
|
this.renderedMatches = [];
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
}
|
this.restoreFocusTarget = null;
|
||||||
|
this.shouldRestoreFocus = true;
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
if (this._initialized) return;
|
|
||||||
this._initialized = true;
|
|
||||||
this.render();
|
this.render();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
<style>
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--modal-border-radius, 0.75rem);
|
||||||
|
padding: 0;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||||
|
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||||
|
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-search {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
width: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-search:hover,
|
||||||
|
.close-search:focus {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(80vh - 180px);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.selected {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-start-content {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-start-content:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-url {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-description {
|
||||||
|
color: #374151;
|
||||||
|
display: -webkit-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-heading {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding: 0.5rem 1rem 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-actions {
|
||||||
|
padding: 0.25rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text kbd {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
dialog {
|
||||||
|
width: 95vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||||
|
|
@ -47,22 +302,23 @@ class NavigationSearch extends HTMLElement {
|
||||||
>
|
>
|
||||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body results-container"></div>
|
<div class="results-container"></div>
|
||||||
<div class="hint-text">
|
<div class="hint-text">
|
||||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||||
<span><kbd>Enter</kbd> Select</span>
|
<span><kbd>Enter</kbd> Select</span>
|
||||||
<span><kbd>Esc</kbd> Close</span>
|
<span><kbd>Esc</kbd> Close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog></datasette-modal>
|
</dialog>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
const dialog = this.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const closeButton = this.querySelector(".close-search");
|
const closeButton = this.shadowRoot.querySelector(".close-search");
|
||||||
const resultsContainer = this.querySelector(".results-container");
|
const resultsContainer =
|
||||||
|
this.shadowRoot.querySelector(".results-container");
|
||||||
|
|
||||||
// Global keyboard listener for "/"
|
// Global keyboard listener for "/"
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
|
|
@ -99,6 +355,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectCurrentItem();
|
this.selectCurrentItem();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
this.closeMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,6 +380,18 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
dialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === dialog) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", () => {
|
dialog.addEventListener("close", () => {
|
||||||
this.onMenuClosed();
|
this.onMenuClosed();
|
||||||
});
|
});
|
||||||
|
|
@ -162,6 +432,19 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focusRestoreTarget(trigger) {
|
||||||
|
if (trigger && typeof trigger.focus === "function") {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
document.activeElement &&
|
||||||
|
typeof document.activeElement.focus === "function"
|
||||||
|
) {
|
||||||
|
return document.activeElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
setNavigationTriggersExpanded(expanded) {
|
setNavigationTriggersExpanded(expanded) {
|
||||||
if (typeof document.querySelectorAll !== "function") {
|
if (typeof document.querySelectorAll !== "function") {
|
||||||
return;
|
return;
|
||||||
|
|
@ -182,8 +465,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateComboboxState() {
|
updateComboboxState() {
|
||||||
const dialog = this.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const matches = this.renderedMatches || [];
|
const matches = this.renderedMatches || [];
|
||||||
this.setElementAttribute(
|
this.setElementAttribute(
|
||||||
input,
|
input,
|
||||||
|
|
@ -208,7 +491,7 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
const status = this.querySelector(`#${this.statusId}`);
|
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = message || "";
|
status.textContent = message || "";
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +701,7 @@ class NavigationSearch extends HTMLElement {
|
||||||
section.render(node, {
|
section.render(node, {
|
||||||
navigationSearch: this,
|
navigationSearch: this,
|
||||||
container,
|
container,
|
||||||
input: this.querySelector(".search-input"),
|
input: this.shadowRoot.querySelector(".search-input"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -457,8 +740,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResults() {
|
renderResults() {
|
||||||
const container = this.querySelector(".results-container");
|
const container = this.shadowRoot.querySelector(".results-container");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const showStartContent = !input.value.trim();
|
const showStartContent = !input.value.trim();
|
||||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
const jumpSections = showStartContent ? this.jumpSections() : [];
|
||||||
const startBlock = showStartContent
|
const startBlock = showStartContent
|
||||||
|
|
@ -570,15 +853,18 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openMenu(returnFocusTo) {
|
openMenu(trigger) {
|
||||||
const input = this.querySelector(".search-input");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
|
|
||||||
this.querySelector("datasette-modal").show({
|
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
|
||||||
returnFocusTo,
|
this.shouldRestoreFocus = true;
|
||||||
initialFocus: input,
|
if (!dialog.open) {
|
||||||
});
|
dialog.showModal();
|
||||||
|
}
|
||||||
this.setNavigationTriggersExpanded(true);
|
this.setNavigationTriggersExpanded(true);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
|
||||||
// Reset state, then populate the default jump list.
|
// Reset state, then populate the default jump list.
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
|
|
@ -588,15 +874,29 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMenu(options = {}) {
|
closeMenu(options = {}) {
|
||||||
this.querySelector("datasette-modal").close(options);
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
this.shouldRestoreFocus = options.restoreFocus !== false;
|
||||||
|
if (dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
this.onMenuClosed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMenuClosed() {
|
onMenuClosed() {
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
this.setElementAttribute(input, "aria-expanded", "false");
|
this.setElementAttribute(input, "aria-expanded", "false");
|
||||||
this.removeElementAttribute(input, "aria-activedescendant");
|
this.removeElementAttribute(input, "aria-activedescendant");
|
||||||
this.setNavigationTriggersExpanded(false);
|
this.setNavigationTriggersExpanded(false);
|
||||||
this.setStatus("");
|
this.setStatus("");
|
||||||
|
if (
|
||||||
|
this.shouldRestoreFocus &&
|
||||||
|
this.restoreFocusTarget &&
|
||||||
|
typeof this.restoreFocusTarget.focus === "function"
|
||||||
|
) {
|
||||||
|
this.restoreFocusTarget.focus();
|
||||||
|
}
|
||||||
|
this.restoreFocusTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,6 @@ function createSetColumnTypeOption(value, name, description, checked) {
|
||||||
|
|
||||||
function setSetColumnTypeDialogBusy(state, isBusy) {
|
function setSetColumnTypeDialogBusy(state, isBusy) {
|
||||||
state.isBusy = isBusy;
|
state.isBusy = isBusy;
|
||||||
state.modal.busy = isBusy;
|
|
||||||
state.saveButton.disabled = isBusy;
|
state.saveButton.disabled = isBusy;
|
||||||
state.cancelButton.disabled = isBusy;
|
state.cancelButton.disabled = isBusy;
|
||||||
Array.from(
|
Array.from(
|
||||||
|
|
@ -186,8 +185,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
|
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
|
||||||
dialog.className = "set-column-type-dialog";
|
dialog.className = "set-column-type-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "set-column-type-title");
|
dialog.setAttribute("aria-labelledby", "set-column-type-title");
|
||||||
|
|
@ -198,17 +196,16 @@ function ensureSetColumnTypeDialog() {
|
||||||
</div>
|
</div>
|
||||||
<p class="set-column-type-status"></p>
|
<p class="set-column-type-status"></p>
|
||||||
<p class="set-column-type-error" hidden></p>
|
<p class="set-column-type-error" hidden></p>
|
||||||
<div class="modal-body set-column-type-options"></div>
|
<div class="set-column-type-options"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info"></span>
|
<span class="footer-info"></span>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost set-column-type-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary set-column-type-save">Save</button>
|
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
setColumnTypeDialogState = {
|
setColumnTypeDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
meta: dialog.querySelector(".modal-meta"),
|
meta: dialog.querySelector(".modal-meta"),
|
||||||
status: dialog.querySelector(".set-column-type-status"),
|
status: dialog.querySelector(".set-column-type-status"),
|
||||||
|
|
@ -223,7 +220,21 @@ function ensureSetColumnTypeDialog() {
|
||||||
};
|
};
|
||||||
|
|
||||||
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
|
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
if (!setColumnTypeDialogState.isBusy) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
if (setColumnTypeDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
|
|
@ -231,9 +242,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
|
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
setColumnTypeDialogState.saveButton.addEventListener(
|
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
|
||||||
"click",
|
|
||||||
async function () {
|
|
||||||
var state = setColumnTypeDialogState;
|
var state = setColumnTypeDialogState;
|
||||||
var selected = state.dialog.querySelector(
|
var selected = state.dialog.querySelector(
|
||||||
'input[name="set-column-type-choice"]:checked',
|
'input[name="set-column-type-choice"]:checked',
|
||||||
|
|
@ -244,7 +253,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (selectedType === currentType) {
|
if (selectedType === currentType) {
|
||||||
state.modal.close();
|
state.dialog.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,8 +284,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
setSetColumnTypeDialogBusy(state, false);
|
setSetColumnTypeDialogBusy(state, false);
|
||||||
showSetColumnTypeDialogError(state, error.message || "Request failed");
|
showSetColumnTypeDialogError(state, error.message || "Request failed");
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return setColumnTypeDialogState;
|
return setColumnTypeDialogState;
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +341,9 @@ function openSetColumnTypeDialog(th) {
|
||||||
state.optionsWrap.appendChild(emptyState);
|
state.optionsWrap.appendChild(emptyState);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.modal.show();
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
var selectedOption = state.dialog.querySelector(
|
var selectedOption = state.dialog.querySelector(
|
||||||
'input[name="set-column-type-choice"]:checked',
|
'input[name="set-column-type-choice"]:checked',
|
||||||
);
|
);
|
||||||
|
|
@ -357,10 +367,9 @@ function shouldShowShowAllColumns() {
|
||||||
|
|
||||||
function hasMultipleVisibleColumns(manager) {
|
function hasMultipleVisibleColumns(manager) {
|
||||||
return (
|
return (
|
||||||
Array.from(
|
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
|
||||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
|
||||||
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1")
|
).length > 1
|
||||||
.length > 1
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -640,12 +649,10 @@ function filterRowNumberFromName(name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextFilterRowNumber(manager) {
|
function nextFilterRowNumber(manager) {
|
||||||
return (
|
return filterRowsWithControls(manager).reduce((max, row) => {
|
||||||
filterRowsWithControls(manager).reduce((max, row) => {
|
|
||||||
var column = row.querySelector("select");
|
var column = row.querySelector("select");
|
||||||
return Math.max(max, filterRowNumberFromName(column && column.name));
|
return Math.max(max, filterRowNumberFromName(column && column.name));
|
||||||
}, 0) + 1
|
}, 0) + 1;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilterRowNumber(row, number) {
|
function setFilterRowNumber(row, number) {
|
||||||
|
|
@ -672,11 +679,9 @@ function updateFilterRowButtons(manager) {
|
||||||
if (addButton) {
|
if (addButton) {
|
||||||
addButton.hidden = index !== rows.length - 1 || !column.value;
|
addButton.hidden = index !== rows.length - 1 || !column.value;
|
||||||
}
|
}
|
||||||
var visibleButtonCount = [removeButton, addButton].filter(
|
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
||||||
function (button) {
|
|
||||||
return button && !button.hidden;
|
return button && !button.hidden;
|
||||||
},
|
}).length;
|
||||||
).length;
|
|
||||||
row.classList.toggle(
|
row.classList.toggle(
|
||||||
"filter-controls-row-has-buttons",
|
"filter-controls-row-has-buttons",
|
||||||
visibleButtonCount > 0,
|
visibleButtonCount > 0,
|
||||||
|
|
@ -698,9 +703,7 @@ function cloneFilterRow(row) {
|
||||||
clone.querySelector(".filter-op select").name = "_filter_op";
|
clone.querySelector(".filter-op select").name = "_filter_op";
|
||||||
clone.querySelector("input.filter-value").name = "_filter_value";
|
clone.querySelector("input.filter-value").name = "_filter_value";
|
||||||
resetFilterRow(clone);
|
resetFilterRow(clone);
|
||||||
clone
|
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
||||||
.querySelectorAll(".filter-row-icon")
|
|
||||||
.forEach((button) => button.remove());
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -857,45 +860,10 @@ function openColumnChooser() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCountAll() {
|
|
||||||
var button = document.querySelector(".count-all");
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
button.addEventListener("click", async function () {
|
|
||||||
var count = document.querySelector(".table-count");
|
|
||||||
var error = document.querySelector(".count-error");
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = "Counting…";
|
|
||||||
error.textContent = "";
|
|
||||||
try {
|
|
||||||
var response = await fetch(button.dataset.countUrl + location.search, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
var data = await response.json();
|
|
||||||
if (!response.ok || !data.ok) {
|
|
||||||
throw new Error((data.errors || ["Count failed"]).join(" "));
|
|
||||||
}
|
|
||||||
count.textContent =
|
|
||||||
data.count.toLocaleString("en-US") +
|
|
||||||
(data.count === 1 ? " row" : " rows");
|
|
||||||
button.remove();
|
|
||||||
} catch (ex) {
|
|
||||||
error.textContent = ex.message || "Count failed";
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = "count all";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensures Table UI is initialized only after the Manager is ready.
|
// Ensures Table UI is initialized only after the Manager is ready.
|
||||||
document.addEventListener("datasette_init", function (evt) {
|
document.addEventListener("datasette_init", function (evt) {
|
||||||
const { detail: manager } = evt;
|
const { detail: manager } = evt;
|
||||||
|
|
||||||
initCountAll();
|
|
||||||
initializeColumnActions(manager);
|
initializeColumnActions(manager);
|
||||||
|
|
||||||
// Main table
|
// Main table
|
||||||
|
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
"""
|
|
||||||
OpenTelemetry integration for Datasette.
|
|
||||||
|
|
||||||
This uses `opentelemetry-api` only. Providers, exporters and sampling are
|
|
||||||
configured by whoever runs Datasette, for example `opentelemetry-instrument`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import contextvars
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import weakref
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from opentelemetry import context as otel_context_api
|
|
||||||
from opentelemetry import metrics as otel_metrics
|
|
||||||
from opentelemetry import trace as otel_trace
|
|
||||||
from opentelemetry.propagate import extract
|
|
||||||
from opentelemetry.propagators.textmap import Getter
|
|
||||||
from opentelemetry.trace import Link, SpanKind, Status, StatusCode, get_current_span
|
|
||||||
|
|
||||||
from .telemetry_registry import (
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_SYSTEM,
|
|
||||||
ERROR_TYPE,
|
|
||||||
HTTP_REQUEST_METHOD,
|
|
||||||
HTTP_RESPONSE_STATUS_CODE,
|
|
||||||
INTERNAL_CLIENT,
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
OPERATION,
|
|
||||||
SERVER_ADDRESS,
|
|
||||||
URL_PATH,
|
|
||||||
URL_SCHEME,
|
|
||||||
USER_AGENT_ORIGINAL,
|
|
||||||
)
|
|
||||||
from .version import __version__
|
|
||||||
|
|
||||||
# True while code is executing within a datasette.client request. Defined
|
|
||||||
# here rather than in app.py to avoid a circular import.
|
|
||||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
|
||||||
|
|
||||||
# The semantic conventions version matching the attribute names used here.
|
|
||||||
# 1.30.0 renamed `db.system` to `db.system.name`, so update this when
|
|
||||||
# renaming attributes to match a newer version.
|
|
||||||
SCHEMA_URL = "https://opentelemetry.io/schemas/1.29.0"
|
|
||||||
|
|
||||||
tracer = otel_trace.get_tracer("datasette", __version__, schema_url=SCHEMA_URL)
|
|
||||||
meter = otel_metrics.get_meter("datasette", __version__, schema_url=SCHEMA_URL)
|
|
||||||
|
|
||||||
MAX_SQL_LENGTH = 2048
|
|
||||||
|
|
||||||
|
|
||||||
def sql_attribute(sql: str) -> str:
|
|
||||||
"Truncate SQL text so it is safe to attach to a span as an attribute."
|
|
||||||
sql = sql.strip()
|
|
||||||
if len(sql) <= MAX_SQL_LENGTH:
|
|
||||||
return sql
|
|
||||||
return sql[:MAX_SQL_LENGTH] + "…[truncated]"
|
|
||||||
|
|
||||||
|
|
||||||
def callback_name(fn) -> str:
|
|
||||||
"""
|
|
||||||
The name recorded as `datasette.callback` for a callback-style call.
|
|
||||||
|
|
||||||
Falls back to the type name for callables such as `functools.partial`
|
|
||||||
that have no `__qualname__`.
|
|
||||||
"""
|
|
||||||
return getattr(fn, "__qualname__", type(fn).__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def linked_root_span_kwargs(context=None):
|
|
||||||
"""
|
|
||||||
Keyword arguments that start a new root span with a ``Link`` back to
|
|
||||||
the current span.
|
|
||||||
|
|
||||||
Use this for work that can outlive the span that caused it, such as a
|
|
||||||
background task or a ``block=False`` write.
|
|
||||||
|
|
||||||
Pass ``context`` to link to the span in a previously captured context
|
|
||||||
instead of the current one. If there is no valid span, no link is added.
|
|
||||||
|
|
||||||
Works with any tracer::
|
|
||||||
|
|
||||||
with my_tracer.start_as_current_span(
|
|
||||||
"myplugin.job", **linked_root_span_kwargs()
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
cause = get_current_span(context).get_span_context()
|
|
||||||
links = [Link(cause)] if cause.is_valid else []
|
|
||||||
return {"context": otel_context_api.Context(), "links": links}
|
|
||||||
|
|
||||||
|
|
||||||
# Keywords that can be recorded as db.operation.name. SQL can be supplied by
|
|
||||||
# users, so an allowlist keeps the number of distinct values small.
|
|
||||||
DB_OPERATION_ALLOWLIST = frozenset(
|
|
||||||
{
|
|
||||||
"SELECT",
|
|
||||||
"INSERT",
|
|
||||||
"UPDATE",
|
|
||||||
"DELETE",
|
|
||||||
"CREATE",
|
|
||||||
"DROP",
|
|
||||||
"ALTER",
|
|
||||||
"PRAGMA",
|
|
||||||
"EXPLAIN",
|
|
||||||
"REPLACE",
|
|
||||||
"VACUUM",
|
|
||||||
"ANALYZE",
|
|
||||||
"WITH",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_LEADING_KEYWORD = re.compile(r"^\s*([A-Za-z]+)")
|
|
||||||
|
|
||||||
|
|
||||||
def sql_operation_name(sql: str) -> str | None:
|
|
||||||
"""
|
|
||||||
The statement's leading keyword if it is in the allowlist, else None.
|
|
||||||
|
|
||||||
Statements that start with a comment or "(" return None. Statements
|
|
||||||
starting with a CTE return `WITH`. Only call this for a single statement.
|
|
||||||
"""
|
|
||||||
match = _LEADING_KEYWORD.match(sql)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
keyword = match.group(1).upper()
|
|
||||||
if keyword in DB_OPERATION_ALLOWLIST:
|
|
||||||
return keyword
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --- The HTTP request span ------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _ScopeHeadersGetter(Getter):
|
|
||||||
"Read W3C trace context from an ASGI scope's headers."
|
|
||||||
|
|
||||||
def get(self, carrier, key):
|
|
||||||
wanted = key.lower().encode("latin-1")
|
|
||||||
values = [v.decode("latin-1") for k, v in carrier if k.lower() == wanted]
|
|
||||||
return values or None
|
|
||||||
|
|
||||||
def keys(self, carrier):
|
|
||||||
return [k.decode("latin-1") for k, _ in carrier]
|
|
||||||
|
|
||||||
|
|
||||||
_HEADERS_GETTER = _ScopeHeadersGetter()
|
|
||||||
|
|
||||||
|
|
||||||
# Methods defined by RFC 9110 plus PATCH (RFC 5789). Anything else is
|
|
||||||
# recorded as `_OTHER`, as recommended by semantic conventions.
|
|
||||||
_KNOWN_METHODS = frozenset(
|
|
||||||
{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clamp_http_method(method):
|
|
||||||
"The request method if it is one we recognise, else ``_OTHER``."
|
|
||||||
method = (method or "").upper()
|
|
||||||
return method if method in _KNOWN_METHODS else "_OTHER"
|
|
||||||
|
|
||||||
|
|
||||||
def _first_header(headers, name):
|
|
||||||
"The first value of a header, decoded, or None."
|
|
||||||
for key, value in headers:
|
|
||||||
if key.lower() == name:
|
|
||||||
return value.decode("latin-1")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _url_path(scope):
|
|
||||||
"""
|
|
||||||
The request path, with any query string removed.
|
|
||||||
|
|
||||||
Prefers `raw_path`, which preserves encoded slashes in database and
|
|
||||||
table names. Some clients include the query string in `raw_path`, so
|
|
||||||
that is stripped as well.
|
|
||||||
"""
|
|
||||||
raw_path = scope.get("raw_path")
|
|
||||||
if raw_path:
|
|
||||||
if isinstance(raw_path, bytes):
|
|
||||||
raw_path = raw_path.decode("latin-1")
|
|
||||||
return raw_path.split("?", 1)[0]
|
|
||||||
return scope.get("path", "")
|
|
||||||
|
|
||||||
|
|
||||||
# The request span is passed to the router in the ASGI scope, because a
|
|
||||||
# plugin's asgi_wrapper() middleware may have made its own span current.
|
|
||||||
# Absent if the span is not recording.
|
|
||||||
REQUEST_SPAN_SCOPE_KEY = "datasette.telemetry.request_span"
|
|
||||||
|
|
||||||
|
|
||||||
def request_span(scope):
|
|
||||||
"""
|
|
||||||
The recording request span for an ASGI scope, or None.
|
|
||||||
|
|
||||||
Falls back to the current span, for when Datasette is running under
|
|
||||||
other instrumentation.
|
|
||||||
"""
|
|
||||||
span = scope.get(REQUEST_SPAN_SCOPE_KEY)
|
|
||||||
if span is None:
|
|
||||||
span = otel_trace.get_current_span()
|
|
||||||
return span if span.is_recording() else None
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryMiddleware:
|
|
||||||
"""
|
|
||||||
One `SpanKind.SERVER` span per HTTP request.
|
|
||||||
|
|
||||||
The span ends after the full response, including any streamed body,
|
|
||||||
has been sent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app):
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
|
||||||
# Pass lifespan and websocket scopes straight through
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
headers = scope.get("headers") or []
|
|
||||||
# Uses the global propagator, configured with OTEL_PROPAGATORS
|
|
||||||
context = extract(headers, getter=_HEADERS_GETTER)
|
|
||||||
method = clamp_http_method(scope.get("method", ""))
|
|
||||||
# Renamed to include the route once routing has happened
|
|
||||||
with tracer.start_as_current_span(
|
|
||||||
method, context=context, kind=SpanKind.SERVER
|
|
||||||
) as span:
|
|
||||||
if not span.is_recording():
|
|
||||||
# No provider installed, or the trace was not sampled
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
span.set_attribute(HTTP_REQUEST_METHOD, method)
|
|
||||||
span.set_attribute(URL_PATH, _url_path(scope))
|
|
||||||
scheme = scope.get("scheme")
|
|
||||||
if scheme:
|
|
||||||
span.set_attribute(URL_SCHEME, scheme)
|
|
||||||
host = _first_header(headers, b"host")
|
|
||||||
if host:
|
|
||||||
span.set_attribute(SERVER_ADDRESS, host)
|
|
||||||
user_agent = _first_header(headers, b"user-agent")
|
|
||||||
if user_agent:
|
|
||||||
span.set_attribute(USER_AGENT_ORIGINAL, user_agent)
|
|
||||||
if _in_datasette_client.get():
|
|
||||||
span.set_attribute(INTERNAL_CLIENT, True)
|
|
||||||
|
|
||||||
scope = dict(scope, **{REQUEST_SPAN_SCOPE_KEY: span})
|
|
||||||
|
|
||||||
# Some responses are sent without a Response object, so the
|
|
||||||
# status is captured by wrapping send()
|
|
||||||
status_holder = {}
|
|
||||||
|
|
||||||
async def wrapped_send(message):
|
|
||||||
if (
|
|
||||||
message["type"] == "http.response.start"
|
|
||||||
and "status" not in status_holder
|
|
||||||
):
|
|
||||||
status_holder["status"] = message["status"]
|
|
||||||
await send(message)
|
|
||||||
|
|
||||||
escaped = False
|
|
||||||
try:
|
|
||||||
await self.app(scope, receive, wrapped_send)
|
|
||||||
except BaseException as exception:
|
|
||||||
# Includes asyncio.CancelledError when a client disconnects
|
|
||||||
escaped = True
|
|
||||||
span.set_attribute(ERROR_TYPE, type(exception).__name__)
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
status = status_holder.get("status")
|
|
||||||
if status is not None:
|
|
||||||
span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status)
|
|
||||||
# 4xx responses are not errors for a server span. If an
|
|
||||||
# exception escaped, keep its class name as error.type.
|
|
||||||
if status >= 500 and not escaped:
|
|
||||||
span.set_status(Status(StatusCode.ERROR))
|
|
||||||
span.set_attribute(ERROR_TYPE, str(status))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Metrics --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_attributes(database_name, operation):
|
|
||||||
return {
|
|
||||||
DB_SYSTEM: "sqlite",
|
|
||||||
DB_NAMESPACE: database_name,
|
|
||||||
OPERATION: operation,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Instruments use plain text descriptions. The registry entries have longer
|
|
||||||
# reStructuredText descriptions for the documentation.
|
|
||||||
|
|
||||||
sql_operation_duration = meter.create_histogram(
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
unit=M_OPERATION_DURATION.unit,
|
|
||||||
description="Duration of a SQL operation issued by Datasette",
|
|
||||||
explicit_bucket_boundaries_advisory=M_OPERATION_DURATION.buckets,
|
|
||||||
)
|
|
||||||
|
|
||||||
write_queue_wait = meter.create_histogram(
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
unit=M_WRITE_QUEUE_WAIT.unit,
|
|
||||||
description=(
|
|
||||||
"Time a write spent queued behind the single write thread for its database"
|
|
||||||
),
|
|
||||||
explicit_bucket_boundaries_advisory=M_WRITE_QUEUE_WAIT.buckets,
|
|
||||||
)
|
|
||||||
|
|
||||||
queries_interrupted = meter.create_counter(
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
unit=M_QUERIES_INTERRUPTED.unit,
|
|
||||||
description="Queries cancelled for exceeding sql_time_limit_ms",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def record_operation_duration(database_name, operation):
|
|
||||||
"""
|
|
||||||
Record `db.client.operation.duration` for one SQL operation.
|
|
||||||
|
|
||||||
Sets `error.type` to the exception class on failure. For a `block=False`
|
|
||||||
write this measures the time taken to enqueue the write.
|
|
||||||
"""
|
|
||||||
attributes = _duration_attributes(database_name, operation)
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
except BaseException as exception:
|
|
||||||
attributes[ERROR_TYPE] = type(exception).__qualname__
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
sql_operation_duration.record(time.perf_counter() - started, attributes)
|
|
||||||
|
|
||||||
|
|
||||||
def record_write_queue_wait(database_name, waited_ns):
|
|
||||||
write_queue_wait.record(waited_ns / 1e9, {DB_NAMESPACE: database_name})
|
|
||||||
|
|
||||||
|
|
||||||
def record_query_interrupted(database_name):
|
|
||||||
queries_interrupted.add(1, {DB_NAMESPACE: database_name})
|
|
||||||
|
|
||||||
|
|
||||||
# Live Datasette instances reported by the gauges below. The lock is needed
|
|
||||||
# because gauge callbacks run on the SDK's collection thread.
|
|
||||||
#
|
|
||||||
# The pool gauges do not identify which instance they came from, so they
|
|
||||||
# are only meaningful for a process running a single Datasette instance.
|
|
||||||
_live_datasettes = weakref.WeakSet()
|
|
||||||
_live_datasettes_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def register_datasette(ds):
|
|
||||||
"Start reporting pool/queue gauges for this Datasette instance."
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
_live_datasettes.add(ds)
|
|
||||||
|
|
||||||
|
|
||||||
def unregister_datasette(ds):
|
|
||||||
"Stop reporting gauges for an instance that has been closed."
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
_live_datasettes.discard(ds)
|
|
||||||
|
|
||||||
|
|
||||||
def _live_instances():
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
return list(_live_datasettes)
|
|
||||||
|
|
||||||
|
|
||||||
def _databases_of(ds):
|
|
||||||
"Every Database attached to an instance, including the internal database."
|
|
||||||
databases = list(ds.databases.values())
|
|
||||||
internal = getattr(ds, "_internal_database", None)
|
|
||||||
if internal is not None:
|
|
||||||
databases.append(internal)
|
|
||||||
return databases
|
|
||||||
|
|
||||||
|
|
||||||
def observe_sql_thread_limit(options=None):
|
|
||||||
"Size of the shared read-query thread pool (the num_sql_threads setting)."
|
|
||||||
for ds in _live_instances():
|
|
||||||
if ds.executor is None:
|
|
||||||
# num_sql_threads=0 - queries run on the event loop, no pool.
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(ds.setting("num_sql_threads"), {})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_sql_thread_queue_depth(options=None):
|
|
||||||
"""
|
|
||||||
Read queries waiting for a free thread in the shared pool.
|
|
||||||
|
|
||||||
`_work_queue` is a private attribute of ThreadPoolExecutor, so this
|
|
||||||
reports nothing if it is missing.
|
|
||||||
"""
|
|
||||||
for ds in _live_instances():
|
|
||||||
if ds.executor is None:
|
|
||||||
continue
|
|
||||||
work_queue = getattr(ds.executor, "_work_queue", None)
|
|
||||||
if work_queue is None:
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(work_queue.qsize(), {})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_pending_queries(options=None):
|
|
||||||
"""
|
|
||||||
Read queries submitted to the pool and not yet finished, per database.
|
|
||||||
|
|
||||||
Reads `len()` without `_pending_execute_futures_lock` to avoid blocking
|
|
||||||
queries.
|
|
||||||
"""
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
yield otel_metrics.Observation(
|
|
||||||
len(db._pending_execute_futures), {DB_NAMESPACE: db.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def observe_write_queue_depth(options=None):
|
|
||||||
"Writes queued behind the single write thread, per database."
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
write_queue = db._write_queue
|
|
||||||
if write_queue is None:
|
|
||||||
# No write has ever been queued for this database.
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(write_queue.qsize(), {DB_NAMESPACE: db.name})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_open_connections(options=None):
|
|
||||||
"Open SQLite connections tracked for closing, per database."
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
yield otel_metrics.Observation(
|
|
||||||
len(db._all_connections), {DB_NAMESPACE: db.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
sql_thread_limit_gauge = meter.create_observable_gauge(
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
callbacks=[observe_sql_thread_limit],
|
|
||||||
unit=M_THREADS_LIMIT.unit,
|
|
||||||
description="Maximum concurrent read queries (the num_sql_threads setting)",
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_thread_queue_depth_gauge = meter.create_observable_gauge(
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
callbacks=[observe_sql_thread_queue_depth],
|
|
||||||
unit=M_THREADS_QUEUE_DEPTH.unit,
|
|
||||||
description="Read queries waiting for a free thread in the shared SQL pool",
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_queries_gauge = meter.create_observable_gauge(
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
callbacks=[observe_pending_queries],
|
|
||||||
unit=M_QUERIES_PENDING.unit,
|
|
||||||
description="Read queries submitted to the pool and not yet complete",
|
|
||||||
)
|
|
||||||
|
|
||||||
write_queue_depth_gauge = meter.create_observable_gauge(
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
callbacks=[observe_write_queue_depth],
|
|
||||||
unit=M_WRITE_QUEUE_DEPTH.unit,
|
|
||||||
description="Writes queued behind a database's single write thread",
|
|
||||||
)
|
|
||||||
|
|
||||||
open_connections_gauge = meter.create_observable_gauge(
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
callbacks=[observe_open_connections],
|
|
||||||
unit=M_CONNECTIONS_OPEN.unit,
|
|
||||||
description="Open SQLite connections tracked for closing",
|
|
||||||
)
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
"""
|
|
||||||
Every span, metric and attribute that Datasette emits.
|
|
||||||
|
|
||||||
These entries are used by the instrumentation code, by `docs/telemetry_doc.py`
|
|
||||||
to generate the documentation, and by `tests/test_telemetry_registry.py` to
|
|
||||||
check that the emitted telemetry matches the registry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from opentelemetry.trace import SpanKind
|
|
||||||
|
|
||||||
|
|
||||||
class Attribute(str):
|
|
||||||
"""
|
|
||||||
A span attribute key, carrying its own documentation.
|
|
||||||
|
|
||||||
Subclasses `str` so it can be handed straight to `set_attribute()`.
|
|
||||||
|
|
||||||
Part of Datasette's public plugin API - plugins declare their own
|
|
||||||
telemetry registries with these classes. See the "Telemetry for plugin
|
|
||||||
authors" documentation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("description", "optional", "values")
|
|
||||||
|
|
||||||
def __new__(cls, name, description, optional=False, values=None):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.description = description
|
|
||||||
self.optional = optional
|
|
||||||
# The allowed values for this attribute, or None to allow any value
|
|
||||||
self.values = frozenset(values) if values is not None else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# Copies and pickles become a plain str, since __new__ requires the
|
|
||||||
# extra arguments. ConsoleMetricExporter deepcopies attribute keys.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"Attribute({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class SpanName(str):
|
|
||||||
"""A span name, carrying its documentation and the attributes it may set.
|
|
||||||
|
|
||||||
Part of Datasette's public plugin API, like `Attribute`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("attributes", "description", "dynamic", "kind", "prefix")
|
|
||||||
|
|
||||||
def __new__(
|
|
||||||
cls,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
attributes=(),
|
|
||||||
prefix=False,
|
|
||||||
dynamic=False,
|
|
||||||
kind=SpanKind.INTERNAL,
|
|
||||||
):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.description = description
|
|
||||||
self.attributes = tuple(attributes)
|
|
||||||
# Match emitted names that start with this prefix, for names with a
|
|
||||||
# variable suffix such as SpanName("chat ", ..., prefix=True)
|
|
||||||
self.prefix = prefix
|
|
||||||
# The emitted name is built at runtime, so `span_for()` matches it by
|
|
||||||
# span kind. The entry's string is a template for the documentation.
|
|
||||||
self.dynamic = dynamic
|
|
||||||
self.kind = kind
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# See Attribute.__reduce__.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"SpanName({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class MetricName(str):
|
|
||||||
"A metric name, carrying its instrument kind, unit and attributes."
|
|
||||||
|
|
||||||
__slots__ = ("attributes", "buckets", "description", "kind", "unit")
|
|
||||||
|
|
||||||
def __new__(cls, name, kind, unit, description, attributes=(), buckets=None):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.kind = kind
|
|
||||||
self.unit = unit
|
|
||||||
self.description = description
|
|
||||||
self.attributes = tuple(attributes)
|
|
||||||
# Explicit bucket boundaries, for histograms only
|
|
||||||
self.buckets = tuple(buckets) if buckets is not None else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# See Attribute.__reduce__.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"MetricName({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
COUNTER = "Counter"
|
|
||||||
UPDOWN_COUNTER = "UpDownCounter"
|
|
||||||
HISTOGRAM = "Histogram"
|
|
||||||
GAUGE = "Observable gauge"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Attributes -----------------------------------------------------------
|
|
||||||
|
|
||||||
HTTP_REQUEST_METHOD = Attribute(
|
|
||||||
"http.request.method",
|
|
||||||
"The HTTP request method. Methods outside the nine defined by RFC 9110 "
|
|
||||||
"and RFC 5789 are recorded as ``_OTHER``.",
|
|
||||||
)
|
|
||||||
HTTP_RESPONSE_STATUS_CODE = Attribute(
|
|
||||||
"http.response.status_code",
|
|
||||||
"The HTTP response status code. Omitted if no response was started.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
HTTP_ROUTE = Attribute(
|
|
||||||
"http.route",
|
|
||||||
"The regular expression for the matched route, for example "
|
|
||||||
"``/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)(\\.(?P<format>\\w+))?$`` "
|
|
||||||
"for a table page. Use this attribute to group requests by route. "
|
|
||||||
"Omitted when no route matches.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
URL_PATH = Attribute(
|
|
||||||
"url.path",
|
|
||||||
"The URL path, excluding the query string.",
|
|
||||||
)
|
|
||||||
URL_SCHEME = Attribute("url.scheme", "``http`` or ``https``.")
|
|
||||||
SERVER_ADDRESS = Attribute(
|
|
||||||
"server.address",
|
|
||||||
"The ``Host`` header, including any ``:port`` suffix. This value is "
|
|
||||||
"supplied by the client.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
USER_AGENT_ORIGINAL = Attribute(
|
|
||||||
"user_agent.original",
|
|
||||||
"The ``User-Agent`` header, verbatim. Omitted if the client sent none.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
INTERNAL_CLIENT = Attribute(
|
|
||||||
"datasette.internal_client",
|
|
||||||
"``True`` for requests made through ``datasette.client``. Calls made "
|
|
||||||
"inside another request produce a nested ``SERVER`` span. Filter on "
|
|
||||||
"this attribute to exclude internal requests from request counts. "
|
|
||||||
"Omitted for requests received over the network.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ERROR_TYPE = Attribute(
|
|
||||||
"error.type",
|
|
||||||
"The exception class name for a failed operation. On HTTP spans, also "
|
|
||||||
"set to the status code as a string for 5xx responses. A 4xx response "
|
|
||||||
"alone does not set this attribute or an error status.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
|
|
||||||
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
|
|
||||||
OPERATION = Attribute(
|
|
||||||
"datasette.operation",
|
|
||||||
"Whether the operation was a read or a write.",
|
|
||||||
values={"read", "write"},
|
|
||||||
)
|
|
||||||
DB_QUERY_TEXT = Attribute(
|
|
||||||
"db.query.text",
|
|
||||||
"The SQL, truncated to 2048 characters. Bound parameter values are not "
|
|
||||||
"recorded. For callback methods, ``datasette.callback`` is recorded instead.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
CALLBACK = Attribute(
|
|
||||||
"datasette.callback",
|
|
||||||
"The qualified name of the Python callable passed to ``execute_fn()``, "
|
|
||||||
"``execute_write_fn()`` or ``execute_isolated_fn()``, for example "
|
|
||||||
"``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of "
|
|
||||||
"``db.query.text``. Lambdas appear as ``<lambda>``; use a named function "
|
|
||||||
"for a more descriptive span.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
DB_OPERATION_NAME = Attribute(
|
|
||||||
"db.operation.name",
|
|
||||||
"The statement's leading keyword, such as ``SELECT``, ``INSERT`` or "
|
|
||||||
"``CREATE``, if it matches the supported allowlist. Statements beginning "
|
|
||||||
"with a common table expression report ``WITH``. Omitted for unrecognized "
|
|
||||||
"keywords and ``execute_write_script()``.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
PARAM_COUNT = Attribute(
|
|
||||||
"datasette.param_count",
|
|
||||||
"Number of bound parameters. Recorded instead of the values themselves.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
PARAM_SETS = Attribute(
|
|
||||||
"datasette.param_sets",
|
|
||||||
"Number of parameter sets consumed by ``execute_write_many()``. "
|
|
||||||
"The parameter values are not recorded.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
TIME_LIMIT_MS = Attribute(
|
|
||||||
"datasette.time_limit_ms",
|
|
||||||
"Time limit applied to the read query, in milliseconds: "
|
|
||||||
":ref:`setting_sql_time_limit_ms` or a shorter ``custom_time_limit``.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ROWS_RETURNED = Attribute(
|
|
||||||
"datasette.rows_returned",
|
|
||||||
"Number of rows returned by a successful read query.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
TRUNCATED = Attribute(
|
|
||||||
"datasette.truncated",
|
|
||||||
"True if the result was cut short by :ref:`setting_max_returned_rows`.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
INTERRUPTED = Attribute(
|
|
||||||
"datasette.interrupted",
|
|
||||||
"True if the query exceeded its time limit. The span status is set to "
|
|
||||||
"``ERROR`` unless the caller used a ``custom_time_limit`` shorter than "
|
|
||||||
":ref:`setting_sql_time_limit_ms`, in which case the status is left unset.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
SQL_ERROR_SUPPRESSED = Attribute(
|
|
||||||
"datasette.sql_error_suppressed",
|
|
||||||
"True for a non-timeout SQL error with ``log_sql_errors=False``. The "
|
|
||||||
"exception is still raised, but the span status is left unset.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
EXECUTESCRIPT = Attribute(
|
|
||||||
"datasette.executescript",
|
|
||||||
"True for ``execute_write_script()``, which runs multiple statements.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
EXECUTEMANY = Attribute(
|
|
||||||
"datasette.executemany",
|
|
||||||
"True for ``execute_write_many()``, which runs one statement against many "
|
|
||||||
"parameter sets.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ISOLATED_CONNECTION = Attribute(
|
|
||||||
"datasette.isolated_connection",
|
|
||||||
"True if the write ran on its own connection rather than the shared write "
|
|
||||||
"connection.",
|
|
||||||
)
|
|
||||||
TRANSACTION = Attribute(
|
|
||||||
"datasette.transaction",
|
|
||||||
"False for statements such as ``VACUUM`` that cannot run inside a transaction.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Spans ----------------------------------------------------------------
|
|
||||||
|
|
||||||
HTTP_REQUEST = SpanName(
|
|
||||||
"{http.request.method} {http.route}",
|
|
||||||
"One span per HTTP request, containing spans from plugin middleware and "
|
|
||||||
"database operations. Named for the HTTP method and matched route, or "
|
|
||||||
"just the method if no route matches. Incoming ``traceparent`` headers "
|
|
||||||
"are extracted using the global propagator to continue the caller's "
|
|
||||||
"trace. Incoming ``baggage`` is not propagated into plugin or downstream "
|
|
||||||
"context in this release. Set ``OTEL_PROPAGATORS=none`` to disable "
|
|
||||||
"extraction. For public instances, strip trace context headers at your "
|
|
||||||
"proxy if callers should not supply trace context.",
|
|
||||||
(
|
|
||||||
HTTP_REQUEST_METHOD,
|
|
||||||
HTTP_ROUTE,
|
|
||||||
URL_PATH,
|
|
||||||
URL_SCHEME,
|
|
||||||
SERVER_ADDRESS,
|
|
||||||
USER_AGENT_ORIGINAL,
|
|
||||||
HTTP_RESPONSE_STATUS_CODE,
|
|
||||||
ERROR_TYPE,
|
|
||||||
INTERNAL_CLIENT,
|
|
||||||
),
|
|
||||||
dynamic=True,
|
|
||||||
kind=SpanKind.SERVER,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_QUERY = SpanName(
|
|
||||||
"db.query",
|
|
||||||
"A SQL operation, including time spent queued for a worker thread. For "
|
|
||||||
"``block=False`` writes, the span ends after the write is queued. "
|
|
||||||
"Callback methods record ``datasette.callback`` in place of ``db.query.text``.",
|
|
||||||
(
|
|
||||||
DB_SYSTEM,
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_QUERY_TEXT,
|
|
||||||
CALLBACK,
|
|
||||||
DB_OPERATION_NAME,
|
|
||||||
PARAM_COUNT,
|
|
||||||
PARAM_SETS,
|
|
||||||
TIME_LIMIT_MS,
|
|
||||||
ROWS_RETURNED,
|
|
||||||
TRUNCATED,
|
|
||||||
INTERRUPTED,
|
|
||||||
SQL_ERROR_SUPPRESSED,
|
|
||||||
EXECUTESCRIPT,
|
|
||||||
EXECUTEMANY,
|
|
||||||
),
|
|
||||||
kind=SpanKind.CLIENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_QUERY_EXECUTE = SpanName(
|
|
||||||
"db.query.execute",
|
|
||||||
"The read executing inside a SQL worker thread. Child of ``db.query``; the "
|
|
||||||
"gap between the two is time spent waiting for a thread.",
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_WRITE_QUEUE_WAIT = SpanName(
|
|
||||||
"db.write.queue_wait",
|
|
||||||
"Time a write spent waiting in its database's write queue. For "
|
|
||||||
"``block=True``, this is a child of ``db.query``. For ``block=False``, "
|
|
||||||
"it is a root span linked to the span that queued the write, since the "
|
|
||||||
"write can outlive that request.",
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_WRITE_EXECUTE = SpanName(
|
|
||||||
"db.write.execute",
|
|
||||||
"The write executing on the write thread. For ``block=True``, this is "
|
|
||||||
"a child of ``db.query``. For ``block=False``, it is a root span linked "
|
|
||||||
"to the span that queued the write.",
|
|
||||||
(ISOLATED_CONNECTION, TRANSACTION),
|
|
||||||
)
|
|
||||||
|
|
||||||
STARTUP = SpanName(
|
|
||||||
"datasette.startup",
|
|
||||||
"Startup work performed by ``invoke_startup()``, including registration "
|
|
||||||
"hooks, schema catalog updates, saved queries, column type configuration "
|
|
||||||
"and the ``startup`` hook. Runs during instance startup, either before "
|
|
||||||
"serving requests or as part of the first request.",
|
|
||||||
)
|
|
||||||
|
|
||||||
SPANS = (
|
|
||||||
HTTP_REQUEST,
|
|
||||||
DB_QUERY,
|
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
DB_WRITE_EXECUTE,
|
|
||||||
STARTUP,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def span_for(emitted_name, kind=None, spans=None):
|
|
||||||
"""
|
|
||||||
Resolve an emitted span name to its registry entry, or None.
|
|
||||||
|
|
||||||
Exact matches take precedence over `prefix=True` entries, which take
|
|
||||||
precedence over `dynamic=True` entries matched by `kind`.
|
|
||||||
|
|
||||||
`spans` defaults to Datasette's own registry.
|
|
||||||
"""
|
|
||||||
if spans is None:
|
|
||||||
spans = SPANS
|
|
||||||
for span in spans:
|
|
||||||
if span.dynamic:
|
|
||||||
continue
|
|
||||||
if emitted_name == span:
|
|
||||||
return span
|
|
||||||
for span in spans:
|
|
||||||
if span.prefix and emitted_name.startswith(span):
|
|
||||||
return span
|
|
||||||
if kind is not None:
|
|
||||||
for span in spans:
|
|
||||||
if span.dynamic and span.kind == kind:
|
|
||||||
return span
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def metric_for(emitted_name, metrics=None):
|
|
||||||
"""
|
|
||||||
Resolve an emitted metric name to its registry entry, or None.
|
|
||||||
|
|
||||||
`metrics` defaults to Datasette's own registry.
|
|
||||||
"""
|
|
||||||
if metrics is None:
|
|
||||||
metrics = METRICS
|
|
||||||
for metric in metrics:
|
|
||||||
if emitted_name == metric:
|
|
||||||
return metric
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def attribute_allowed(entry, emitted_key):
|
|
||||||
"""
|
|
||||||
Whether `emitted_key` is a registered attribute of `entry`.
|
|
||||||
|
|
||||||
`entry` is a `SpanName` or a `MetricName` - both carry `.attributes`.
|
|
||||||
"""
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
return emitted_key in entry.attributes
|
|
||||||
|
|
||||||
|
|
||||||
def attribute_value_allowed(entry, emitted_key, value):
|
|
||||||
"""
|
|
||||||
Whether `value` is permitted for `emitted_key` on `entry` (a `SpanName`
|
|
||||||
or a `MetricName`).
|
|
||||||
|
|
||||||
Any value is allowed if the attribute does not declare `values=`.
|
|
||||||
"""
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
for attribute in entry.attributes:
|
|
||||||
if attribute == emitted_key:
|
|
||||||
return attribute.values is None or value in attribute.values
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Metrics --------------------------------------------------------------
|
|
||||||
|
|
||||||
# Bucket boundaries in seconds for every duration histogram. OpenTelemetry's
|
|
||||||
# defaults are designed for milliseconds and would put almost every SQLite
|
|
||||||
# query in the first bucket. These are the semantic conventions' recommended
|
|
||||||
# boundaries for db.client.operation.duration, plus 0.0001 and 0.0005 for
|
|
||||||
# fast in-process SQLite queries.
|
|
||||||
DURATION_BUCKETS = (0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10)
|
|
||||||
|
|
||||||
M_OPERATION_DURATION = MetricName(
|
|
||||||
"db.client.operation.duration",
|
|
||||||
HISTOGRAM,
|
|
||||||
"s",
|
|
||||||
"Duration of a SQL operation, including callback-based calls such as "
|
|
||||||
"``execute_fn()``. For ``block=False`` writes, measures enqueue time.",
|
|
||||||
(DB_SYSTEM, DB_NAMESPACE, OPERATION, ERROR_TYPE),
|
|
||||||
buckets=DURATION_BUCKETS,
|
|
||||||
)
|
|
||||||
|
|
||||||
M_WRITE_QUEUE_WAIT = MetricName(
|
|
||||||
"datasette.write.queue_wait",
|
|
||||||
HISTOGRAM,
|
|
||||||
"s",
|
|
||||||
"Time each write waited in its database's write queue.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
buckets=DURATION_BUCKETS,
|
|
||||||
)
|
|
||||||
|
|
||||||
M_QUERIES_INTERRUPTED = MetricName(
|
|
||||||
"datasette.sql.queries.interrupted",
|
|
||||||
COUNTER,
|
|
||||||
"{query}",
|
|
||||||
"Queries cancelled for exceeding :ref:`setting_sql_time_limit_ms`. A "
|
|
||||||
"rising rate can indicate that queries need optimization or a higher "
|
|
||||||
"time limit. Caller-selected timeouts shorter than this limit, such as "
|
|
||||||
"those used for facet suggestion, are excluded.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_THREADS_LIMIT = MetricName(
|
|
||||||
"datasette.sql.threads.limit",
|
|
||||||
GAUGE,
|
|
||||||
"{thread}",
|
|
||||||
"Maximum concurrent read queries, configured by "
|
|
||||||
":ref:`setting_num_sql_threads`. Not reported when ``num_sql_threads`` "
|
|
||||||
"is ``0``.",
|
|
||||||
)
|
|
||||||
|
|
||||||
M_THREADS_QUEUE_DEPTH = MetricName(
|
|
||||||
"datasette.sql.threads.queue_depth",
|
|
||||||
GAUGE,
|
|
||||||
"{query}",
|
|
||||||
"Read queries waiting for a free SQL thread. Sustained values above "
|
|
||||||
"zero indicate a saturated read pool.",
|
|
||||||
)
|
|
||||||
|
|
||||||
M_QUERIES_PENDING = MetricName(
|
|
||||||
"datasette.sql.queries.pending",
|
|
||||||
GAUGE,
|
|
||||||
"{query}",
|
|
||||||
"Read queries submitted to the pool and not yet complete. Sum across "
|
|
||||||
"databases and compare with ``datasette.sql.threads.limit`` to assess "
|
|
||||||
"pool usage.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_WRITE_QUEUE_DEPTH = MetricName(
|
|
||||||
"datasette.write.queue_depth",
|
|
||||||
GAUGE,
|
|
||||||
"{write}",
|
|
||||||
"Writes waiting for a database's single write thread. Increasing "
|
|
||||||
"``num_sql_threads`` does not increase write concurrency. Not reported for "
|
|
||||||
"databases that have never been written to.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_CONNECTIONS_OPEN = MetricName(
|
|
||||||
"datasette.connections.open",
|
|
||||||
GAUGE,
|
|
||||||
"{connection}",
|
|
||||||
"Open SQLite connections managed by Datasette.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
METRICS = (
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
)
|
|
||||||
|
|
@ -1,427 +0,0 @@
|
||||||
"""
|
|
||||||
Pytest helpers for testing OpenTelemetry instrumentation - Datasette's own
|
|
||||||
and any plugin's. Part of Datasette's public plugin API; see the "Telemetry
|
|
||||||
for plugin authors" documentation.
|
|
||||||
|
|
||||||
Usage from a plugin's ``conftest.py``::
|
|
||||||
|
|
||||||
from datasette.telemetry_testing import ( # noqa: F401
|
|
||||||
MetricsCollector,
|
|
||||||
otel_metrics,
|
|
||||||
otel_meter_provider,
|
|
||||||
otel_provider,
|
|
||||||
otel_spans,
|
|
||||||
)
|
|
||||||
|
|
||||||
Tests can then use the ``otel_spans`` and ``otel_metrics`` fixtures. The
|
|
||||||
OpenTelemetry SDK is imported lazily, and the fixtures skip if it is not
|
|
||||||
installed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .telemetry_registry import (
|
|
||||||
attribute_allowed,
|
|
||||||
attribute_value_allowed,
|
|
||||||
metric_for,
|
|
||||||
span_for,
|
|
||||||
)
|
|
||||||
|
|
||||||
_span_exporter = None
|
|
||||||
_metric_reader = None
|
|
||||||
|
|
||||||
|
|
||||||
def install_span_exporter():
|
|
||||||
"""
|
|
||||||
Install a TracerProvider + InMemorySpanExporter once per process and
|
|
||||||
return the exporter, or None when the SDK is not installed.
|
|
||||||
|
|
||||||
Uses `SimpleSpanProcessor` so spans are exported as soon as they end.
|
|
||||||
"""
|
|
||||||
global _span_exporter
|
|
||||||
if _span_exporter is not None:
|
|
||||||
return _span_exporter
|
|
||||||
try:
|
|
||||||
from opentelemetry import trace as otel_trace
|
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
|
||||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
||||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
|
||||||
InMemorySpanExporter,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
exporter = InMemorySpanExporter()
|
|
||||||
provider = TracerProvider()
|
|
||||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
||||||
otel_trace.set_tracer_provider(provider)
|
|
||||||
# set_tracer_provider() is ignored if a provider was already installed,
|
|
||||||
# in which case the fixtures skip
|
|
||||||
if otel_trace.get_tracer_provider() is not provider:
|
|
||||||
return None
|
|
||||||
_span_exporter = exporter
|
|
||||||
return exporter
|
|
||||||
|
|
||||||
|
|
||||||
def install_metric_reader():
|
|
||||||
"""
|
|
||||||
Install a MeterProvider + InMemoryMetricReader once per process and
|
|
||||||
return the reader, or None when the SDK is not installed.
|
|
||||||
|
|
||||||
Uses delta temporality for counters and histograms, so each collection
|
|
||||||
only reports measurements since the previous one.
|
|
||||||
"""
|
|
||||||
global _metric_reader
|
|
||||||
if _metric_reader is not None:
|
|
||||||
return _metric_reader
|
|
||||||
try:
|
|
||||||
from opentelemetry import metrics as otel_metrics_api
|
|
||||||
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
|
|
||||||
from opentelemetry.sdk.metrics.export import (
|
|
||||||
AggregationTemporality,
|
|
||||||
InMemoryMetricReader,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
reader = InMemoryMetricReader(
|
|
||||||
preferred_temporality={
|
|
||||||
Counter: AggregationTemporality.DELTA,
|
|
||||||
Histogram: AggregationTemporality.DELTA,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
provider = MeterProvider(metric_readers=[reader])
|
|
||||||
otel_metrics_api.set_meter_provider(provider)
|
|
||||||
if otel_metrics_api.get_meter_provider() is not provider:
|
|
||||||
return None
|
|
||||||
_metric_reader = reader
|
|
||||||
return reader
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def otel_provider():
|
|
||||||
"Install the span exporter once per test session, before any spans are created."
|
|
||||||
install_span_exporter()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def otel_meter_provider():
|
|
||||||
"Install the metric reader once per test session."
|
|
||||||
install_metric_reader()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def otel_reset():
|
|
||||||
"Clear recorded spans and drain collected metrics after every test."
|
|
||||||
yield
|
|
||||||
if _span_exporter is not None:
|
|
||||||
_span_exporter.clear()
|
|
||||||
if _metric_reader is not None:
|
|
||||||
_metric_reader.get_metrics_data()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def otel_spans():
|
|
||||||
"""
|
|
||||||
The in-memory span exporter, cleared before the test. Call
|
|
||||||
`.get_finished_spans()` to retrieve spans.
|
|
||||||
"""
|
|
||||||
pytest.importorskip("opentelemetry.sdk")
|
|
||||||
exporter = install_span_exporter()
|
|
||||||
if exporter is None:
|
|
||||||
pytest.skip("OpenTelemetry SDK provider was not installed")
|
|
||||||
exporter.clear()
|
|
||||||
yield exporter
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsCollector:
|
|
||||||
"""
|
|
||||||
Wraps an `InMemoryMetricReader`.
|
|
||||||
|
|
||||||
`collect()` runs a collection cycle and stores a snapshot, which
|
|
||||||
`points()` and `point()` then query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, reader):
|
|
||||||
self.reader = reader
|
|
||||||
self.snapshot = {}
|
|
||||||
# (instrumentation scope name, sdk Metric) pairs from the last collect()
|
|
||||||
self.collected = []
|
|
||||||
|
|
||||||
def collect(self):
|
|
||||||
self.snapshot = {}
|
|
||||||
self.collected = []
|
|
||||||
data = self.reader.get_metrics_data()
|
|
||||||
if data is None:
|
|
||||||
return self.snapshot
|
|
||||||
for resource_metrics in data.resource_metrics:
|
|
||||||
for scope_metrics in resource_metrics.scope_metrics:
|
|
||||||
scope_name = scope_metrics.scope.name if scope_metrics.scope else None
|
|
||||||
for metric in scope_metrics.metrics:
|
|
||||||
self.snapshot.setdefault(metric.name, []).extend(
|
|
||||||
metric.data.data_points
|
|
||||||
)
|
|
||||||
self.collected.append((scope_name, metric))
|
|
||||||
return self.snapshot
|
|
||||||
|
|
||||||
def points(self, name, attributes=None):
|
|
||||||
"Data points for `name` whose attributes are a superset of `attributes`."
|
|
||||||
found = []
|
|
||||||
for point in self.snapshot.get(name, []):
|
|
||||||
point_attributes = dict(point.attributes or {})
|
|
||||||
if all(point_attributes.get(k) == v for k, v in (attributes or {}).items()):
|
|
||||||
found.append(point)
|
|
||||||
return found
|
|
||||||
|
|
||||||
def point(self, name, attributes=None):
|
|
||||||
"The single matching data point, asserting there is exactly one."
|
|
||||||
found = self.points(name, attributes)
|
|
||||||
assert len(found) == 1, (
|
|
||||||
f"expected exactly one {name} point matching {attributes}, "
|
|
||||||
f"got {len(found)}: {found}"
|
|
||||||
)
|
|
||||||
return found[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def otel_metrics():
|
|
||||||
"A `MetricsCollector`, drained before the test so counts start from zero."
|
|
||||||
pytest.importorskip("opentelemetry.sdk")
|
|
||||||
reader = install_metric_reader()
|
|
||||||
if reader is None:
|
|
||||||
pytest.skip("OpenTelemetry SDK meter provider was not installed")
|
|
||||||
reader.get_metrics_data()
|
|
||||||
yield MetricsCollector(reader)
|
|
||||||
|
|
||||||
|
|
||||||
def _scoped(finished_spans, scope_name):
|
|
||||||
if scope_name is None:
|
|
||||||
return list(finished_spans)
|
|
||||||
return [
|
|
||||||
span
|
|
||||||
for span in finished_spans
|
|
||||||
if span.instrumentation_scope and span.instrumentation_scope.name == scope_name
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def assert_spans_conform(registry_spans, finished_spans, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every finished span is registered in `registry_spans`, sets only
|
|
||||||
registered attributes and uses allowed attribute values.
|
|
||||||
|
|
||||||
Pass `scope_name` to only check spans from that instrumentation scope.
|
|
||||||
"""
|
|
||||||
problems = []
|
|
||||||
for span in _scoped(finished_spans, scope_name):
|
|
||||||
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
|
|
||||||
if entry is None:
|
|
||||||
problems.append(f"unregistered span: {span.name!r}")
|
|
||||||
continue
|
|
||||||
for key, value in (span.attributes or {}).items():
|
|
||||||
if not attribute_allowed(entry, str(key)):
|
|
||||||
problems.append(f"{span.name}: unregistered attribute {key!r}")
|
|
||||||
elif not attribute_value_allowed(entry, str(key), value):
|
|
||||||
problems.append(
|
|
||||||
f"{span.name}: {key}={value!r} not in the declared enum"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_spans_covered(registry_spans, finished_spans, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every entry in `registry_spans` was emitted at least once, with
|
|
||||||
each of its attributes that is not `optional=True`.
|
|
||||||
"""
|
|
||||||
spans = _scoped(finished_spans, scope_name)
|
|
||||||
seen_attributes = {}
|
|
||||||
for span in spans:
|
|
||||||
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
|
|
||||||
if entry is not None:
|
|
||||||
seen = seen_attributes.setdefault(str(entry), set())
|
|
||||||
seen.update(str(key) for key in (span.attributes or {}))
|
|
||||||
problems = []
|
|
||||||
for entry in registry_spans:
|
|
||||||
if str(entry) not in seen_attributes:
|
|
||||||
problems.append(f"registered span never emitted: {entry!r}")
|
|
||||||
continue
|
|
||||||
required = {
|
|
||||||
str(attribute) for attribute in entry.attributes if not attribute.optional
|
|
||||||
}
|
|
||||||
missing = required - seen_attributes[str(entry)]
|
|
||||||
if missing:
|
|
||||||
problems.append(
|
|
||||||
f"{entry}: registered attributes never emitted: {sorted(missing)}"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
# Registry instrument kinds mapped to the SDK data type collected for them.
|
|
||||||
# Both counter kinds collect as Sum, distinguished by is_monotonic.
|
|
||||||
_KIND_TO_DATA_TYPE = {
|
|
||||||
"Counter": "Sum",
|
|
||||||
"UpDownCounter": "Sum",
|
|
||||||
"Histogram": "Histogram",
|
|
||||||
"Observable gauge": "Gauge",
|
|
||||||
}
|
|
||||||
_KIND_IS_MONOTONIC = {"Counter": True, "UpDownCounter": False}
|
|
||||||
|
|
||||||
|
|
||||||
def _scoped_metrics(collector, scope_name):
|
|
||||||
for scope, metric in collector.collected:
|
|
||||||
if scope_name is None or scope == scope_name:
|
|
||||||
yield metric
|
|
||||||
|
|
||||||
|
|
||||||
def assert_metrics_conform(registry_metrics, collector, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every metric in the collector's last `collect()` is registered in
|
|
||||||
`registry_metrics` with a matching instrument kind and unit, sets only
|
|
||||||
registered attributes and uses allowed attribute values.
|
|
||||||
|
|
||||||
Pass `scope_name` to only check metrics from that instrumentation scope.
|
|
||||||
"""
|
|
||||||
problems = set()
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
entry = metric_for(metric.name, metrics=registry_metrics)
|
|
||||||
if entry is None:
|
|
||||||
problems.add(f"unregistered metric: {metric.name!r}")
|
|
||||||
continue
|
|
||||||
expected_data_type = _KIND_TO_DATA_TYPE.get(entry.kind)
|
|
||||||
actual_data_type = type(metric.data).__name__
|
|
||||||
if expected_data_type is not None and actual_data_type != expected_data_type:
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: registry declares {entry.kind}, "
|
|
||||||
f"SDK collected {actual_data_type}"
|
|
||||||
)
|
|
||||||
expected_monotonic = _KIND_IS_MONOTONIC.get(entry.kind)
|
|
||||||
actual_monotonic = getattr(metric.data, "is_monotonic", None)
|
|
||||||
if (
|
|
||||||
expected_monotonic is not None
|
|
||||||
and actual_monotonic is not None
|
|
||||||
and actual_monotonic != expected_monotonic
|
|
||||||
):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: registry declares {entry.kind}, but the "
|
|
||||||
f"collected Sum is_monotonic={actual_monotonic}"
|
|
||||||
)
|
|
||||||
if (metric.unit or "") != (entry.unit or ""):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: instrument unit {metric.unit!r} != "
|
|
||||||
f"registry unit {entry.unit!r}"
|
|
||||||
)
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
for key, value in dict(point.attributes or {}).items():
|
|
||||||
if not attribute_allowed(entry, str(key)):
|
|
||||||
problems.add(f"{metric.name}: unregistered attribute {key!r}")
|
|
||||||
elif not attribute_value_allowed(entry, str(key), value):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: {key}={value!r} not in the declared enum"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(sorted(problems))
|
|
||||||
|
|
||||||
|
|
||||||
def assert_metrics_covered(registry_metrics, collector, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every entry in `registry_metrics` was collected at least once,
|
|
||||||
with each of its attributes that is not `optional=True`.
|
|
||||||
|
|
||||||
Call `collect()` once after the workload and before this check.
|
|
||||||
"""
|
|
||||||
seen_attributes = {}
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
entry = metric_for(metric.name, metrics=registry_metrics)
|
|
||||||
if entry is None:
|
|
||||||
continue
|
|
||||||
seen = seen_attributes.setdefault(str(entry), set())
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
seen.update(str(key) for key in dict(point.attributes or {}))
|
|
||||||
problems = []
|
|
||||||
for entry in registry_metrics:
|
|
||||||
if str(entry) not in seen_attributes:
|
|
||||||
problems.append(f"registered metric never collected: {entry!r}")
|
|
||||||
continue
|
|
||||||
required = {
|
|
||||||
str(attribute) for attribute in entry.attributes if not attribute.optional
|
|
||||||
}
|
|
||||||
missing = required - seen_attributes[str(entry)]
|
|
||||||
if missing:
|
|
||||||
problems.append(
|
|
||||||
f"{entry}: registered attributes never collected: {sorted(missing)}"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_no_forbidden_values(
|
|
||||||
forbidden, finished_spans=None, collector=None, scope_name=None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Assert that none of the `forbidden` strings appear anywhere in the
|
|
||||||
emitted telemetry: span names, span attribute values, span event names
|
|
||||||
and attributes, span status descriptions, or metric point attributes.
|
|
||||||
|
|
||||||
Use fake private values such as tokens or email addresses in your test
|
|
||||||
workload, then check that they were not recorded:
|
|
||||||
|
|
||||||
FORBIDDEN = {"secret-token-123", "alice@example.com"}
|
|
||||||
run_workload_using_those_values()
|
|
||||||
assert_no_forbidden_values(
|
|
||||||
FORBIDDEN,
|
|
||||||
finished_spans=otel_spans.get_finished_spans(),
|
|
||||||
collector=otel_metrics,
|
|
||||||
)
|
|
||||||
|
|
||||||
Matches substrings of each value's string form. Empty strings in
|
|
||||||
`forbidden` are ignored. Leave `scope_name` unset to also check
|
|
||||||
Datasette's own telemetry.
|
|
||||||
"""
|
|
||||||
needles = [needle for needle in forbidden if needle]
|
|
||||||
leaks = set()
|
|
||||||
|
|
||||||
def check(value, where):
|
|
||||||
text = str(value)
|
|
||||||
for needle in needles:
|
|
||||||
if needle in text:
|
|
||||||
leaks.add(f"{where} contains {needle!r}")
|
|
||||||
|
|
||||||
if finished_spans is not None:
|
|
||||||
for span in _scoped(finished_spans, scope_name):
|
|
||||||
check(span.name, f"span name {str(span.name)!r}")
|
|
||||||
for key, value in (span.attributes or {}).items():
|
|
||||||
check(value, f"{span.name} attribute {key}")
|
|
||||||
for event in span.events or ():
|
|
||||||
check(event.name, f"{span.name} event name")
|
|
||||||
for key, value in (event.attributes or {}).items():
|
|
||||||
check(value, f"{span.name} event {event.name} attribute {key}")
|
|
||||||
if span.status is not None and span.status.description:
|
|
||||||
check(span.status.description, f"{span.name} status description")
|
|
||||||
if collector is not None:
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
for key, value in dict(point.attributes or {}).items():
|
|
||||||
check(value, f"metric {metric.name} attribute {key}")
|
|
||||||
assert not leaks, "forbidden values leaked into telemetry:\n" + "\n".join(
|
|
||||||
sorted(leaks)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_package_never_imports_sdk(*module_names):
|
|
||||||
"""
|
|
||||||
Import the named modules in a fresh interpreter and assert none of them
|
|
||||||
imported `opentelemetry.sdk`.
|
|
||||||
|
|
||||||
Run the test that calls this early in your suite: on macOS with CPython
|
|
||||||
3.13, starting a subprocess from a process with many threads can crash.
|
|
||||||
"""
|
|
||||||
imports = "; ".join(f"import {name}" for name in module_names)
|
|
||||||
code = (
|
|
||||||
f"import sys; {imports}; "
|
|
||||||
"print([m for m in sys.modules if m.startswith('opentelemetry.sdk')])"
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-c", code], capture_output=True, text=True, check=True
|
|
||||||
)
|
|
||||||
assert result.stdout.strip() == "[]", (
|
|
||||||
f"importing {module_names} pulled in the OpenTelemetry SDK: "
|
|
||||||
f"{result.stdout.strip()}"
|
|
||||||
)
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}API Explorer{% endblock %}
|
{% block title %}API Explorer{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -125,7 +126,7 @@ getForm.addEventListener("submit", (ev) => {
|
||||||
document.getElementById('response-status').textContent = response.status;
|
document.getElementById('response-status').textContent = response.status;
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
|
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||||
errorList.style.display = 'none';
|
errorList.style.display = 'none';
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
alert(error);
|
alert(error);
|
||||||
|
|
@ -173,7 +174,7 @@ postForm.addEventListener("submit", (ev) => {
|
||||||
} else {
|
} else {
|
||||||
errorList.style.display = 'none';
|
errorList.style.display = 'none';
|
||||||
}
|
}
|
||||||
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
|
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
alert("Error: " + err);
|
alert("Error: " + err);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||||
<script src="{{ static('modal.js') }}" defer></script>
|
|
||||||
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
||||||
{% for url in extra_js_urls %}
|
{% for url in extra_js_urls %}
|
||||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}Allowed Resources{% endblock %}
|
{% block title %}Allowed Resources{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -197,7 +198,7 @@ function displayResults(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update raw JSON
|
// Update raw JSON
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
|
|
@ -207,7 +208,7 @@ function displayError(data) {
|
||||||
|
|
||||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
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
|
// Disable child input if parent is empty
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}Explain a permission decision{% endblock %}
|
{% block title %}Explain a permission decision{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -237,7 +238,7 @@ function displayResult(data) {
|
||||||
displayRules(data.explanation);
|
displayRules(data.explanation);
|
||||||
displayRestrictions(data.explanation.restrictions);
|
displayRestrictions(data.explanation.restrictions);
|
||||||
displayRequirements(data.explanation.required_actions);
|
displayRequirements(data.explanation.required_actions);
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRules(explanation) {
|
function displayRules(explanation) {
|
||||||
|
|
@ -297,7 +298,7 @@ function displayError(data) {
|
||||||
document.getElementById('matching-rules').innerHTML = '';
|
document.getElementById('matching-rules').innerHTML = '';
|
||||||
document.getElementById('restrictions-section').style.display = 'none';
|
document.getElementById('restrictions-section').style.display = 'none';
|
||||||
document.getElementById('requirements-section').style.display = 'none';
|
document.getElementById('requirements-section').style.display = 'none';
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', event => {
|
form.addEventListener('submit', event => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}Permission Rules{% endblock %}
|
{% block title %}Permission Rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -184,7 +185,7 @@ function displayResults(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update raw JSON
|
// Update raw JSON
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
|
|
@ -194,7 +195,7 @@ function displayError(data) {
|
||||||
|
|
||||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@
|
||||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{% if database.views_count -%}
|
{% if database.views_count -%}
|
||||||
, {{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
|
{% if database.tables_count or database.hidden_tables_count %}, {% endif -%}
|
||||||
|
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
|
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}{{ "{:,}".format(count - 1) }}+ rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
|
|
@ -47,12 +47,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if count or human_description_en %}
|
{% if count or human_description_en %}
|
||||||
<h3 class="table-summary">
|
<h3>
|
||||||
{% if count_truncated %}<span class="table-count" aria-live="polite">{{ "{:,}".format(count - 1) }}+ rows</span>
|
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||||
<button type="button" class="count-all" data-count-url="{{ urls.table(database, table) }}/-/count">count all</button>
|
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||||
<span class="count-error" role="alert"></span>
|
|
||||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||||
{% if human_description_en %}<span class="table-summary-description">{{ human_description_en }}</span>{% endif %}
|
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,7 @@ def escape_css_string(s):
|
||||||
|
|
||||||
|
|
||||||
def escape_sqlite(s):
|
def escape_sqlite(s):
|
||||||
if _boring_keyword_re.fullmatch(s) and (s.lower() not in reserved_words):
|
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||||
return s
|
return s
|
||||||
return '"{}"'.format(s.replace('"', '""'))
|
return '"{}"'.format(s.replace('"', '""'))
|
||||||
|
|
||||||
|
|
@ -820,8 +820,7 @@ def detect_spatialite(conn):
|
||||||
|
|
||||||
def detect_fts(conn, table):
|
def detect_fts(conn, table):
|
||||||
"""Detect if table has a corresponding FTS virtual table and return it"""
|
"""Detect if table has a corresponding FTS virtual table and return it"""
|
||||||
sql, params = detect_fts_sql(table)
|
rows = conn.execute(detect_fts_sql(table)).fetchall()
|
||||||
rows = conn.execute(sql, params).fetchall()
|
|
||||||
if len(rows) == 0:
|
if len(rows) == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
|
|
@ -829,26 +828,18 @@ def detect_fts(conn, table):
|
||||||
|
|
||||||
|
|
||||||
def detect_fts_sql(table):
|
def detect_fts_sql(table):
|
||||||
escaped_table = table.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return r"""
|
||||||
return (
|
|
||||||
r"""
|
|
||||||
select name from sqlite_master
|
select name from sqlite_master
|
||||||
where rootpage = 0
|
where rootpage = 0
|
||||||
and (
|
and (
|
||||||
sql like :fts_double_quoted escape char(92)
|
sql like '%VIRTUAL TABLE%USING FTS%content="{table}"%'
|
||||||
or sql like :fts_bracket_quoted escape char(92)
|
or sql like '%VIRTUAL TABLE%USING FTS%content=[{table}]%'
|
||||||
or (
|
or (
|
||||||
tbl_name = :table
|
tbl_name = "{table}"
|
||||||
and sql like '%VIRTUAL TABLE%USING FTS%'
|
and sql like '%VIRTUAL TABLE%USING FTS%'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""",
|
""".format(table=table.replace("'", "''"))
|
||||||
{
|
|
||||||
"fts_double_quoted": f'%VIRTUAL TABLE%USING FTS%content="{escaped_table}"%',
|
|
||||||
"fts_bracket_quoted": f"%VIRTUAL TABLE%USING FTS%content=[{escaped_table}]%",
|
|
||||||
"table": table,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_json1(conn=None):
|
def detect_json1(conn=None):
|
||||||
|
|
@ -1566,13 +1557,7 @@ async def row_sql_params_pks(db, table, pk_values):
|
||||||
if use_rowid:
|
if use_rowid:
|
||||||
select = "rowid, *"
|
select = "rowid, *"
|
||||||
pks = ["rowid"]
|
pks = ["rowid"]
|
||||||
wheres = []
|
wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]
|
||||||
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}")
|
|
||||||
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
|
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
|
||||||
params = {}
|
params = {}
|
||||||
for i, pk_value in enumerate(pk_values):
|
for i, pk_value in enumerate(pk_values):
|
||||||
|
|
@ -1744,7 +1729,7 @@ def redact_keys(original: dict, key_patterns: Iterable) -> dict:
|
||||||
return {
|
return {
|
||||||
k: (
|
k: (
|
||||||
redact(v)
|
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 "***"
|
else "***"
|
||||||
)
|
)
|
||||||
for k, v in data.items()
|
for k, v in data.items()
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,6 @@ from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
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(
|
async def build_allowed_resources_sql(
|
||||||
|
|
@ -158,7 +149,6 @@ async def _build_single_action_sql(
|
||||||
raise ValueError(f"Unknown action: {action}")
|
raise ValueError(f"Unknown action: {action}")
|
||||||
|
|
||||||
# Get base resources SQL from the resource class
|
# Get base resources SQL from the resource class
|
||||||
child_collation = _child_collation(action_obj)
|
|
||||||
base_resources_sql = await action_obj.resource_class.resources_sql(
|
base_resources_sql = await action_obj.resource_class.resources_sql(
|
||||||
datasette, actor=actor
|
datasette, actor=actor
|
||||||
)
|
)
|
||||||
|
|
@ -195,7 +185,7 @@ async def _build_single_action_sql(
|
||||||
if permission_sql.sql is None:
|
if permission_sql.sql is None:
|
||||||
continue
|
continue
|
||||||
rule_sqls.append(f"""
|
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}
|
{permission_sql.sql}
|
||||||
)
|
)
|
||||||
""".strip())
|
""".strip())
|
||||||
|
|
@ -309,9 +299,9 @@ async def _build_single_action_sql(
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
["anon_child_agg AS ("]
|
["anon_child_agg AS ("]
|
||||||
+ _anon_agg(
|
+ _anon_agg(
|
||||||
f"parent, child COLLATE {child_collation} AS child,",
|
"parent, child,",
|
||||||
"parent IS NOT NULL AND child IS NOT NULL",
|
"parent IS NOT NULL AND child IS NOT NULL",
|
||||||
f"parent, child COLLATE {child_collation}",
|
"parent, child",
|
||||||
)
|
)
|
||||||
+ ["),", "anon_parent_agg AS ("]
|
+ ["),", "anon_parent_agg AS ("]
|
||||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||||
|
|
@ -392,8 +382,7 @@ async def _build_single_action_sql(
|
||||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||||
# with UNION ALL inside the restriction SQL statements
|
# with UNION ALL inside the restriction SQL statements
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
|
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||||
for sql in restriction_sqls
|
|
||||||
)
|
)
|
||||||
# Decompose by NULL-pattern so the final filter can use pure-equality
|
# Decompose by NULL-pattern so the final filter can use pure-equality
|
||||||
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
||||||
|
|
@ -491,7 +480,6 @@ async def build_permission_rules_sql(
|
||||||
union_parts = []
|
union_parts = []
|
||||||
all_params = {}
|
all_params = {}
|
||||||
restriction_sqls = []
|
restriction_sqls = []
|
||||||
child_collation = _child_collation(action_obj)
|
|
||||||
|
|
||||||
for permission_sql in permission_sqls:
|
for permission_sql in permission_sqls:
|
||||||
all_params.update(permission_sql.params or {})
|
all_params.update(permission_sql.params or {})
|
||||||
|
|
@ -505,7 +493,7 @@ async def build_permission_rules_sql(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
union_parts.append(f"""
|
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}
|
{permission_sql.sql}
|
||||||
)
|
)
|
||||||
""".strip())
|
""".strip())
|
||||||
|
|
@ -576,7 +564,6 @@ async def check_permissions_for_actions(
|
||||||
verdicts = {}
|
verdicts = {}
|
||||||
|
|
||||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
||||||
child_collation = _child_collation(datasette.actions[action])
|
|
||||||
prefix = f"a{i}_"
|
prefix = f"a{i}_"
|
||||||
rule_parts = []
|
rule_parts = []
|
||||||
restriction_parts = []
|
restriction_parts = []
|
||||||
|
|
@ -602,7 +589,7 @@ async def check_permissions_for_actions(
|
||||||
if sql is None:
|
if sql is None:
|
||||||
continue
|
continue
|
||||||
rule_parts.append(
|
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:
|
if not rule_parts:
|
||||||
|
|
@ -636,8 +623,7 @@ async def check_permissions_for_actions(
|
||||||
if restriction_parts:
|
if restriction_parts:
|
||||||
# Database-level restrictions (parent, NULL) match all children
|
# Database-level restrictions (parent, NULL) match all children
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
|
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
||||||
for sql in restriction_parts
|
|
||||||
)
|
)
|
||||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
||||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
||||||
|
|
@ -784,7 +770,6 @@ async def _explain_single_action(
|
||||||
db = datasette.get_internal_database()
|
db = datasette.get_internal_database()
|
||||||
matched_rules = []
|
matched_rules = []
|
||||||
restrictions = []
|
restrictions = []
|
||||||
child_collation = _child_collation(datasette.actions[action])
|
|
||||||
|
|
||||||
for permission_sql in permission_sqls:
|
for permission_sql in permission_sqls:
|
||||||
params = dict(permission_sql.params or {})
|
params = dict(permission_sql.params or {})
|
||||||
|
|
@ -799,7 +784,7 @@ async def _explain_single_action(
|
||||||
SELECT parent, child, allow, reason
|
SELECT parent, child, allow, reason
|
||||||
FROM ({permission_sql.sql}) AS permission_rules
|
FROM ({permission_sql.sql}) AS permission_rules
|
||||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||||
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
|
AND (child IS NULL OR child = :{child_param})
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
@ -826,7 +811,7 @@ async def _explain_single_action(
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
||||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||||
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
|
AND (child IS NULL OR child = :{child_param})
|
||||||
) AS resource_is_in_allowlist
|
) AS resource_is_in_allowlist
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from http.cookies import Morsel, SimpleCookie
|
from http.cookies import Morsel, SimpleCookie
|
||||||
|
|
@ -83,19 +82,6 @@ SAMESITE_VALUES = ("strict", "lax", "none")
|
||||||
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
class _RequestHeaders(dict):
|
|
||||||
"""Incoming headers with lowercase keys and case-insensitive lookups."""
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return super().__getitem__(key.lower())
|
|
||||||
|
|
||||||
def get(self, key, default=None):
|
|
||||||
return super().get(key.lower(), default)
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
return super().__contains__(key.lower())
|
|
||||||
|
|
||||||
|
|
||||||
class Request:
|
class Request:
|
||||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||||
self.scope = scope
|
self.scope = scope
|
||||||
|
|
@ -125,10 +111,10 @@ class Request:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def headers(self):
|
def headers(self):
|
||||||
return _RequestHeaders(
|
return {
|
||||||
(k.decode("latin-1").lower(), v.decode("latin-1"))
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||||
for k, v in self.scope.get("headers") or []
|
for k, v in self.scope.get("headers") or []
|
||||||
)
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
|
|
@ -314,24 +300,12 @@ class AsgiLifespan:
|
||||||
while True:
|
while True:
|
||||||
message = await receive()
|
message = await receive()
|
||||||
if message["type"] == "lifespan.startup":
|
if message["type"] == "lifespan.startup":
|
||||||
try:
|
|
||||||
for fn in self.on_startup:
|
for fn in self.on_startup:
|
||||||
await fn()
|
await fn()
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.startup.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.startup.complete"})
|
await send({"type": "lifespan.startup.complete"})
|
||||||
elif message["type"] == "lifespan.shutdown":
|
elif message["type"] == "lifespan.shutdown":
|
||||||
try:
|
|
||||||
for fn in self.on_shutdown:
|
for fn in self.on_shutdown:
|
||||||
await fn()
|
await fn()
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.shutdown.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.shutdown.complete"})
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
|
@ -511,8 +485,6 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
await asgi_send_html(send, "404: File not found", 404)
|
await asgi_send_html(send, "404: File not found", 404)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only the actual static-file handler can bypass dynamic response privacy.
|
|
||||||
inner_static._datasette_static = True
|
|
||||||
return inner_static
|
return inner_static
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -652,23 +624,10 @@ class AsgiRunOnFirstRequest:
|
||||||
self.asgi = asgi
|
self.asgi = asgi
|
||||||
self.on_startup = on_startup
|
self.on_startup = on_startup
|
||||||
self._started = False
|
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):
|
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:
|
if not self._started:
|
||||||
|
self._started = True
|
||||||
for hook in self.on_startup:
|
for hook in self.on_startup:
|
||||||
await hook()
|
await hook()
|
||||||
self._started = True
|
|
||||||
return await self.asgi(scope, receive, send)
|
return await self.asgi(scope, receive, send)
|
||||||
|
|
|
||||||
|
|
@ -358,13 +358,6 @@ class MultipartParser:
|
||||||
self.buffer.extend(chunk)
|
self.buffer.extend(chunk)
|
||||||
self._process()
|
self._process()
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Discard completed uploads and any file still being received."""
|
|
||||||
if self.current_file is not None:
|
|
||||||
self.current_file.close()
|
|
||||||
self.current_file = None
|
|
||||||
self.form_data.close()
|
|
||||||
|
|
||||||
def _process(self) -> None:
|
def _process(self) -> None:
|
||||||
"""Process buffered data."""
|
"""Process buffered data."""
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -584,9 +577,6 @@ class MultipartParser:
|
||||||
def _finish_part(self) -> None:
|
def _finish_part(self) -> None:
|
||||||
"""Finalize current part and add to form data."""
|
"""Finalize current part and add to form data."""
|
||||||
if self.current_name is None:
|
if self.current_name is None:
|
||||||
if self.current_file is not None:
|
|
||||||
self.current_file.close()
|
|
||||||
self.current_file = None
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.current_filename is not None:
|
if self.current_filename is not None:
|
||||||
|
|
@ -732,29 +722,12 @@ async def parse_form_data(
|
||||||
batch_target = 64 * 1024
|
batch_target = 64 * 1024
|
||||||
batch = bytearray()
|
batch = bytearray()
|
||||||
|
|
||||||
async def run_parser(fn, *args):
|
|
||||||
# Cancellation must not close files while a worker is using them.
|
|
||||||
task = asyncio.create_task(asyncio.to_thread(fn, *args))
|
|
||||||
try:
|
|
||||||
return await asyncio.shield(task)
|
|
||||||
except asyncio.CancelledError as cancelled:
|
|
||||||
try:
|
|
||||||
while not task.done():
|
|
||||||
try:
|
|
||||||
await asyncio.shield(task)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
continue
|
|
||||||
task.result()
|
|
||||||
finally:
|
|
||||||
raise cancelled
|
|
||||||
|
|
||||||
async def flush_batch() -> None:
|
async def flush_batch() -> None:
|
||||||
if batch:
|
if batch:
|
||||||
data = bytes(batch)
|
data = bytes(batch)
|
||||||
batch.clear()
|
batch.clear()
|
||||||
await run_parser(parser.feed, data)
|
await asyncio.to_thread(parser.feed, data)
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
while True:
|
||||||
message = await receive()
|
message = await receive()
|
||||||
message_type = message.get("type")
|
message_type = message.get("type")
|
||||||
|
|
@ -771,11 +744,7 @@ async def parse_form_data(
|
||||||
break
|
break
|
||||||
|
|
||||||
await flush_batch()
|
await flush_batch()
|
||||||
return await run_parser(parser.finalize)
|
return await asyncio.to_thread(parser.finalize)
|
||||||
except BaseException:
|
|
||||||
# No FormData is returned to the caller to take ownership on failure.
|
|
||||||
await asyncio.to_thread(parser.close)
|
|
||||||
raise
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise MultipartParseError(
|
raise MultipartParseError(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from datasette.utils import escape_sqlite
|
|
||||||
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
|
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
|
||||||
|
|
||||||
SQLOperation = Literal[
|
SQLOperation = Literal[
|
||||||
|
|
@ -197,16 +195,6 @@ def _allow_authorizer_action(*args):
|
||||||
return sqlite3.SQLITE_OK
|
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(
|
def analyze_sql_tables(
|
||||||
conn,
|
conn,
|
||||||
sql: str,
|
sql: str,
|
||||||
|
|
@ -220,9 +208,7 @@ def analyze_sql_tables(
|
||||||
|
|
||||||
This function is synchronous and connection-based. It temporarily installs a
|
This function is synchronous and connection-based. It temporarily installs a
|
||||||
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
|
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
|
||||||
callbacks observed while SQLite compiles the statement. ``CREATE VIEW`` is
|
callbacks observed while SQLite compiles the statement.
|
||||||
additionally executed inside a rolled-back savepoint so its source-table reads
|
|
||||||
can be discovered by analyzing a query against the temporary view.
|
|
||||||
"""
|
"""
|
||||||
operations: dict[OperationKey, set[str]] = {}
|
operations: dict[OperationKey, set[str]] = {}
|
||||||
|
|
||||||
|
|
@ -495,7 +481,7 @@ def analyze_sql_tables(
|
||||||
conn, key.table, schema=key.sqlite_schema
|
conn, key.table, schema=key.sqlite_schema
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_disable_authorizer(conn)
|
conn.set_authorizer(None)
|
||||||
|
|
||||||
has_schema_operation = any(
|
has_schema_operation = any(
|
||||||
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
|
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
|
||||||
|
|
@ -546,7 +532,7 @@ def analyze_sql_tables(
|
||||||
return None
|
return None
|
||||||
return table_kind_cache[(key.sqlite_schema, key.table)]
|
return table_kind_cache[(key.sqlite_schema, key.table)]
|
||||||
|
|
||||||
analysis = SQLAnalysis(
|
return SQLAnalysis(
|
||||||
operations=tuple(
|
operations=tuple(
|
||||||
Operation(
|
Operation(
|
||||||
operation=key.operation,
|
operation=key.operation,
|
||||||
|
|
@ -563,58 +549,3 @@ def analyze_sql_tables(
|
||||||
for key, columns in operations.items()
|
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,8 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
|
||||||
_cached_sqlite_version = None
|
_cached_sqlite_version = None
|
||||||
_cached_supports_returning = None
|
_cached_supports_returning = None
|
||||||
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
||||||
_SQLITE_IDENTIFIER_RE = (
|
|
||||||
r"""(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`(?:[^`]|``)*`|\[[^\]]*\]|[^\s.()'"`\[\]]+)"""
|
|
||||||
)
|
|
||||||
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
||||||
r"^\s*CREATE\s+VIRTUAL\s+TABLE\b\s*(?:IF\s+NOT\s+EXISTS\s+)?"
|
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
|
||||||
+ _SQLITE_IDENTIFIER_RE
|
|
||||||
+ r"(?:\s*\.\s*"
|
|
||||||
+ _SQLITE_IDENTIFIER_RE
|
|
||||||
+ r")?\s*\bUSING\b\s*("
|
|
||||||
+ _SQLITE_IDENTIFIER_RE
|
|
||||||
+ r")",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
||||||
|
|
@ -92,53 +83,19 @@ def sqlite_table_type(
|
||||||
) -> SQLiteTableType | None:
|
) -> SQLiteTableType | None:
|
||||||
if supports_table_list():
|
if supports_table_list():
|
||||||
try:
|
try:
|
||||||
# Use the "PRAGMA table_list" statement form rather than the
|
query = "select type from pragma_table_list where name = ?"
|
||||||
# pragma_table_list(...) table-valued function. The
|
params: tuple[str, ...] = (table,)
|
||||||
# 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.
|
|
||||||
if schema is not None:
|
if schema is not None:
|
||||||
query = f"PRAGMA {_quote_identifier(schema)}.table_list"
|
query += " and schema = ?"
|
||||||
else:
|
params = (table, schema)
|
||||||
query = "PRAGMA table_list"
|
row = conn.execute(query, params).fetchone()
|
||||||
cursor = conn.execute(query)
|
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
|
||||||
columns = [description[0] for description in cursor.description]
|
return row[0]
|
||||||
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
|
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
pass
|
pass
|
||||||
return _sqlite_table_type_from_schema(conn, table, schema=schema)
|
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]:
|
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
|
|
@ -161,63 +118,6 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
|
||||||
return sorted(hidden_tables) + content_fts_tables
|
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(
|
def _sqlite_table_type_from_schema(
|
||||||
conn,
|
conn,
|
||||||
table: str,
|
table: str,
|
||||||
|
|
@ -284,151 +184,10 @@ def _quote_identifier(value: str) -> str:
|
||||||
def _virtual_table_module(sql: str | None) -> str | None:
|
def _virtual_table_module(sql: str | None) -> str | None:
|
||||||
if not sql:
|
if not sql:
|
||||||
return None
|
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)
|
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
||||||
if match is None:
|
if match is None:
|
||||||
return None
|
return None
|
||||||
open_paren = sql.find("(", match.end())
|
return match.group(1).strip("\"'[]`").lower()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from urllib.parse import urlencode
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
# These wrapper classes pre-date the introduction of
|
# 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
|
# be removed if the Datasette tests are modified to
|
||||||
# call datasette.client directly.
|
# call datasette.client directly.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a41"
|
__version__ = "1.0a38"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class DatasetteError(Exception):
|
||||||
self.error_dict = error_dict or {}
|
self.error_dict = error_dict or {}
|
||||||
self.status = status
|
self.status = status
|
||||||
self.message_is_html = message_is_html
|
self.message_is_html = message_is_html
|
||||||
# Plain text used for JSON and CSV error responses when message is HTML
|
# Plain text used for JSON error responses when message is HTML
|
||||||
self.plain_message = plain_message
|
self.plain_message = plain_message
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,7 @@ from datasette.write_sql import QueryWriteRejected
|
||||||
|
|
||||||
from . import Context
|
from . import Context
|
||||||
from .base import DatasetteError, View, stream_csv
|
from .base import DatasetteError, View, stream_csv
|
||||||
from .query_helpers import (
|
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
||||||
_block_framing,
|
|
||||||
_ensure_stored_query_execution_permissions,
|
|
||||||
_table_columns,
|
|
||||||
)
|
|
||||||
from .table_create_alter import _create_table_ui_context
|
from .table_create_alter import _create_table_ui_context
|
||||||
from .table_extras import (
|
from .table_extras import (
|
||||||
QueryExtraContext,
|
QueryExtraContext,
|
||||||
|
|
@ -861,8 +857,7 @@ class QueryView(View):
|
||||||
raise DatasetteError("?sql= is required", status=400)
|
raise DatasetteError("?sql= is required", status=400)
|
||||||
|
|
||||||
async def fetch_data_for_csv(request, _next=None):
|
async def fetch_data_for_csv(request, _next=None):
|
||||||
# Reuse the trusted magic parameter values prepared above.
|
results = await db.execute(sql, params, truncate=True)
|
||||||
results = await db.execute(sql, params_for_query, truncate=True)
|
|
||||||
data = {"rows": results.rows, "columns": results.columns}
|
data = {"rows": results.rows, "columns": results.columns}
|
||||||
return data, None, None
|
return data, None, None
|
||||||
|
|
||||||
|
|
@ -1145,8 +1140,6 @@ class QueryView(View):
|
||||||
assert False, f"Invalid format: {format_}"
|
assert False, f"Invalid format: {format_}"
|
||||||
if datasette.cors:
|
if datasette.cors:
|
||||||
add_cors_headers(r.headers)
|
add_cors_headers(r.headers)
|
||||||
if stored_query_write and format_ == "html":
|
|
||||||
_block_framing(r)
|
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from datasette.database import QueryInterrupted
|
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
@ -385,7 +384,7 @@ class ExecuteWriteView(BaseView):
|
||||||
try:
|
try:
|
||||||
execute_write_kwargs = {"request": request}
|
execute_write_kwargs = {"request": request}
|
||||||
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
|
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
|
||||||
except (QueryInterrupted, sqlite3.DatabaseError) as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
message = str(ex)
|
message = str(ex)
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _block_framing(Response.error([message], 400))
|
return _block_framing(Response.error([message], 400))
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,9 @@ from datasette.utils import (
|
||||||
path_with_format,
|
path_with_format,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
tilde_decode,
|
|
||||||
to_css_class,
|
to_css_class,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
|
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
|
||||||
from datasette.utils.sqlite import check_structured_write_table
|
|
||||||
|
|
||||||
from . import Context, from_extra
|
from . import Context, from_extra
|
||||||
from .base import BaseView, DatasetteError, stream_csv
|
from .base import BaseView, DatasetteError, stream_csv
|
||||||
|
|
@ -139,12 +137,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):
|
class RowView(BaseView):
|
||||||
name = "row"
|
name = "row"
|
||||||
|
|
||||||
|
|
@ -271,7 +263,7 @@ class RowView(BaseView):
|
||||||
if ttl is None or not ttl.isdigit():
|
if ttl is None or not ttl.isdigit():
|
||||||
ttl = self.ds.setting("default_cache_ttl")
|
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):
|
async def html(self, request, data, extra_template_data, templates):
|
||||||
extras = {}
|
extras = {}
|
||||||
|
|
@ -384,17 +376,9 @@ class RowView(BaseView):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_response_headers(self, response, ttl, request=None):
|
def set_response_headers(self, response, ttl):
|
||||||
private = getattr(request, "_datasette_private_response", False)
|
|
||||||
# Set far-future cache expiry
|
# Set far-future cache expiry
|
||||||
if self.ds.cache_headers and response.status == 200:
|
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"
|
|
||||||
else:
|
|
||||||
ttl = int(ttl)
|
ttl = int(ttl)
|
||||||
if ttl == 0:
|
if ttl == 0:
|
||||||
ttl_header = "no-cache"
|
ttl_header = "no-cache"
|
||||||
|
|
@ -407,27 +391,21 @@ class RowView(BaseView):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def data(self, request, default_labels=False):
|
async def data(self, request, default_labels=False):
|
||||||
db, table, resource = await _database_and_table_resource_from_request(
|
resolved = await self.ds.resolve_row(request)
|
||||||
self.ds, request
|
db = resolved.db
|
||||||
)
|
|
||||||
database = db.name
|
database = db.name
|
||||||
|
table = resolved.table
|
||||||
|
pk_values = resolved.pk_values
|
||||||
|
|
||||||
# Check the URL resource before resolving the row, so a denied request
|
# Ensure user has permission to view this row
|
||||||
# cannot distinguish an existing primary key from a missing one.
|
|
||||||
visible, private = await self.ds.check_visibility(
|
visible, private = await self.ds.check_visibility(
|
||||||
request.actor,
|
request.actor,
|
||||||
action="view-table",
|
action="view-table",
|
||||||
resource=resource,
|
resource=TableResource(database=database, table=table),
|
||||||
)
|
)
|
||||||
if not visible:
|
if not visible:
|
||||||
raise Forbidden("You do not have permission to view this table")
|
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(
|
results = await resolved.db.execute(
|
||||||
resolved.sql, resolved.params, truncate=True
|
resolved.sql, resolved.params, truncate=True
|
||||||
)
|
)
|
||||||
|
|
@ -504,8 +482,8 @@ class RowView(BaseView):
|
||||||
for row in display_rows:
|
for row in display_rows:
|
||||||
for cell in row:
|
for cell in row:
|
||||||
if cell["column"] in pk_set:
|
if cell["column"] in pk_set:
|
||||||
cell["value"] = markupsafe.Markup("<strong>{}</strong>").format(
|
cell["value"] = markupsafe.Markup(
|
||||||
cell["value"]
|
"<strong>{}</strong>".format(cell["value"])
|
||||||
)
|
)
|
||||||
|
|
||||||
label_column = await db.label_column_for_table(table) if is_table else None
|
label_column = await db.label_column_for_table(table) if is_table else None
|
||||||
|
|
@ -578,7 +556,7 @@ class RowView(BaseView):
|
||||||
"private": private,
|
"private": private,
|
||||||
"columns": reordered_columns,
|
"columns": reordered_columns,
|
||||||
"foreign_key_tables": await self.foreign_key_tables(
|
"foreign_key_tables": await self.foreign_key_tables(
|
||||||
database, table, pk_values, actor=request.actor
|
database, table, pk_values
|
||||||
),
|
),
|
||||||
"database_color": db.color,
|
"database_color": db.color,
|
||||||
"display_columns": display_columns,
|
"display_columns": display_columns,
|
||||||
|
|
@ -655,23 +633,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:
|
if len(pk_values) != 1:
|
||||||
return []
|
return []
|
||||||
db = self.ds.databases[database]
|
db = self.ds.databases[database]
|
||||||
all_foreign_keys = await db.get_all_foreign_keys()
|
all_foreign_keys = await db.get_all_foreign_keys()
|
||||||
foreign_keys = []
|
foreign_keys = all_foreign_keys[table]["incoming"]
|
||||||
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)
|
|
||||||
if len(foreign_keys) == 0:
|
if len(foreign_keys) == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -728,24 +695,9 @@ def _truncated_row_flash_label(label):
|
||||||
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
||||||
|
|
||||||
|
|
||||||
async def _row_flash_message(
|
async def _row_flash_message(db, action, resolved, row=None):
|
||||||
datasette, request, action, resolved, row=None, *, refresh_row=False
|
|
||||||
):
|
|
||||||
pk_label = ", ".join(resolved.pk_values)
|
pk_label = ", ".join(resolved.pk_values)
|
||||||
# Mutation permission does not grant access to stored row labels.
|
label_column = await db.label_column_for_table(resolved.table)
|
||||||
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 = row_label_from_label_column(row or resolved.row, label_column)
|
label = row_label_from_label_column(row or resolved.row, label_column)
|
||||||
if label:
|
if label:
|
||||||
label = _truncated_row_flash_label(label)
|
label = _truncated_row_flash_label(label)
|
||||||
|
|
@ -758,28 +710,22 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound
|
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, _, resource = await _database_and_table_resource_from_request(
|
resolved = await datasette.resolve_row(request)
|
||||||
datasette, request
|
|
||||||
)
|
|
||||||
except DatabaseNotFound as e:
|
except DatabaseNotFound as e:
|
||||||
return False, Response.error([f"Database not found: {e.database_name}"], 404)
|
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)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved = await datasette.resolve_row(request)
|
|
||||||
except TableNotFound as e:
|
except TableNotFound as e:
|
||||||
return False, Response.error([f"Table not found: {e.table}"], 404)
|
return False, Response.error([f"Table not found: {e.table}"], 404)
|
||||||
except RowNotFound as e:
|
except RowNotFound as e:
|
||||||
return False, Response.error([f"Record not found: {e.pk_values}"], 404)
|
return False, Response.error([f"Record not found: {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
|
return True, resolved
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -798,7 +744,6 @@ class RowDeleteView(BaseView):
|
||||||
|
|
||||||
# Delete table
|
# Delete table
|
||||||
def delete_row(conn):
|
def delete_row(conn):
|
||||||
check_structured_write_table(conn, resolved.table)
|
|
||||||
sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)
|
sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -820,7 +765,7 @@ class RowDeleteView(BaseView):
|
||||||
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
||||||
self.ds.add_message(
|
self.ds.add_message(
|
||||||
request,
|
request,
|
||||||
await _row_flash_message(self.ds, request, "Deleted", resolved),
|
await _row_flash_message(resolved.db, "Deleted", resolved),
|
||||||
self.ds.INFO,
|
self.ds.INFO,
|
||||||
)
|
)
|
||||||
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
||||||
|
|
@ -881,7 +826,6 @@ class RowUpdateView(BaseView):
|
||||||
return Response.error(["Permission denied for alter-table"], 403)
|
return Response.error(["Permission denied for alter-table"], 403)
|
||||||
|
|
||||||
def update_row(conn):
|
def update_row(conn):
|
||||||
check_structured_write_table(conn, resolved.table)
|
|
||||||
sqlite_utils.Database(conn)[resolved.table].update(
|
sqlite_utils.Database(conn)[resolved.table].update(
|
||||||
resolved.pk_values, update, alter=alter
|
resolved.pk_values, update, alter=alter
|
||||||
)
|
)
|
||||||
|
|
@ -894,14 +838,7 @@ class RowUpdateView(BaseView):
|
||||||
|
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
returned_row = None
|
returned_row = None
|
||||||
# Only read back and disclose the stored row if the actor is also
|
if data.get("return"):
|
||||||
# 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,
|
|
||||||
):
|
|
||||||
results = await resolved.db.execute(
|
results = await resolved.db.execute(
|
||||||
resolved.sql, resolved.params, truncate=True
|
resolved.sql, resolved.params, truncate=True
|
||||||
)
|
)
|
||||||
|
|
@ -918,15 +855,16 @@ class RowUpdateView(BaseView):
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.args.get("_message"):
|
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(
|
self.ds.add_message(
|
||||||
request,
|
request,
|
||||||
await _row_flash_message(
|
await _row_flash_message(
|
||||||
self.ds,
|
resolved.db, "Updated", resolved, row=message_row
|
||||||
request,
|
|
||||||
"Updated",
|
|
||||||
resolved,
|
|
||||||
row=returned_row,
|
|
||||||
refresh_row=True,
|
|
||||||
),
|
),
|
||||||
self.ds.INFO,
|
self.ds.INFO,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,6 @@ class AllowedResourcesView(BaseView):
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
|
|
||||||
await self.ds.refresh_schemas()
|
await self.ds.refresh_schemas()
|
||||||
|
|
||||||
# Check if user has permissions-debug (to show sensitive fields)
|
# Check if user has permissions-debug (to show sensitive fields)
|
||||||
|
|
@ -797,8 +796,6 @@ class CreateTokenView(BaseView):
|
||||||
raise Forbidden(
|
raise Forbidden(
|
||||||
"Token authentication cannot be used to create additional tokens"
|
"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):
|
async def shared(self, request):
|
||||||
self.check_permission(request)
|
self.check_permission(request)
|
||||||
|
|
@ -876,11 +873,6 @@ class CreateTokenView(BaseView):
|
||||||
else:
|
else:
|
||||||
errors.append("Invalid expire duration unit")
|
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?
|
# Are there any restrictions?
|
||||||
from datasette.tokens import TokenRestrictions
|
from datasette.tokens import TokenRestrictions
|
||||||
|
|
||||||
|
|
@ -1269,21 +1261,14 @@ class SchemaBaseView(BaseView):
|
||||||
|
|
||||||
has_json_alternate = False
|
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."""
|
"""Get schema SQL for a database."""
|
||||||
db = self.ds.databases[database_name]
|
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(
|
result = await db.execute(
|
||||||
"select tbl_name, sql from sqlite_master where sql is not null"
|
"select group_concat(sql, ';' || CHAR(10)) as schema 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
|
|
||||||
)
|
)
|
||||||
|
row = result.first()
|
||||||
|
return row["schema"] if row and row["schema"] else ""
|
||||||
|
|
||||||
def format_json_response(self, data):
|
def format_json_response(self, data):
|
||||||
"""Format data as JSON response with CORS headers if needed."""
|
"""Format data as JSON response with CORS headers if needed."""
|
||||||
|
|
@ -1345,7 +1330,7 @@ class InstanceSchemaView(SchemaBaseView):
|
||||||
# Get schema for each database
|
# Get schema for each database
|
||||||
schemas = []
|
schemas = []
|
||||||
for database_name in allowed_databases:
|
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})
|
schemas.append({"database": database_name, "schema": schema})
|
||||||
|
|
||||||
if format_ == "json":
|
if format_ == "json":
|
||||||
|
|
@ -1386,7 +1371,7 @@ class DatabaseSchemaView(SchemaBaseView):
|
||||||
if database_name not in self.ds.databases:
|
if database_name not in self.ds.databases:
|
||||||
return self.format_error_response("Database not found", format_)
|
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":
|
if format_ == "json":
|
||||||
return self.format_json_response(
|
return self.format_json_response(
|
||||||
|
|
@ -1425,8 +1410,7 @@ class TableSchemaView(SchemaBaseView):
|
||||||
# Get schema for the table
|
# Get schema for the table
|
||||||
db = self.ds.databases[database_name]
|
db = self.ds.databases[database_name]
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
"select sql from sqlite_master where name = ? "
|
"select sql from sqlite_master where name = ? and sql is not null",
|
||||||
"and type in ('table', 'view') and sql is not null",
|
|
||||||
[table_name],
|
[table_name],
|
||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ class QueryCreateView(BaseView):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
response.status = status
|
response.status = status
|
||||||
return _block_framing(response)
|
return response
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db = await self.ds.resolve_database(request)
|
db = await self.ds.resolve_database(request)
|
||||||
|
|
@ -527,7 +527,7 @@ class QueryEditView(BaseView):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
response.status = status
|
response.status = status
|
||||||
return _block_framing(response)
|
return response
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
|
|
@ -639,8 +639,7 @@ class QueryDeleteView(BaseView):
|
||||||
return Response.error(
|
return Response.error(
|
||||||
["Trusted queries cannot be deleted using the API"], 403
|
["Trusted queries cannot be deleted using the API"], 403
|
||||||
)
|
)
|
||||||
return _block_framing(
|
return await self.render(
|
||||||
await self.render(
|
|
||||||
["query_delete.html"],
|
["query_delete.html"],
|
||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
|
|
@ -650,7 +649,6 @@ class QueryDeleteView(BaseView):
|
||||||
"query_url": self.ds.urls.table(db.name, query_name),
|
"query_url": self.ds.urls.table(db.name, query_name),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import urllib
|
import urllib
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
@ -58,7 +57,6 @@ from datasette.utils.asgi import (
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
from datasette.utils.sqlite import check_structured_write_table
|
|
||||||
|
|
||||||
from . import Context, from_extra
|
from . import Context, from_extra
|
||||||
from .base import BaseView, DatasetteError, stream_csv
|
from .base import BaseView, DatasetteError, stream_csv
|
||||||
|
|
@ -672,7 +670,7 @@ async def display_columns_and_rows(
|
||||||
}
|
}
|
||||||
pks = await db.primary_keys(table_name)
|
pks = await db.primary_keys(table_name)
|
||||||
pks_for_display = pks
|
pks_for_display = pks
|
||||||
if not pks_for_display and not await db.view_exists(table_name):
|
if not pks_for_display:
|
||||||
pks_for_display = ["rowid"]
|
pks_for_display = ["rowid"]
|
||||||
label_column = None
|
label_column = None
|
||||||
if link_column:
|
if link_column:
|
||||||
|
|
@ -903,7 +901,7 @@ async def display_columns_and_rows(
|
||||||
columns = [col for col in columns if col["name"] != pks[0]]
|
columns = [col for col in columns if col["name"] != pks[0]]
|
||||||
first_column = {
|
first_column = {
|
||||||
"name": pks[0],
|
"name": pks[0],
|
||||||
"sortable": pks[0] in sortable_columns,
|
"sortable": len(pks) == 1,
|
||||||
"is_pk": True,
|
"is_pk": True,
|
||||||
"type": column_details[pks[0]].type,
|
"type": column_details[pks[0]].type,
|
||||||
"notnull": column_details[pks[0]].notnull,
|
"notnull": column_details[pks[0]].notnull,
|
||||||
|
|
@ -1128,7 +1126,6 @@ class TableInsertView(BaseView):
|
||||||
row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]
|
row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]
|
||||||
|
|
||||||
def insert_or_upsert_rows(conn):
|
def insert_or_upsert_rows(conn):
|
||||||
check_structured_write_table(conn, table_name)
|
|
||||||
table = sqlite_utils.Database(conn)[table_name]
|
table = sqlite_utils.Database(conn)[table_name]
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if upsert:
|
if upsert:
|
||||||
|
|
@ -1160,32 +1157,17 @@ class TableInsertView(BaseView):
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||||
return Response.error([str(e)])
|
return Response.error([str(e)])
|
||||||
result = {"ok": True}
|
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 should_return:
|
||||||
if upsert:
|
if upsert:
|
||||||
# Fetch based on initial input IDs
|
# Fetch based on initial input IDs
|
||||||
where_clause = " OR ".join(
|
where_clause = " OR ".join(
|
||||||
[
|
["({})".format(" AND ".join(f"{pk} = ?" for pk in pks))]
|
||||||
"({})".format(
|
|
||||||
" AND ".join(f"{escape_sqlite(pk)} = ?" for pk in pks)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
* len(row_pk_values_for_later)
|
* len(row_pk_values_for_later)
|
||||||
)
|
)
|
||||||
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
||||||
fetched_rows = await db.execute(
|
fetched_rows = await db.execute(
|
||||||
"select {}* from {} where {}".format(
|
"select {}* from [{}] where {}".format(
|
||||||
"rowid, " if pks == ["rowid"] else "",
|
"rowid, " if pks == ["rowid"] else "", table_name, where_clause
|
||||||
escape_sqlite(table_name),
|
|
||||||
where_clause,
|
|
||||||
),
|
),
|
||||||
args,
|
args,
|
||||||
)
|
)
|
||||||
|
|
@ -1400,9 +1382,7 @@ class TableDropView(BaseView):
|
||||||
"database": database_name,
|
"database": database_name,
|
||||||
"table": table_name,
|
"table": table_name,
|
||||||
"row_count": (
|
"row_count": (
|
||||||
await db.execute(
|
await db.execute(f"select count(*) from [{table_name}]")
|
||||||
f"select count(*) from {escape_sqlite(table_name)}"
|
|
||||||
)
|
|
||||||
).single_value(),
|
).single_value(),
|
||||||
"message": 'Pass "confirm": true to confirm',
|
"message": 'Pass "confirm": true to confirm',
|
||||||
},
|
},
|
||||||
|
|
@ -1411,9 +1391,7 @@ class TableDropView(BaseView):
|
||||||
|
|
||||||
# Drop table
|
# Drop table
|
||||||
def drop_table(conn):
|
def drop_table(conn):
|
||||||
table = sqlite_utils.Database(conn)[table_name]
|
sqlite_utils.Database(conn)[table_name].drop()
|
||||||
table.disable_fts()
|
|
||||||
table.drop()
|
|
||||||
|
|
||||||
await db.execute_write_fn(drop_table, request=request)
|
await db.execute_write_fn(drop_table, request=request)
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
|
|
@ -1429,42 +1407,6 @@ class TableDropView(BaseView):
|
||||||
return Response.json({"ok": True}, status=200)
|
return Response.json({"ok": True}, status=200)
|
||||||
|
|
||||||
|
|
||||||
class TableCountView(BaseView):
|
|
||||||
name = "table-count"
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
try:
|
|
||||||
return await self.count(request)
|
|
||||||
except (NotFound, Forbidden, BadRequest, DatasetteError) as ex:
|
|
||||||
return Response.error(str(ex), status=ex.status)
|
|
||||||
|
|
||||||
async def count(self, request):
|
|
||||||
resolved = await self.ds.resolve_table(request)
|
|
||||||
visible, _private = await self.ds.check_visibility(
|
|
||||||
request.actor,
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
|
||||||
)
|
|
||||||
if not visible:
|
|
||||||
raise Forbidden("You do not have permission to view this table")
|
|
||||||
_, where_clauses, params, _, _ = await _table_filters(
|
|
||||||
self.ds, request, resolved.db.name, resolved.table
|
|
||||||
)
|
|
||||||
sql = f"select count(*) from {escape_sqlite(resolved.table)}"
|
|
||||||
if where_clauses:
|
|
||||||
sql += " where " + " and ".join(where_clauses)
|
|
||||||
try:
|
|
||||||
results = await resolved.db.execute(sql, params)
|
|
||||||
except QueryInterrupted:
|
|
||||||
return Response.error("Count query timed out", status=400)
|
|
||||||
except (sqlite3.OperationalError, InvalidSql) as ex:
|
|
||||||
return Response.error(str(ex), status=400)
|
|
||||||
return Response.json(
|
|
||||||
{"ok": True, "count": results.single_value()},
|
|
||||||
headers={"Cache-Control": "no-store"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TableFragmentView(BaseView):
|
class TableFragmentView(BaseView):
|
||||||
name = "table-fragment"
|
name = "table-fragment"
|
||||||
|
|
||||||
|
|
@ -1751,16 +1693,7 @@ async def table_view(datasette, request):
|
||||||
if ttl is None or not ttl.isdigit():
|
if ttl is None or not ttl.isdigit():
|
||||||
ttl = datasette.setting("default_cache_ttl")
|
ttl = datasette.setting("default_cache_ttl")
|
||||||
|
|
||||||
private = getattr(request, "_datasette_private_response", False)
|
|
||||||
|
|
||||||
if datasette.cache_headers and response.status == 200:
|
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"
|
|
||||||
else:
|
|
||||||
ttl = int(ttl)
|
ttl = int(ttl)
|
||||||
if ttl == 0:
|
if ttl == 0:
|
||||||
ttl_header = "no-cache"
|
ttl_header = "no-cache"
|
||||||
|
|
@ -1802,7 +1735,6 @@ async def table_view_traced(datasette, request):
|
||||||
context_for_html_hack = True
|
context_for_html_hack = True
|
||||||
default_labels = True
|
default_labels = True
|
||||||
|
|
||||||
start = time.perf_counter()
|
|
||||||
view_data = await table_view_data(
|
view_data = await table_view_data(
|
||||||
datasette,
|
datasette,
|
||||||
request,
|
request,
|
||||||
|
|
@ -1813,7 +1745,6 @@ async def table_view_traced(datasette, request):
|
||||||
)
|
)
|
||||||
if isinstance(view_data, Response):
|
if isinstance(view_data, Response):
|
||||||
return view_data
|
return view_data
|
||||||
query_ms = (time.perf_counter() - start) * 1000
|
|
||||||
data, rows, columns, _expanded_columns, sql, next_url = view_data
|
data, rows, columns, _expanded_columns, sql, next_url = view_data
|
||||||
|
|
||||||
# Handle formats from plugins
|
# Handle formats from plugins
|
||||||
|
|
@ -1960,7 +1891,7 @@ async def table_view_traced(datasette, request):
|
||||||
resource=DatabaseResource(database=resolved.db.name),
|
resource=DatabaseResource(database=resolved.db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
),
|
),
|
||||||
query_ms=query_ms,
|
query_ms=1.2,
|
||||||
select_templates=[
|
select_templates=[
|
||||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
for template_name in templates
|
for template_name in templates
|
||||||
|
|
@ -1992,47 +1923,6 @@ async def table_view_traced(datasette, request):
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
async def _table_filters(datasette, request, database_name, table_name):
|
|
||||||
# Arguments that start with _ and don't contain a __ are
|
|
||||||
# special - things like ?_search= - and should not be
|
|
||||||
# treated as filters.
|
|
||||||
filter_args = []
|
|
||||||
for key in request.args:
|
|
||||||
if not (key.startswith("_") and "__" not in key):
|
|
||||||
for v in request.args.getlist(key):
|
|
||||||
filter_args.append((key, v))
|
|
||||||
|
|
||||||
# Build where clauses from query string arguments
|
|
||||||
filters = Filters(sorted(filter_args))
|
|
||||||
where_clauses, params = filters.build_where_clauses(table_name)
|
|
||||||
|
|
||||||
# Execute filters_from_request plugin hooks - including the default
|
|
||||||
# ones that live in datasette/filters.py
|
|
||||||
extra_context_from_filters = {}
|
|
||||||
extra_human_descriptions = []
|
|
||||||
|
|
||||||
for hook in pm.hook.filters_from_request(
|
|
||||||
request=request,
|
|
||||||
table=table_name,
|
|
||||||
database=database_name,
|
|
||||||
datasette=datasette,
|
|
||||||
):
|
|
||||||
filter_arguments = await await_me_maybe(hook)
|
|
||||||
if filter_arguments:
|
|
||||||
where_clauses.extend(filter_arguments.where_clauses)
|
|
||||||
params.update(filter_arguments.params)
|
|
||||||
extra_human_descriptions.extend(filter_arguments.human_descriptions)
|
|
||||||
extra_context_from_filters.update(filter_arguments.extra_context)
|
|
||||||
|
|
||||||
return (
|
|
||||||
filters,
|
|
||||||
where_clauses,
|
|
||||||
params,
|
|
||||||
extra_human_descriptions,
|
|
||||||
extra_context_from_filters,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def table_view_data(
|
async def table_view_data(
|
||||||
datasette,
|
datasette,
|
||||||
request,
|
request,
|
||||||
|
|
@ -2057,10 +1947,6 @@ async def table_view_data(
|
||||||
)
|
)
|
||||||
if not visible:
|
if not visible:
|
||||||
raise Forbidden("You do not have permission to view this table")
|
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 based on request.args, if necessary
|
||||||
redirect_response = await _redirect_if_needed(datasette, request, resolved)
|
redirect_response = await _redirect_if_needed(datasette, request, resolved)
|
||||||
|
|
@ -2111,13 +1997,36 @@ async def table_view_data(
|
||||||
|
|
||||||
table_metadata = await datasette.table_config(database_name, table_name)
|
table_metadata = await datasette.table_config(database_name, table_name)
|
||||||
|
|
||||||
(
|
# Arguments that start with _ and don't contain a __ are
|
||||||
filters,
|
# special - things like ?_search= - and should not be
|
||||||
where_clauses,
|
# treated as filters.
|
||||||
params,
|
filter_args = []
|
||||||
extra_human_descriptions,
|
for key in request.args:
|
||||||
extra_context_from_filters,
|
if not (key.startswith("_") and "__" not in key):
|
||||||
) = await _table_filters(datasette, request, database_name, table_name)
|
for v in request.args.getlist(key):
|
||||||
|
filter_args.append((key, v))
|
||||||
|
|
||||||
|
# Build where clauses from query string arguments
|
||||||
|
filters = Filters(sorted(filter_args))
|
||||||
|
where_clauses, params = filters.build_where_clauses(table_name)
|
||||||
|
|
||||||
|
# Execute filters_from_request plugin hooks - including the default
|
||||||
|
# ones that live in datasette/filters.py
|
||||||
|
extra_context_from_filters = {}
|
||||||
|
extra_human_descriptions = []
|
||||||
|
|
||||||
|
for hook in pm.hook.filters_from_request(
|
||||||
|
request=request,
|
||||||
|
table=table_name,
|
||||||
|
database=database_name,
|
||||||
|
datasette=datasette,
|
||||||
|
):
|
||||||
|
filter_arguments = await await_me_maybe(hook)
|
||||||
|
if filter_arguments:
|
||||||
|
where_clauses.extend(filter_arguments.where_clauses)
|
||||||
|
params.update(filter_arguments.params)
|
||||||
|
extra_human_descriptions.extend(filter_arguments.human_descriptions)
|
||||||
|
extra_context_from_filters.update(filter_arguments.extra_context)
|
||||||
|
|
||||||
# Deal with custom sort orders
|
# Deal with custom sort orders
|
||||||
sortable_columns = await _sortable_columns_for_table(
|
sortable_columns = await _sortable_columns_for_table(
|
||||||
|
|
@ -2315,6 +2224,8 @@ async def table_view_data(
|
||||||
new_rows.append(new_row)
|
new_rows.append(new_row)
|
||||||
rows = new_rows
|
rows = new_rows
|
||||||
|
|
||||||
|
_next = request.args.get("_next")
|
||||||
|
|
||||||
# Pagination next link
|
# Pagination next link
|
||||||
next_value, next_url = await _next_value_and_url(
|
next_value, next_url = await _next_value_and_url(
|
||||||
datasette,
|
datasette,
|
||||||
|
|
@ -2519,12 +2430,9 @@ async def _next_value_and_url(
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
||||||
prefix_where_clause = " and ".join(
|
prefix_where_clause = " and ".join(
|
||||||
f"{escape_sqlite(pk)} = :pk{i}" for i, pk in enumerate(pks)
|
f"[{pk}] = :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 = f"select [{sort or sort_desc}] from [{table_name}] where {prefix_where_clause}"
|
||||||
prefix = (
|
prefix = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
prefix_lookup_sql,
|
prefix_lookup_sql,
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,7 @@ from datasette.utils import (
|
||||||
table_column_details,
|
table_column_details,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
|
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
|
||||||
from datasette.utils.permissions import (
|
from datasette.utils.sqlite import sqlite_hidden_table_names
|
||||||
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 .base import BaseView
|
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):
|
def _singular(name):
|
||||||
if name.endswith("ies") and len(name) > 3:
|
if name.endswith("ies") and len(name) > 3:
|
||||||
return name[:-3] + "y"
|
return name[:-3] + "y"
|
||||||
|
|
@ -853,18 +821,16 @@ class TableCreateView(BaseView):
|
||||||
ignore = create_request.ignore
|
ignore = create_request.ignore
|
||||||
replace = create_request.replace
|
replace = create_request.replace
|
||||||
|
|
||||||
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
|
# Replacing rows requires update-row permission
|
||||||
if replace and not await self.ds.allowed(
|
if replace and not await self.ds.allowed(
|
||||||
action="update-row",
|
action="update-row",
|
||||||
resource=table_resource,
|
resource=DatabaseResource(database=database_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return Response.error(["Permission denied: need update-row"], 403)
|
return Response.error(["Permission denied: need update-row"], 403)
|
||||||
|
|
||||||
|
table_name = create_request.table
|
||||||
|
table_exists = await db.table_exists(table_name)
|
||||||
columns = create_request.columns
|
columns = create_request.columns
|
||||||
rows = create_request.rows_list
|
rows = create_request.rows_list
|
||||||
|
|
||||||
|
|
@ -872,7 +838,7 @@ class TableCreateView(BaseView):
|
||||||
# Must have insert-row permission
|
# Must have insert-row permission
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="insert-row",
|
action="insert-row",
|
||||||
resource=table_resource,
|
resource=DatabaseResource(database=database_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return Response.error(["Permission denied: need insert-row"], 403)
|
return Response.error(["Permission denied: need insert-row"], 403)
|
||||||
|
|
@ -891,7 +857,7 @@ class TableCreateView(BaseView):
|
||||||
if create_request.alter:
|
if create_request.alter:
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="alter-table",
|
action="alter-table",
|
||||||
resource=table_resource,
|
resource=DatabaseResource(database=database_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return Response.error(
|
return Response.error(
|
||||||
|
|
@ -927,7 +893,6 @@ class TableCreateView(BaseView):
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_table(conn):
|
def create_table(conn):
|
||||||
check_structured_write_table(conn, table_name, allow_missing=True)
|
|
||||||
db_for_write = sqlite_utils.Database(conn)
|
db_for_write = sqlite_utils.Database(conn)
|
||||||
table = db_for_write[table_name]
|
table = db_for_write[table_name]
|
||||||
if rows:
|
if rows:
|
||||||
|
|
@ -1047,9 +1012,6 @@ class DatabaseForeignKeyTargetsView(BaseView):
|
||||||
for target in (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts()
|
for target in (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts()
|
||||||
if target["fk_table"] not in hidden_tables
|
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(
|
return Response.json(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
@ -1088,15 +1050,6 @@ class TableForeignKeySuggestionsView(BaseView):
|
||||||
source_columns, targets, current_by_column = await db.execute_fn(
|
source_columns, targets, current_by_column = await db.execute_fn(
|
||||||
lambda conn: _foreign_key_suggestion_metadata(conn, table_name)
|
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 = []
|
columns = []
|
||||||
options_by_column = {}
|
options_by_column = {}
|
||||||
|
|
@ -1309,9 +1262,7 @@ class TableAlterView(BaseView):
|
||||||
elif operation.op == "set_foreign_keys":
|
elif operation.op == "set_foreign_keys":
|
||||||
foreign_keys = [fk.tuple for fk in args.foreign_keys]
|
foreign_keys = [fk.tuple for fk in args.foreign_keys]
|
||||||
|
|
||||||
# Use a savepoint inside execute_write_fn's transaction so
|
with operation_conn:
|
||||||
# write_wrapper hooks can still reject and roll back the write.
|
|
||||||
with db_for_write.atomic():
|
|
||||||
for column in add_columns:
|
for column in add_columns:
|
||||||
not_null_default = None
|
not_null_default = None
|
||||||
if column.not_null:
|
if column.not_null:
|
||||||
|
|
|
||||||
|
|
@ -1206,10 +1206,7 @@ class ForeignKeyTablesExtra(Extra):
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return await context.foreign_key_tables(
|
return await context.foreign_key_tables(
|
||||||
context.database_name,
|
context.database_name, context.table_name, context.pk_values
|
||||||
context.table_name,
|
|
||||||
context.pk_values,
|
|
||||||
actor=context.request.actor,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,22 +83,6 @@ def decision_for_write_sql_operation(
|
||||||
)
|
)
|
||||||
if operation.operation == "function":
|
if operation.operation == "function":
|
||||||
return IgnoreWriteSqlOperation("SQL 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 (
|
if (
|
||||||
operation.operation == "read"
|
operation.operation == "read"
|
||||||
and operation.target_type == "table"
|
and operation.target_type == "table"
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,6 @@ Datasette resolves matching rules from most specific to least specific:
|
||||||
|
|
||||||
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.
|
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
|
.. list-table:: Permission rule examples
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
|
|
@ -191,18 +182,6 @@ names and other resource types remain case-sensitive.
|
||||||
|
|
||||||
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 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 checks using :ref:`datasette_allowed`, 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.
|
``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.
|
||||||
|
|
@ -792,8 +771,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.
|
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.
|
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.
|
You can alternatively use an ``"allow_sql"`` block to control who is allowed to execute arbitrary SQL queries.
|
||||||
|
|
@ -1382,12 +1359,6 @@ view-table
|
||||||
|
|
||||||
Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.io/fixtures/complex_foreign_keys
|
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)``
|
``resource`` - ``datasette.resources.TableResource(database, table)``
|
||||||
``database`` is the name of the database (string)
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
|
@ -1550,8 +1521,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
|
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)``
|
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||||
``database`` is the name of the database (string)
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,121 +4,6 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
.. _v1_0_a41:
|
|
||||||
|
|
||||||
1.0a41 (2026-09-24)
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
OpenTelemetry support, a new JavaScript API for creating modal dialogs, and several smaller bug fixes.
|
|
||||||
|
|
||||||
OpenTelemetry
|
|
||||||
~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Datasette now supports `OpenTelemetry <https://opentelemetry.io/>`__ traces and metrics for monitoring application performance. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:issue:`1730`, :issue:`2867`)
|
|
||||||
|
|
||||||
- Traces cover HTTP requests, database queries and startup, including time spent waiting for SQL threads and queued writes.
|
|
||||||
- Metrics report query latency, time-limit interruptions, SQL thread usage, write queues and open connections.
|
|
||||||
- New :ref:`tools for plugin authors <plugin_telemetry>` help plugins add their own traces and metrics, with shared registry classes and pytest helpers.
|
|
||||||
|
|
||||||
To collect telemetry, configure an OpenTelemetry SDK and exporter, then run Datasette using ``opentelemetry-instrument``. See :ref:`internals_telemetry` for setup instructions.
|
|
||||||
|
|
||||||
Other features
|
|
||||||
~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
- New :ref:`DatasetteModal JavaScript API <javascript_plugins_modals>` for plugins to create dialogs with Datasette's shared styles, keyboard behavior and focus handling. Datasette's built-in dialogs use the same API. (:issue:`2790`, :pr:`2948`)
|
|
||||||
|
|
||||||
Bug fixes
|
|
||||||
~~~~~~~~~
|
|
||||||
|
|
||||||
- Table pages now show measured query timings instead of always displaying 1.2ms. (:issue:`2446`)
|
|
||||||
- Facet loading now ignores unrelated query parameters such as ``?_facets=x``, instead of returning a 500 error. Thanks, `Peng Boyu <https://github.com/pengboyu-dev>`__. (:pr:`2949`)
|
|
||||||
- Foreign key values no longer link to tables that do not exist. Thanks, `Dipak Chaudhari <https://github.com/dchaudhari7177>`__. (:issue:`1515`, :pr:`2952`)
|
|
||||||
- Fixed missing punctuation between table and view counts on the homepage, such as ``0 tables1 view``. Thanks, `Dipak Chaudhari <https://github.com/dchaudhari7177>`__. (:issue:`2012`, :pr:`2951`)
|
|
||||||
- The sort menu now excludes primary keys that are not included in :ref:`sortable_columns <table_configuration_sortable_columns>`. Thanks, `Sanjay Santhanam <https://github.com/Sanjays2402>`__. (:issue:`1980`, :pr:`2858`)
|
|
||||||
|
|
||||||
.. _v1_0_a40:
|
|
||||||
|
|
||||||
1.0a40 (2026-09-16)
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
A security fix, a new set of APIs providing background tasks for plugins, an endpoint for counting matching rows, and a collection of bug fixes.
|
|
||||||
|
|
||||||
Security fix
|
|
||||||
~~~~~~~~~~~~
|
|
||||||
|
|
||||||
- Fixed a security issue where a trailing newline in a requested table name could bypass table permissions and expose private rows. Thanks for the report, `dpfkdlemtp <https://github.com/dpfkdlemtp>`__. `GHSA-h547-rmjf-5m2m <https://github.com/simonw/datasette/security/advisories/GHSA-h547-rmjf-5m2m>`__
|
|
||||||
|
|
||||||
Background tasks
|
|
||||||
~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Datasette plugins can now use **background tasks** to run code independent of the Datasette request/response cycle.
|
|
||||||
|
|
||||||
- New :ref:`datasette_add_background_task` API: plugins register supervised, long-lived background work - typically from a ``startup`` hook - and these will be launched after every ``startup`` hook has run. Tasks are cancelled (with a five-second grace period) on shutdown.
|
|
||||||
- New ``/-/tasks`` JSON debug endpoint lists every supervised background task and its state, in the style of ``/-/threads``. See :ref:`JsonDataView_tasks`. It requires the ``permissions-debug`` permission.
|
|
||||||
- New :ref:`plugin_hook_shutdown` plugin hook, called during graceful shutdown (Ctrl-C, ``SIGTERM``) before background tasks are cancelled and before database connections are closed. It is not called on a hard kill (``SIGKILL``).
|
|
||||||
- Plugin ``asgi_wrapper`` middleware now always runs *after* startup has completed.
|
|
||||||
- If your plugin uses ``asgi_wrapper`` to start background tasks on the first incoming request, you should migrate to ``datasette.add_background_task()`` instead. `datasette-cron <https://datasette.io/plugins/datasette-cron>`__ and `datasette-enrichments <https://datasette.io/plugins/datasette-enrichments>`__ are being migrated to this pattern.
|
|
||||||
|
|
||||||
Other features
|
|
||||||
~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
- New :ref:`POST count endpoint <TableCountView>` for counting filtered table rows, now used by the **count all** button. (:issue:`2914`)
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
Bug fixes
|
|
||||||
~~~~~~~~~
|
|
||||||
|
|
||||||
- Column facets now show the remove-filter link for filters using ``column__exact=value``, as well as ``column=value``. (:issue:`1695`)
|
|
||||||
- The :ref:`alter-table API <TableAlterView>` now rolls back schema changes when a :ref:`write_wrapper <plugin_hook_write_wrapper>` raises after the write. (:issue:`2924`, :pr:`2925`)
|
|
||||||
- The :ref:`extra_template_vars() <plugin_hook_extra_template_vars>` plugin hook can now return a function or awaitable that resolves to ``None`` when no extra variables are needed. (:issue:`2005`)
|
|
||||||
- :ref:`request.headers <internals_request>` now supports case-insensitive header lookups, so ``request.headers.get("Content-Type")`` works as well as ``request.headers.get("content-type")``. (:issue:`1861`)
|
|
||||||
- CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`)
|
|
||||||
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`)
|
|
||||||
- Numeric comparison filters now correctly handle decimal values, negative numbers and scientific notation when filtering computed columns and SQL views. Thanks, `Rami Abdelrazzaq <https://github.com/RamiNoodle733>`__. (:issue:`1681`, :pr:`2876`)
|
|
||||||
- Fixed CSV streaming with ``?_stream=on`` on SQL views repeating the second page of results until the CSV size limit was reached. Thanks, `Ankita Advitot <https://github.com/AnkitaAdvitot>`__. (:issue:`2902`, :pr:`2903`)
|
|
||||||
|
|
||||||
.. _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:
|
.. _v1_0_a38:
|
||||||
|
|
||||||
1.0a38 (2026-08-06)
|
1.0a38 (2026-08-06)
|
||||||
|
|
@ -1207,7 +1092,7 @@ Features
|
||||||
- New ``--nolock`` option for ignoring file locks when opening read-only databases. (:issue:`1744`)
|
- New ``--nolock`` option for ignoring file locks when opening read-only databases. (:issue:`1744`)
|
||||||
- Spaces in the database names in URLs are now encoded as ``+`` rather than ``~20``. (:issue:`1701`)
|
- Spaces in the database names in URLs are now encoded as ``+`` rather than ``~20``. (:issue:`1701`)
|
||||||
- ``<Binary: 2427344 bytes>`` is now displayed as ``<Binary: 2,427,344 bytes>`` and is accompanied by tooltip showing "2.3MB". (:issue:`1712`)
|
- ``<Binary: 2427344 bytes>`` is now displayed as ``<Binary: 2,427,344 bytes>`` and is accompanied by tooltip showing "2.3MB". (:issue:`1712`)
|
||||||
- The base Docker image used by ``datasette publish cloudrun``, ``datasette package`` and the `official Datasette image <https://hub.docker.com/r/datasetteproject/datasette>`__ has been upgraded to ``3.10.6-slim-bullseye``. (:issue:`1768`)
|
- The base Docker image used by ``datasette publish cloudrun``, ``datasette package`` and the `official Datasette image <https://hub.docker.com/datasetteproject/datasette>`__ has been upgraded to ``3.10.6-slim-bullseye``. (:issue:`1768`)
|
||||||
- Canned writable queries against immutable databases now show a warning message. (:issue:`1728`)
|
- Canned writable queries against immutable databases now show a warning message. (:issue:`1728`)
|
||||||
- ``datasette publish cloudrun`` has a new ``--timeout`` option which can be used to increase the time limit applied by the Google Cloud build environment. Thanks, Tim Sherratt. (:pr:`1717`)
|
- ``datasette publish cloudrun`` has a new ``--timeout`` option which can be used to increase the time limit applied by the Google Cloud build environment. Thanks, Tim Sherratt. (:pr:`1717`)
|
||||||
- ``datasette publish cloudrun`` has new ``--min-instances`` and ``--max-instances`` options. (:issue:`1779`)
|
- ``datasette publish cloudrun`` has new ``--min-instances`` and ``--max-instances`` options. (:issue:`1779`)
|
||||||
|
|
@ -2236,7 +2121,7 @@ If you are still running Python 3.5 you should stick with ``0.30.2``, which you
|
||||||
- Removed obsolete ``?_group_count=col`` feature (:issue:`504`)
|
- Removed obsolete ``?_group_count=col`` feature (:issue:`504`)
|
||||||
- Improved user interface and documentation for ``datasette publish cloudrun`` (:issue:`608`)
|
- Improved user interface and documentation for ``datasette publish cloudrun`` (:issue:`608`)
|
||||||
- Tables with indexes now show the ``CREATE INDEX`` statements on the table page (:issue:`618`)
|
- Tables with indexes now show the ``CREATE INDEX`` statements on the table page (:issue:`618`)
|
||||||
- Current version of `uvicorn <https://uvicorn.dev/>`__ is now shown on ``/-/versions``
|
- Current version of `uvicorn <https://www.uvicorn.org/>`__ is now shown on ``/-/versions``
|
||||||
- Python 3.8 is now supported! (:issue:`622`)
|
- Python 3.8 is now supported! (:issue:`622`)
|
||||||
- Python 3.5 is no longer supported.
|
- Python 3.5 is no longer supported.
|
||||||
|
|
||||||
|
|
@ -2287,7 +2172,7 @@ If you are still running Python 3.5 you should stick with ``0.30.2``, which you
|
||||||
0.29.2 (2019-07-13)
|
0.29.2 (2019-07-13)
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
- Bumped `Uvicorn <https://uvicorn.dev/>`__ to 0.8.4, fixing a bug where the query string was not included in the server logs. (:issue:`559`)
|
- Bumped `Uvicorn <https://www.uvicorn.org/>`__ to 0.8.4, fixing a bug where the query string was not included in the server logs. (:issue:`559`)
|
||||||
- Fixed bug where the navigation breadcrumbs were not displayed correctly on the page for a custom query. (:issue:`558`)
|
- Fixed bug where the navigation breadcrumbs were not displayed correctly on the page for a custom query. (:issue:`558`)
|
||||||
- Fixed bug where custom query names containing unicode characters caused errors.
|
- Fixed bug where custom query names containing unicode characters caused errors.
|
||||||
|
|
||||||
|
|
@ -2309,7 +2194,7 @@ ASGI, new plugin hooks, facet by date and much, much more...
|
||||||
ASGI
|
ASGI
|
||||||
~~~~
|
~~~~
|
||||||
|
|
||||||
`ASGI <https://asgi.readthedocs.io/>`__ is the Asynchronous Server Gateway Interface standard. I've been wanting to convert Datasette into an ASGI application for over a year - `Port Datasette to ASGI #272 <https://github.com/simonw/datasette/issues/272>`__ tracks thirteen months of intermittent development - but with Datasette 0.29 the change is finally released. This also means Datasette now runs on top of `Uvicorn <https://uvicorn.dev/>`__ and no longer depends on `Sanic <https://github.com/huge-success/sanic>`__.
|
`ASGI <https://asgi.readthedocs.io/>`__ is the Asynchronous Server Gateway Interface standard. I've been wanting to convert Datasette into an ASGI application for over a year - `Port Datasette to ASGI #272 <https://github.com/simonw/datasette/issues/272>`__ tracks thirteen months of intermittent development - but with Datasette 0.29 the change is finally released. This also means Datasette now runs on top of `Uvicorn <https://www.uvicorn.org/>`__ and no longer depends on `Sanic <https://github.com/huge-success/sanic>`__.
|
||||||
|
|
||||||
I wrote about the significance of this change in `Porting Datasette to ASGI, and Turtles all the way down <https://simonwillison.net/2019/Jun/23/datasette-asgi/>`__.
|
I wrote about the significance of this change in `Porting Datasette to ASGI, and Turtles all the way down <https://simonwillison.net/2019/Jun/23/datasette-asgi/>`__.
|
||||||
|
|
||||||
|
|
@ -2723,7 +2608,7 @@ Miscellaneous
|
||||||
as a string.
|
as a string.
|
||||||
* If you just want an array of the first value of each row, use the new
|
* If you just want an array of the first value of each row, use the new
|
||||||
``?_shape=arrayfirst`` option - `example
|
``?_shape=arrayfirst`` option - `example
|
||||||
<https://latest.datasette.io/fixtures.json?sql=select+_neighborhood+from+facetable+order+by+pk+limit+101&_shape=arrayfirst>`_.
|
<https://latest.datasette.io/fixtures.json?sql=select+neighborhood+from+facetable+order+by+pk+limit+101&_shape=arrayfirst>`_.
|
||||||
|
|
||||||
0.22.1 (2018-05-23)
|
0.22.1 (2018-05-23)
|
||||||
-------------------
|
-------------------
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,6 @@ Or since this is the default command you can run this instead::
|
||||||
|
|
||||||
Once started you can access it at ``http://localhost:8001``
|
Once started you can access it at ``http://localhost:8001``
|
||||||
|
|
||||||
Use ``--internal PATH`` or the ``DATASETTE_INTERNAL`` environment variable to persist :ref:`Datasette's internal database <internals_internal>` to a SQLite file.
|
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
help(["serve", "--help"])
|
help(["serve", "--help"])
|
||||||
.. ]]]
|
.. ]]]
|
||||||
|
|
|
||||||
|
|
@ -312,19 +312,6 @@ To update these pages, run the following command::
|
||||||
|
|
||||||
uv run cog -r docs/*.rst
|
uv run cog -r docs/*.rst
|
||||||
|
|
||||||
.. _contributing_documentation_screenshots:
|
|
||||||
|
|
||||||
Documentation screenshots
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Screenshots in the documentation are defined in ``docs/shots.yml`` and taken using `shot-scraper <https://shot-scraper.datasette.io/>`__. That file starts a Datasette server that loads JavaScript from ``docs/shots/``, then saves each screenshot as a WebP image in ``docs/images/``.
|
|
||||||
|
|
||||||
To take any screenshots that do not exist yet, run::
|
|
||||||
|
|
||||||
just shots
|
|
||||||
|
|
||||||
``just docs`` runs this too. Existing images are skipped. To replace a screenshot, delete its image file and run ``just shots`` again.
|
|
||||||
|
|
||||||
.. _contributing_template_contexts:
|
.. _contributing_template_contexts:
|
||||||
|
|
||||||
Documented template contexts
|
Documented template contexts
|
||||||
|
|
|
||||||
|
|
@ -302,14 +302,6 @@ content you can do so by creating a ``row.html`` template like this:
|
||||||
Note the ``default:row.html`` template name, which ensures Jinja will inherit
|
Note the ``default:row.html`` template name, which ensures Jinja will inherit
|
||||||
from the default template.
|
from the default template.
|
||||||
|
|
||||||
The default ``base.html`` template provides a ``crumbs`` block inside its navigation block. Override ``crumbs`` to customize the breadcrumbs without replacing the rest of the navigation. The imported ``crumbs.nav()`` macro renders Datasette's permission-aware breadcrumbs:
|
|
||||||
|
|
||||||
.. code-block:: jinja
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database, table=table) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
The ``_table.html`` template is included by both the row and the table pages,
|
The ``_table.html`` template is included by both the row and the table pages,
|
||||||
and a list of rows. The default ``_table.html`` template renders them as an
|
and a list of rows. The default ``_table.html`` template renders them as an
|
||||||
HTML template and `can be seen here <https://github.com/simonw/datasette/blob/main/datasette/templates/_table.html>`_.
|
HTML template and `can be seen here <https://github.com/simonw/datasette/blob/main/datasette/templates/_table.html>`_.
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,13 @@ Datasette facets can be used to add a faceted browse interface to any database t
|
||||||
With facets, tables are displayed along with a summary showing the most common values in specified columns.
|
With facets, tables are displayed along with a summary showing the most common values in specified columns.
|
||||||
These values can be selected to further filter the table.
|
These values can be selected to further filter the table.
|
||||||
|
|
||||||
Here's `an example <https://datasette.io/legislators/legislator_terms?_facet=type&_facet=party&_facet=state&_facet_size=10>`__:
|
Here's `an example <https://congress-legislators.datasettes.com/legislators/legislator_terms?_facet=type&_facet=party&_facet=state&_facet_size=10>`__:
|
||||||
|
|
||||||
.. image:: https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png
|
.. image:: https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png
|
||||||
:alt: Screenshot showing facets against a table of congressional legislators. Suggested facets include state_rank and start and end dates, and the displayed facets are state, party and type. Each facet lists values along with a count of rows for each value.
|
:alt: Screenshot showing facets against a table of congressional legislators. Suggested facets include state_rank and start and end dates, and the displayed facets are state, party and type. Each facet lists values along with a count of rows for each value.
|
||||||
|
|
||||||
Facets can be specified in two ways: using query string parameters, or in ``metadata.json`` configuration for the table.
|
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
|
Facets in query strings
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ The table page and table view API
|
||||||
|
|
||||||
Table views that support full-text search can be queried using the ``?_search=TERMS`` query string parameter. This will run the search against content from all of the columns that have been included in the index.
|
Table views that support full-text search can be queried using the ``?_search=TERMS`` query string parameter. This will run the search against content from all of the columns that have been included in the index.
|
||||||
|
|
||||||
Try `searching Datasette ecosystem repositories for "csv" <https://datasette.io/content/repos?_search=csv>`__ to find tools for working with CSV files.
|
Try this example: `fara.datasettes.com/fara/FARA_All_ShortForms?_search=manafort <https://fara.datasettes.com/fara/FARA_All_ShortForms?_search=manafort>`__
|
||||||
|
|
||||||
SQLite full-text search supports wildcards. This means you can easily implement prefix auto-complete by including an asterisk at the end of the search term - for example::
|
SQLite full-text search supports wildcards. This means you can easily implement prefix auto-complete by including an asterisk at the end of the search term - for example::
|
||||||
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -120,31 +120,41 @@ Searches using custom SQL
|
||||||
|
|
||||||
You can include full-text search results in custom SQL queries. The general pattern with SQLite search is to run the search as a sub-select that returns rowid values, then include those rowids in another part of the query.
|
You can include full-text search results in custom SQL queries. The general pattern with SQLite search is to run the search as a sub-select that returns rowid values, then include those rowids in another part of the query.
|
||||||
|
|
||||||
You can see the syntax for a basic search by running that search on a table page and then clicking "View and edit SQL" to see the underlying SQL. For example, consider this search for `repositories mentioning "csv" <https://datasette.io/content/repos?_search=csv>`_::
|
You can see the syntax for a basic search by running that search on a table page and then clicking "View and edit SQL" to see the underlying SQL. For example, consider this search for `manafort is the US FARA database <https://fara.datasettes.com/fara/FARA_All_ShortForms?_search=manafort>`_::
|
||||||
|
|
||||||
/content/repos?_search=csv
|
/fara/FARA_All_ShortForms?_search=manafort
|
||||||
|
|
||||||
The generated SQL selects all columns in the table. This simplified version selects just the repository ID, full name and description, using the same full-text search condition. `Run this query <https://datasette.io/content?sql=select%0A++id%2C%0A++full_name%2C%0A++description%0Afrom%0A++repos%0Awhere%0A++rowid+in+%28%0A++++select%0A++++++rowid%0A++++from%0A++++++repos_fts%0A++++where%0A++++++repos_fts+match+escape_fts%28%3Asearch%29%0A++%29%0Aorder+by%0A++id%0Alimit%0A++101&search=csv>`_ with ``search`` set to ``csv``:
|
If you click `View and edit SQL <https://fara.datasettes.com/fara?sql=select%0D%0A++rowid%2C%0D%0A++Short_Form_Termination_Date%2C%0D%0A++Short_Form_Date%2C%0D%0A++Short_Form_Last_Name%2C%0D%0A++Short_Form_First_Name%2C%0D%0A++Registration_Number%2C%0D%0A++Registration_Date%2C%0D%0A++Registrant_Name%2C%0D%0A++Address_1%2C%0D%0A++Address_2%2C%0D%0A++City%2C%0D%0A++State%2C%0D%0A++Zip%0D%0Afrom%0D%0A++FARA_All_ShortForms%0D%0Awhere%0D%0A++rowid+in+%28%0D%0A++++select%0D%0A++++++rowid%0D%0A++++from%0D%0A++++++FARA_All_ShortForms_fts%0D%0A++++where%0D%0A++++++FARA_All_ShortForms_fts+match+escape_fts%28%3Asearch%29%0D%0A++%29%0D%0Aorder+by%0D%0A++rowid%0D%0Alimit%0D%0A++101&search=manafort>`_ you'll see that the underlying SQL looks like this:
|
||||||
|
|
||||||
.. code-block:: sql
|
.. code-block:: sql
|
||||||
|
|
||||||
select
|
select
|
||||||
id,
|
rowid,
|
||||||
full_name,
|
Short_Form_Termination_Date,
|
||||||
description
|
Short_Form_Date,
|
||||||
|
Short_Form_Last_Name,
|
||||||
|
Short_Form_First_Name,
|
||||||
|
Registration_Number,
|
||||||
|
Registration_Date,
|
||||||
|
Registrant_Name,
|
||||||
|
Address_1,
|
||||||
|
Address_2,
|
||||||
|
City,
|
||||||
|
State,
|
||||||
|
Zip
|
||||||
from
|
from
|
||||||
repos
|
FARA_All_ShortForms
|
||||||
where
|
where
|
||||||
rowid in (
|
rowid in (
|
||||||
select
|
select
|
||||||
rowid
|
rowid
|
||||||
from
|
from
|
||||||
repos_fts
|
FARA_All_ShortForms_fts
|
||||||
where
|
where
|
||||||
repos_fts match escape_fts(:search)
|
FARA_All_ShortForms_fts match escape_fts(:search)
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
id
|
rowid
|
||||||
limit
|
limit
|
||||||
101
|
101
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
|
|
@ -64,7 +64,6 @@ Contents
|
||||||
javascript_plugins
|
javascript_plugins
|
||||||
plugin_hooks
|
plugin_hooks
|
||||||
testing_plugins
|
testing_plugins
|
||||||
plugin_telemetry
|
|
||||||
internals
|
internals
|
||||||
events
|
events
|
||||||
upgrade_guide
|
upgrade_guide
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ The request object is passed to various plugin hooks. It represents an incoming
|
||||||
The request scheme - usually ``https`` or ``http``.
|
The request scheme - usually ``https`` or ``http``.
|
||||||
|
|
||||||
``.headers`` - dictionary (str -> str)
|
``.headers`` - dictionary (str -> str)
|
||||||
A dictionary of incoming HTTP request headers. Header lookups using ``request.headers["Content-Type"]``, ``request.headers.get("Content-Type")`` and ``"Content-Type" in request.headers`` are case-insensitive. Header names are lowercase when iterating over the dictionary.
|
A dictionary of incoming HTTP request headers. Header names have been converted to lowercase.
|
||||||
|
|
||||||
``.cookies`` - dictionary (str -> str)
|
``.cookies`` - dictionary (str -> str)
|
||||||
A dictionary of incoming cookies
|
A dictionary of incoming cookies
|
||||||
|
|
@ -147,7 +147,7 @@ And a class method that can be used to create fake request objects for use in te
|
||||||
.. _internals_multiparams:
|
.. _internals_multiparams:
|
||||||
|
|
||||||
The MultiParams class
|
The MultiParams class
|
||||||
---------------------
|
=====================
|
||||||
|
|
||||||
``request.args`` is a ``MultiParams`` object - a dictionary-like object which provides access to query string parameters that may have multiple values.
|
``request.args`` is a ``MultiParams`` object - a dictionary-like object which provides access to query string parameters that may have multiple values.
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ Consider the query string ``?foo=1&foo=2&bar=3`` - with two values for ``foo`` a
|
||||||
.. _internals_formdata:
|
.. _internals_formdata:
|
||||||
|
|
||||||
The FormData class
|
The FormData class
|
||||||
------------------
|
==================
|
||||||
|
|
||||||
``await request.form()`` returns a ``FormData`` object - a dictionary-like object which provides access to form fields and uploaded files. It has a similar interface to ``MultiParams``.
|
``await request.form()`` returns a ``FormData`` object - a dictionary-like object which provides access to form fields and uploaded files. It has a similar interface to ``MultiParams``.
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ The FormData class
|
||||||
.. _internals_uploadedfile:
|
.. _internals_uploadedfile:
|
||||||
|
|
||||||
The UploadedFile class
|
The UploadedFile class
|
||||||
----------------------
|
======================
|
||||||
|
|
||||||
When parsing multipart form data with ``files=True``, file uploads are returned as ``UploadedFile`` objects with the following properties and methods:
|
When parsing multipart form data with ``files=True``, file uploads are returned as ``UploadedFile`` objects with the following properties and methods:
|
||||||
|
|
||||||
|
|
@ -1403,31 +1403,7 @@ Release all resources held by this ``Datasette`` instance. This calls :ref:`data
|
||||||
|
|
||||||
If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed.
|
If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed.
|
||||||
|
|
||||||
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``. See :ref:`datasette_lifecycle` for where ``close()`` fits into the full startup-to-shutdown sequence.
|
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``.
|
||||||
|
|
||||||
.. _datasette_add_background_task:
|
|
||||||
|
|
||||||
.add_background_task(func, name=None)
|
|
||||||
-------------------------------------
|
|
||||||
|
|
||||||
``func`` - async callable
|
|
||||||
A coroutine function taking one positional argument, the ``Datasette`` instance. Core calls ``await func(datasette)``.
|
|
||||||
|
|
||||||
``name`` - string, optional
|
|
||||||
A name for the task, used to identify it in the ``/-/tasks`` introspection endpoint (:ref:`JsonDataView_tasks`) and in log messages. Defaults to ``func.__qualname__``. If the resulting name collides with an already-registered task, a ``-2``, ``-3``, ... suffix is appended.
|
|
||||||
|
|
||||||
Registers supervised background work and returns a :ref:`BackgroundTask <BackgroundTask>` handle. Tasks registered during startup launch after all startup hooks finish; tasks registered after launch start immediately.
|
|
||||||
|
|
||||||
See :ref:`internals_background_tasks` for examples, launch behavior, task supervision and cancellation.
|
|
||||||
|
|
||||||
.. _datasette_start_background_tasks:
|
|
||||||
|
|
||||||
await .start_background_tasks()
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
Runs startup (if it has not already run) and launches every task registered with :ref:`datasette_add_background_task`.
|
|
||||||
|
|
||||||
See :ref:`internals_background_tasks` for when tasks launch automatically, and :ref:`internals_background_tasks_explicit` for examples and startup considerations in tests and headless programs.
|
|
||||||
|
|
||||||
.. _datasette_track_event:
|
.. _datasette_track_event:
|
||||||
|
|
||||||
|
|
@ -1618,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.
|
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:
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
Execute an internal request with the given HTTP method against that path.
|
||||||
|
|
||||||
These methods can be used with :ref:`internals_datasette_urls` - for example:
|
These methods can be used with :ref:`internals_datasette_urls` - for example:
|
||||||
|
|
@ -1660,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.
|
``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:
|
.. _internals_datasette_client_actor:
|
||||||
|
|
||||||
|
|
@ -1789,135 +1765,6 @@ Use the ``format="json"`` (or ``"csv"`` or other formats supported by plugins) a
|
||||||
|
|
||||||
These methods each return a ``datasette.utils.PrefixedUrlString`` object, which is a subclass of the Python ``str`` type. This allows the logic that considers the ``base_url`` setting to detect if that prefix has already been applied to the path.
|
These methods each return a ``datasette.utils.PrefixedUrlString`` object, which is a subclass of the Python ``str`` type. This allows the logic that considers the ``base_url`` setting to detect if that prefix has already been applied to the path.
|
||||||
|
|
||||||
.. _datasette_lifecycle:
|
|
||||||
|
|
||||||
Application lifecycle
|
|
||||||
=====================
|
|
||||||
|
|
||||||
Datasette guarantees a fixed sequence of events between the moment a ``Datasette`` instance is constructed and the moment its resources are released:
|
|
||||||
|
|
||||||
1. ``Datasette(...)`` — the constructor runs synchronously and does not run plugin hooks.
|
|
||||||
2. **Startup** — ``await datasette.invoke_startup()`` runs once: it populates the internal database's catalog of table schemas (:ref:`internals_internal`), loads canned queries and column type configuration, then calls every registered :ref:`plugin_hook_startup` hook, in plugin registration order. When Datasette is being served, table-count precomputation for immutable databases runs immediately before this, as part of the same startup sequence.
|
|
||||||
3. **Background-task launch** — once *every* ``startup`` hook has finished (not before), every task registered with :ref:`datasette_add_background_task` — by any plugin — is launched. A task registered by one plugin's ``startup`` hook can safely depend on state set up by another plugin's ``startup`` hook, because launch only happens after the whole round of hooks completes.
|
|
||||||
4. **Serving** — the instance handles requests (or, for headless or CLI use, does whatever the embedding program does with it).
|
|
||||||
5. **Shutdown** — triggered by the ASGI ``lifespan.shutdown`` event (Ctrl-C, ``SIGTERM``) or the end of a ``datasette serve`` process: every :ref:`plugin_hook_shutdown` hook runs first, while background tasks are still alive, so a plugin can tell its own task to wind down gracefully; every still-running background task is then cancelled and given a five-second grace period to actually stop; finally every database connection is released via :ref:`datasette_close`.
|
|
||||||
|
|
||||||
.. admonition:: Startup hooks run on the event loop that serves requests
|
|
||||||
|
|
||||||
In every trigger path below, ``startup`` hooks run on the same ``asyncio`` event loop that goes on to accept connections. It is safe to create loop-bound primitives — ``asyncio.Lock``, ``asyncio.Queue``, ``asyncio.Event``, a raw ``asyncio.create_task()`` call — inside a ``startup`` hook, and to register long-lived background work with :ref:`datasette_add_background_task` there.
|
|
||||||
|
|
||||||
Three trigger paths
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
- ``datasette serve`` (CLI) — startup and ``uvicorn.Server.serve()`` both run inside a single ``asyncio.run()`` call, so there is exactly one event loop for the whole life of the process.
|
|
||||||
- **ASGI lifespan** — ``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette, including requests seen by plugin :ref:`asgi_wrapper <plugin_asgi_wrapper>` middleware. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly.
|
|
||||||
- **First-request fallback** — an internal ``AsgiRunOnFirstRequest`` wrapper runs the same startup work as a safety net for hosts that never send ASGI lifespan events at all: some ASGI mounts, a bare ``app()`` embedded inside another framework, and :ref:`datasette.client <internals_datasette_client>` / test clients, which drive requests directly over ``httpx2.ASGITransport`` without ever emitting ``lifespan.startup``. It runs startup exactly once, the first time any non-lifespan scope arrives, guarded by a lock so that concurrent early requests can't run it twice.
|
|
||||||
|
|
||||||
All three paths call the same idempotent internal methods, so it is safe for more than one of them to fire — lifespan startup completing and then a first request arriving afterwards is a no-op the second time. A host that never sends lifespan events and never goes through the CLI degrades to first-request timing: startup runs on the first request instead of before it, exactly as Datasette always worked prior to this lifecycle guarantee. This is a deliberate fallback rather than a regression — see :ref:`internals_background_tasks` for how to opt out of launching background tasks (the ``--get`` CLI path) or drive startup and launch explicitly (tests, headless embedders).
|
|
||||||
|
|
||||||
.. _internals_background_tasks:
|
|
||||||
|
|
||||||
Background tasks
|
|
||||||
================
|
|
||||||
|
|
||||||
Datasette can supervise long-lived background work for plugins, such as polling for updates. Register work using :ref:`datasette_add_background_task` and use the returned :ref:`BackgroundTask <BackgroundTask>` handle to inspect or cancel it. See :ref:`datasette_lifecycle` for how background tasks fit into the application's startup and shutdown sequence.
|
|
||||||
|
|
||||||
Registering tasks
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
Use :ref:`datasette_add_background_task` to register an async callable, typically from a :ref:`plugin_hook_startup` hook. The callable takes one argument, the ``Datasette`` instance. Tasks can also be registered later, including from a request handler.
|
|
||||||
|
|
||||||
Registration is separate from launch. Calling this from a ``startup`` hook — the common case — buffers the task; core launches every registered task once *all* ``startup`` hooks have completed, as described in :ref:`datasette_lifecycle`. Calling it after launch has already happened — for example from a request handler, to start a per-job task dynamically — starts the task immediately instead.
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
async def poll_for_updates(datasette):
|
|
||||||
while True:
|
|
||||||
await do_one_poll(datasette)
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
datasette.add_background_task(
|
|
||||||
poll_for_updates, name="my-plugin-poller"
|
|
||||||
)
|
|
||||||
|
|
||||||
Datasette supervises each registered task:
|
|
||||||
|
|
||||||
- Keeps the task alive.
|
|
||||||
- Logs exceptions other than ``asyncio.CancelledError``, with their tracebacks, to the ``datasette.background_tasks`` logger. The exception is recorded on the handle's ``.exception``, and its ``.state`` becomes ``crashed``.
|
|
||||||
- Cancels running tasks during shutdown and gives them five seconds to stop. See :ref:`datasette_lifecycle`.
|
|
||||||
|
|
||||||
.. _internals_background_tasks_launch:
|
|
||||||
|
|
||||||
Launch matrix
|
|
||||||
-------------
|
|
||||||
|
|
||||||
Whether registered tasks actually launch depends on how the instance is being run:
|
|
||||||
|
|
||||||
.. list-table::
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Trigger
|
|
||||||
- Launches registered tasks?
|
|
||||||
* - ASGI lifespan (real server deployments)
|
|
||||||
- Yes, after ``lifespan.startup`` completes
|
|
||||||
* - First-request fallback (lifespan-less hosts)
|
|
||||||
- Yes, on the first request — parity with the lifespan case
|
|
||||||
* - ``datasette serve --get``
|
|
||||||
- Never
|
|
||||||
* - Tests / headless embedders
|
|
||||||
- Only if you call :ref:`datasette_start_background_tasks` explicitly
|
|
||||||
|
|
||||||
``datasette --get`` never launches background tasks, even though its one-shot request flows through the same first-request fallback as everything else: it sets an internal flag before making that request specifically to suppress the launch, since a one-shot CLI invocation has no server loop left running afterwards to keep any launched tasks alive.
|
|
||||||
|
|
||||||
.. _internals_background_tasks_explicit:
|
|
||||||
|
|
||||||
Starting tasks explicitly
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
Call :ref:`datasette_start_background_tasks` to run startup (if it has not already run) and launch every task registered with :ref:`datasette_add_background_task`. This is the explicit equivalent of what happens automatically via ASGI lifespan or the first-request fallback in a served deployment — the entry point for tests and headless embedders (a cron-style CLI command that wants supervised background work without running a server) that need background tasks without going through either of those paths.
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
datasette = Datasette(memory=True)
|
|
||||||
await datasette.start_background_tasks()
|
|
||||||
|
|
||||||
.. _BackgroundTask:
|
|
||||||
|
|
||||||
BackgroundTask objects
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
:ref:`datasette_add_background_task` returns a ``BackgroundTask`` handle with the following attributes:
|
|
||||||
|
|
||||||
``.name`` - string
|
|
||||||
The task's (unique) name.
|
|
||||||
|
|
||||||
``.state`` - string
|
|
||||||
One of ``registered`` (added but not yet launched), ``running``, ``completed`` (returned cleanly), ``crashed`` (raised an exception) or ``cancelled``.
|
|
||||||
|
|
||||||
``.task`` - ``asyncio.Task`` or ``None``
|
|
||||||
The underlying ``asyncio.Task``, once launched. ``None`` while still ``registered``.
|
|
||||||
|
|
||||||
``.exception`` - ``BaseException`` or ``None``
|
|
||||||
The exception that crashed the task, if ``.state`` is ``crashed``.
|
|
||||||
|
|
||||||
``.started_at`` - string or ``None``
|
|
||||||
ISO 8601 UTC timestamp of when the task was launched.
|
|
||||||
|
|
||||||
``.function`` - string
|
|
||||||
The callable's dotted module and qualified name, for example ``my_plugin.jobs.poll_for_updates``.
|
|
||||||
|
|
||||||
``.cancel()``
|
|
||||||
Cancel the task. If it has already launched, this cancels the underlying ``asyncio.Task`` — ``.state`` becomes ``cancelled`` once the cancellation is observed. If it has not launched yet, it is removed from the queue so it never runs.
|
|
||||||
|
|
||||||
This is also the shape of each entry returned by the ``/-/tasks`` JSON introspection endpoint — see :ref:`JsonDataView_tasks`.
|
|
||||||
|
|
||||||
.. _internals_permission_classes:
|
.. _internals_permission_classes:
|
||||||
|
|
||||||
Permission classes and utilities
|
Permission classes and utilities
|
||||||
|
|
@ -2174,12 +2021,10 @@ Example usage:
|
||||||
|
|
||||||
version = await db.execute_fn(get_version)
|
version = await db.execute_fn(get_version)
|
||||||
|
|
||||||
The call is traced as a ``db.query`` OpenTelemetry span carrying ``datasette.callback`` (the function's qualified name) rather than ``db.query.text``, since the SQL is whatever the function chooses to run - see :ref:`internals_telemetry`. Passing a named function gives the span a readable identity; a lambda reports ``<lambda>``.
|
|
||||||
|
|
||||||
.. _database_execute_write:
|
.. _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, transaction=True)
|
||||||
----------------------------------------------------------------------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -2218,13 +2063,6 @@ Each call to ``execute_write()`` will be executed inside a transaction. Pass
|
||||||
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
|
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
|
||||||
a transaction.
|
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.
|
|
||||||
|
|
||||||
.. _database_execute_write_script:
|
.. _database_execute_write_script:
|
||||||
|
|
||||||
await db.execute_write_script(sql, block=True)
|
await db.execute_write_script(sql, block=True)
|
||||||
|
|
@ -2259,8 +2097,6 @@ This method works like ``.execute_write()``, but instead of a SQL statement you
|
||||||
|
|
||||||
The function can then perform multiple actions, safe in the knowledge that it has exclusive access to the single writable connection for as long as it is executing.
|
The function can then perform multiple actions, safe in the knowledge that it has exclusive access to the single writable connection for as long as it is executing.
|
||||||
|
|
||||||
Like ``execute_fn()``, the call is traced as a ``db.query`` OpenTelemetry span carrying ``datasette.callback`` rather than ``db.query.text``, above the write-queue spans - see :ref:`internals_telemetry`. A named function gives the span a readable identity; a lambda reports ``<lambda>``.
|
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
||||||
``fn`` needs to be a regular function, not an ``async def`` function.
|
``fn`` needs to be a regular function, not an ``async def`` function.
|
||||||
|
|
@ -2317,25 +2153,7 @@ The value returned from ``await database.execute_write_fn(...)`` will be the ret
|
||||||
|
|
||||||
If your function raises an exception that exception will be propagated up to the ``await`` line.
|
If your function raises an exception that exception will be propagated up to the ``await`` line.
|
||||||
|
|
||||||
By default Datasette manages the transaction. For nested transactions, use `sqlite_utils.Database(conn).atomic() <https://sqlite-utils.datasette.io/en/stable/python-api.html#grouping-changes-with-db-atomic>`__. Pass ``transaction=False`` to manage transactions yourself.
|
By default your function will be executed inside a transaction. You can pass ``transaction=False`` to disable this behavior, though if you do that you should be careful to manually apply transactions - ideally using the ``with conn:`` pattern, or you may see ``OperationalError: database table is locked`` errors.
|
||||||
|
|
||||||
For example, archive an article and record the change in an audit log:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
import sqlite_utils
|
|
||||||
|
|
||||||
|
|
||||||
def archive_article(conn):
|
|
||||||
db = sqlite_utils.Database(conn)
|
|
||||||
with db.atomic():
|
|
||||||
db["articles"].update(1, {"archived": True})
|
|
||||||
db["audit_log"].insert(
|
|
||||||
{"article_id": 1, "action": "archive"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
await database.execute_write_fn(archive_article)
|
|
||||||
|
|
||||||
If you specify ``block=False`` the method becomes fire-and-forget, queueing your function to be executed and then allowing your code after the call to ``.execute_write_fn()`` to continue running while the underlying thread waits for an opportunity to run your function. A UUID representing the queued task will be returned. Any exceptions in your code will be silently swallowed.
|
If you specify ``block=False`` the method becomes fire-and-forget, queueing your function to be executed and then allowing your code after the call to ``.execute_write_fn()`` to continue running while the underlying thread waits for an opportunity to run your function. A UUID representing the queued task will be returned. Any exceptions in your code will be silently swallowed.
|
||||||
|
|
||||||
|
|
@ -2495,259 +2313,6 @@ The ``Database`` class also provides properties and methods for introspecting th
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.. _internals_telemetry:
|
|
||||||
|
|
||||||
OpenTelemetry
|
|
||||||
=============
|
|
||||||
|
|
||||||
Datasette uses the `opentelemetry-api <https://pypi.org/project/opentelemetry-api/>`__ library to provide `OpenTelemetry <https://opentelemetry.io>`__ traces and metrics for Datasette applications.
|
|
||||||
|
|
||||||
Datasette emits telemetry under the ``datasette`` instrumentation scope. To enable tracing, run Datasette under the ``opentelemetry-instrument`` agent.
|
|
||||||
|
|
||||||
Plugins can emit their own spans and metrics alongside these, using the same registry classes and test helpers core uses - see :ref:`plugin_telemetry`.
|
|
||||||
|
|
||||||
.. _internals_telemetry_turning_on:
|
|
||||||
|
|
||||||
Turning tracing on
|
|
||||||
------------------
|
|
||||||
|
|
||||||
Install an OpenTelemetry SDK, an exporter and the instrumentation agent, then launch Datasette through ``opentelemetry-instrument``:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
pip install opentelemetry-distro opentelemetry-exporter-otlp
|
|
||||||
|
|
||||||
OTEL_SERVICE_NAME=datasette \
|
|
||||||
OTEL_METRICS_EXPORTER=console \
|
|
||||||
OTEL_LOGS_EXPORTER=console \
|
|
||||||
OTEL_TRACES_EXPORTER=console \
|
|
||||||
opentelemetry-instrument datasette mydb.db
|
|
||||||
|
|
||||||
Or using ``uv run``:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
OTEL_SERVICE_NAME=datasette \
|
|
||||||
OTEL_METRICS_EXPORTER=console \
|
|
||||||
OTEL_LOGS_EXPORTER=console \
|
|
||||||
OTEL_TRACES_EXPORTER=console \
|
|
||||||
uv run \
|
|
||||||
--with opentelemetry-distro \
|
|
||||||
--with opentelemetry-exporter-otlp \
|
|
||||||
opentelemetry-instrument datasette mydb.db
|
|
||||||
|
|
||||||
This will output pretty-printed JSON telemetry to your console, representing requests and database queries executed by Datasette.
|
|
||||||
|
|
||||||
To use an exporter endpoint, set ``OTEL_EXPORTER_OTLP_ENDPOINT`` to a URL, set ``OTEL_TRACES_EXPORTER`` to ``otlp``, and set the other exporters to ``none``:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
OTEL_SERVICE_NAME=datasette \
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
|
|
||||||
OTEL_METRICS_EXPORTER=none \
|
|
||||||
OTEL_LOGS_EXPORTER=none \
|
|
||||||
OTEL_TRACES_EXPORTER=otlp \
|
|
||||||
opentelemetry-instrument datasette mydb.db
|
|
||||||
|
|
||||||
On macOS one easy option for a port 4317 OTLP endpoint is `otel-tui <https://github.com/ymtdzzz/otel-tui>`__:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
brew install ymtdzzz/tap/otel-tui
|
|
||||||
otel-tui
|
|
||||||
|
|
||||||
Traces sent to port 4317 by Datasette will now display in a TUI in your terminal.
|
|
||||||
|
|
||||||
A few things catch people out:
|
|
||||||
|
|
||||||
- **You must use "opentelemetry-instrument datasette"**. Running just ``OTEL_TRACES_EXPORTER=console datasette mydb.db`` produces no telemetry.
|
|
||||||
- **Spans do not appear immediately.** The SDK's default ``BatchSpanProcessor`` flushes on a timer, every 5 seconds. Either wait, or stop the process - shutdown triggers a final flush - or set ``OTEL_BSP_SCHEDULE_DELAY=1000`` while you are experimenting.
|
|
||||||
- **Always set** ``OTEL_SERVICE_NAME``. Without it the SDK's default resource reports a ``service.name`` of ``unknown_service``, and your traces will be filed under that instead of under a name you can search for.
|
|
||||||
|
|
||||||
.. _internals_telemetry_requests:
|
|
||||||
|
|
||||||
Span reference
|
|
||||||
--------------
|
|
||||||
|
|
||||||
Datasette emits six spans. One covers the HTTP request, and is the root everything else raised while serving that request hangs from. Four describe the database layer - one per query, one for the work that query does inside a SQL worker thread, and two more for the write queue. The sixth covers startup. Attribute names use the ``datasette.*`` prefix for Datasette-specific data, alongside standard OpenTelemetry attributes such as ``db.system``.
|
|
||||||
|
|
||||||
A request to a table page produces a span named, in full::
|
|
||||||
|
|
||||||
GET /(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$
|
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
from telemetry_doc import spans
|
|
||||||
spans(cog)
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
``{http.request.method} {http.route}``
|
|
||||||
One span per HTTP request, containing spans from plugin middleware and database operations. Named for the HTTP method and matched route, or just the method if no route matches. Incoming ``traceparent`` headers are extracted using the global propagator to continue the caller's trace. Incoming ``baggage`` is not propagated into plugin or downstream context in this release. Set ``OTEL_PROPAGATORS=none`` to disable extraction. For public instances, strip trace context headers at your proxy if callers should not supply trace context.
|
|
||||||
|
|
||||||
Kind: ``SERVER``.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``http.request.method`` - The HTTP request method. Methods outside the nine defined by RFC 9110 and RFC 5789 are recorded as ``_OTHER``.
|
|
||||||
- ``http.route`` *(optional)* - The regular expression for the matched route, for example ``/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$`` for a table page. Use this attribute to group requests by route. Omitted when no route matches.
|
|
||||||
- ``url.path`` - The URL path, excluding the query string.
|
|
||||||
- ``url.scheme`` - ``http`` or ``https``.
|
|
||||||
- ``server.address`` *(optional)* - The ``Host`` header, including any ``:port`` suffix. This value is supplied by the client.
|
|
||||||
- ``user_agent.original`` *(optional)* - The ``User-Agent`` header, verbatim. Omitted if the client sent none.
|
|
||||||
- ``http.response.status_code`` *(optional)* - The HTTP response status code. Omitted if no response was started.
|
|
||||||
- ``error.type`` *(optional)* - The exception class name for a failed operation. On HTTP spans, also set to the status code as a string for 5xx responses. A 4xx response alone does not set this attribute or an error status.
|
|
||||||
- ``datasette.internal_client`` *(optional)* - ``True`` for requests made through ``datasette.client``. Calls made inside another request produce a nested ``SERVER`` span. Filter on this attribute to exclude internal requests from request counts. Omitted for requests received over the network.
|
|
||||||
|
|
||||||
``db.query``
|
|
||||||
A SQL operation, including time spent queued for a worker thread. For ``block=False`` writes, the span ends after the write is queued. Callback methods record ``datasette.callback`` in place of ``db.query.text``.
|
|
||||||
|
|
||||||
Kind: ``CLIENT``.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.system`` - Always ``sqlite``.
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
- ``db.query.text`` *(optional)* - The SQL, truncated to 2048 characters. Bound parameter values are not recorded. For callback methods, ``datasette.callback`` is recorded instead.
|
|
||||||
- ``datasette.callback`` *(optional)* - The qualified name of the Python callable passed to ``execute_fn()``, ``execute_write_fn()`` or ``execute_isolated_fn()``, for example ``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of ``db.query.text``. Lambdas appear as ``<lambda>``; use a named function for a more descriptive span.
|
|
||||||
- ``db.operation.name`` *(optional)* - The statement's leading keyword, such as ``SELECT``, ``INSERT`` or ``CREATE``, if it matches the supported allowlist. Statements beginning with a common table expression report ``WITH``. Omitted for unrecognized keywords and ``execute_write_script()``.
|
|
||||||
- ``datasette.param_count`` *(optional)* - Number of bound parameters. Recorded instead of the values themselves.
|
|
||||||
- ``datasette.param_sets`` *(optional)* - Number of parameter sets consumed by ``execute_write_many()``. The parameter values are not recorded.
|
|
||||||
- ``datasette.time_limit_ms`` *(optional)* - Time limit applied to the read query, in milliseconds: :ref:`setting_sql_time_limit_ms` or a shorter ``custom_time_limit``.
|
|
||||||
- ``datasette.rows_returned`` *(optional)* - Number of rows returned by a successful read query.
|
|
||||||
- ``datasette.truncated`` *(optional)* - True if the result was cut short by :ref:`setting_max_returned_rows`.
|
|
||||||
- ``datasette.interrupted`` *(optional)* - True if the query exceeded its time limit. The span status is set to ``ERROR`` unless the caller used a ``custom_time_limit`` shorter than :ref:`setting_sql_time_limit_ms`, in which case the status is left unset.
|
|
||||||
- ``datasette.sql_error_suppressed`` *(optional)* - True for a non-timeout SQL error with ``log_sql_errors=False``. The exception is still raised, but the span status is left unset.
|
|
||||||
- ``datasette.executescript`` *(optional)* - True for ``execute_write_script()``, which runs multiple statements.
|
|
||||||
- ``datasette.executemany`` *(optional)* - True for ``execute_write_many()``, which runs one statement against many parameter sets.
|
|
||||||
|
|
||||||
``db.query.execute``
|
|
||||||
The read executing inside a SQL worker thread. Child of ``db.query``; the gap between the two is time spent waiting for a thread.
|
|
||||||
|
|
||||||
No attributes.
|
|
||||||
|
|
||||||
``db.write.queue_wait``
|
|
||||||
Time a write spent waiting in its database's write queue. For ``block=True``, this is a child of ``db.query``. For ``block=False``, it is a root span linked to the span that queued the write, since the write can outlive that request.
|
|
||||||
|
|
||||||
No attributes.
|
|
||||||
|
|
||||||
``db.write.execute``
|
|
||||||
The write executing on the write thread. For ``block=True``, this is a child of ``db.query``. For ``block=False``, it is a root span linked to the span that queued the write.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``datasette.isolated_connection`` - True if the write ran on its own connection rather than the shared write connection.
|
|
||||||
- ``datasette.transaction`` - False for statements such as ``VACUUM`` that cannot run inside a transaction.
|
|
||||||
|
|
||||||
``datasette.startup``
|
|
||||||
Startup work performed by ``invoke_startup()``, including registration hooks, schema catalog updates, saved queries, column type configuration and the ``startup`` hook. Runs during instance startup, either before serving requests or as part of the first request.
|
|
||||||
|
|
||||||
No attributes.
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
.. _internals_telemetry_metrics:
|
|
||||||
|
|
||||||
Metric reference
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Spans describe events; metrics describe levels and rates. Metrics can be used to answer questions like "Am I saturating my :ref:`setting_num_sql_threads` threads right now?". Trace sampling drops a portion of traces but does not drop any metrics.
|
|
||||||
|
|
||||||
Datasette configures duration histograms in **seconds**. OpenTelemetry's default boundaries are tuned for milliseconds but these would file every SQLite query into a single bucket, making quantile queries meaningless.
|
|
||||||
|
|
||||||
This reference is also generated from ``datasette/telemetry_registry.py``:
|
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
from telemetry_doc import metrics
|
|
||||||
metrics(cog)
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
``db.client.operation.duration``
|
|
||||||
Histogram, unit ``s``. Duration of a SQL operation, including callback-based calls such as ``execute_fn()``. For ``block=False`` writes, measures enqueue time.
|
|
||||||
|
|
||||||
Bucket boundaries: ``0.0001``, ``0.0005``, ``0.001``, ``0.005``, ``0.01``, ``0.05``, ``0.1``, ``0.5``, ``1``, ``5``, ``10``.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.system`` - Always ``sqlite``.
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
- ``datasette.operation`` - Whether the operation was a read or a write. One of: ``read``, ``write``.
|
|
||||||
- ``error.type`` *(optional)* - The exception class name for a failed operation. On HTTP spans, also set to the status code as a string for 5xx responses. A 4xx response alone does not set this attribute or an error status.
|
|
||||||
|
|
||||||
``datasette.write.queue_wait``
|
|
||||||
Histogram, unit ``s``. Time each write waited in its database's write queue.
|
|
||||||
|
|
||||||
Bucket boundaries: ``0.0001``, ``0.0005``, ``0.001``, ``0.005``, ``0.01``, ``0.05``, ``0.1``, ``0.5``, ``1``, ``5``, ``10``.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
|
|
||||||
``datasette.sql.queries.interrupted``
|
|
||||||
Counter, unit ``{query}``. Queries cancelled for exceeding :ref:`setting_sql_time_limit_ms`. A rising rate can indicate that queries need optimization or a higher time limit. Caller-selected timeouts shorter than this limit, such as those used for facet suggestion, are excluded.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
|
|
||||||
``datasette.sql.threads.limit``
|
|
||||||
Observable gauge, unit ``{thread}``. Maximum concurrent read queries, configured by :ref:`setting_num_sql_threads`. Not reported when ``num_sql_threads`` is ``0``.
|
|
||||||
|
|
||||||
No attributes.
|
|
||||||
|
|
||||||
``datasette.sql.threads.queue_depth``
|
|
||||||
Observable gauge, unit ``{query}``. Read queries waiting for a free SQL thread. Sustained values above zero indicate a saturated read pool.
|
|
||||||
|
|
||||||
No attributes.
|
|
||||||
|
|
||||||
``datasette.sql.queries.pending``
|
|
||||||
Observable gauge, unit ``{query}``. Read queries submitted to the pool and not yet complete. Sum across databases and compare with ``datasette.sql.threads.limit`` to assess pool usage.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
|
|
||||||
``datasette.write.queue_depth``
|
|
||||||
Observable gauge, unit ``{write}``. Writes waiting for a database's single write thread. Increasing ``num_sql_threads`` does not increase write concurrency. Not reported for databases that have never been written to.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
|
|
||||||
``datasette.connections.open``
|
|
||||||
Observable gauge, unit ``{connection}``. Open SQLite connections managed by Datasette.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
- ``db.namespace`` - Name of the database being queried.
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Exemplars
|
|
||||||
~~~~~~~~~
|
|
||||||
|
|
||||||
An OpenTelemetry `exemplar <https://opentelemetry.io/docs/specs/otel/metrics/data-model/#exemplars>`__ attaches a trace ID and span ID to one sample backing a histogram measurement. Where a spike in ``db.client.operation.duration`` alone tells you "queries were slow sometime in this minute", the exemplar attached to one of the samples in that spike gives you the trace ID of an actual slow query to open:
|
|
||||||
|
|
||||||
.. code-block:: text
|
|
||||||
|
|
||||||
db.client.operation.duration count=4
|
|
||||||
exemplars: 4
|
|
||||||
value=0.001564s trace_id=ddfaf45fd4e14913497d7efeac95f381 span_id=fd5792bdbb01e533
|
|
||||||
value=0.006320s trace_id=34aea775ade11a3c5f716695731000fe span_id=25ed9e29dd84dbee
|
|
||||||
value=0.045253s trace_id=a65cb58d1460a179f0d04046ff51ed0d span_id=7f34d6378c85d062
|
|
||||||
value=0.305240s trace_id=6089f4c515c221c0ca7bb53667b37ac8 span_id=0516f4a6641eaa0b
|
|
||||||
|
|
||||||
.. _internals_telemetry_privacy:
|
|
||||||
|
|
||||||
Privacy and safety
|
|
||||||
------------------
|
|
||||||
|
|
||||||
Datasette does not configure a telemetry exporter itself. If you enable one, traces may contain sensitive information:
|
|
||||||
|
|
||||||
- **SQL text is truncated to 2048 characters.** Literal values in that text are retained. Bound SQL parameter values are not added as attributes; ``datasette.param_count`` records only their count.
|
|
||||||
- **Request spans include URL paths, host names and User-Agent headers.** Paths can include identifying values such as row primary keys. Core does not add actor identifiers, cookies, authorization headers, client IP addresses or a ``url.query`` attribute.
|
|
||||||
- **Exception messages and tracebacks may be recorded.** These can contain data from requests or database operations.
|
|
||||||
|
|
||||||
Review what your application and plugins record before exporting telemetry to an external service. Restrict access to exported data and configure redaction or filtering where needed.
|
|
||||||
|
|
||||||
.. _internals_csrf:
|
.. _internals_csrf:
|
||||||
|
|
||||||
CSRF protection
|
CSRF protection
|
||||||
|
|
@ -2769,20 +2334,7 @@ No token, cookie, or hidden form field is needed. Any ``<form method="POST">`` i
|
||||||
Datasette's internal database
|
Datasette's internal database
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown.
|
Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown. Users of Datasette can optionally pass in a ``--internal`` flag to specify the path to a SQLite database to use as the internal database, which will persist internal data across Datasette instances.
|
||||||
|
|
||||||
To persist internal data across Datasette instances, use the ``--internal`` option to specify the path to a SQLite database:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
datasette mydatabase.db --internal internal.db
|
|
||||||
|
|
||||||
You can also set the ``DATASETTE_INTERNAL`` environment variable to specify this path without passing ``--internal`` each time:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
export DATASETTE_INTERNAL=/path/to/internal.db
|
|
||||||
datasette mydatabase.db
|
|
||||||
|
|
||||||
Datasette maintains tables called ``catalog_databases``, ``catalog_tables``, ``catalog_views``, ``catalog_columns``, ``catalog_indexes``, ``catalog_foreign_keys`` with details of the attached databases and their schemas. These tables should not be considered a stable API - they may change between Datasette releases.
|
Datasette maintains tables called ``catalog_databases``, ``catalog_tables``, ``catalog_views``, ``catalog_columns``, ``catalog_indexes``, ``catalog_foreign_keys`` with details of the attached databases and their schemas. These tables should not be considered a stable API - they may change between Datasette releases.
|
||||||
|
|
||||||
|
|
@ -3071,12 +2623,12 @@ This example uses trace to record the start, end and duration of any HTTP GET re
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette.tracer import trace
|
from datasette.tracer import trace
|
||||||
import httpx2
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
async def fetch_url(url):
|
async def fetch_url(url):
|
||||||
with trace("fetch-url", 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)
|
return await client.get(url)
|
||||||
|
|
||||||
.. _internals_tracer_trace_child_tasks:
|
.. _internals_tracer_trace_child_tasks:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Each of these pages can be viewed in your browser. Add ``.json`` to the URL to g
|
||||||
|
|
||||||
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
|
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
|
||||||
|
|
||||||
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads``, ``/-/tasks`` and ``/-/actions``, whose shapes may change in future releases.
|
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases.
|
||||||
|
|
||||||
.. _JsonDataView_metadata:
|
.. _JsonDataView_metadata:
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ Shows the version of Datasette, Python and SQLite. `Versions example <https://la
|
||||||
/-/plugins
|
/-/plugins
|
||||||
----------
|
----------
|
||||||
|
|
||||||
Shows a list of currently installed plugins and their versions. `Plugins example <https://datasette.io/-/plugins>`_:
|
Shows a list of currently installed plugins and their versions. `Plugins example <https://san-francisco.datasettes.com/-/plugins>`_:
|
||||||
|
|
||||||
.. code-block:: json
|
.. code-block:: json
|
||||||
|
|
||||||
|
|
@ -278,42 +278,6 @@ Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``per
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
.. _JsonDataView_tasks:
|
|
||||||
|
|
||||||
/-/tasks
|
|
||||||
--------
|
|
||||||
|
|
||||||
Shows the state of every supervised background task registered with :ref:`datasette.add_background_task() <datasette_add_background_task>`; see also :ref:`BackgroundTask <BackgroundTask>` for what each field below means, and :ref:`datasette_lifecycle` for when tasks are launched. This endpoint requires the ``permissions-debug`` permission, since a crashed task's ``exception`` field can reveal internals such as file paths or query text:
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"name": "my_plugin.poll_for_updates",
|
|
||||||
"state": "running",
|
|
||||||
"function": "my_plugin.poll_for_updates",
|
|
||||||
"started_at": "2026-07-30T12:00:00+00:00",
|
|
||||||
"exception": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "my_plugin.broken_task",
|
|
||||||
"state": "crashed",
|
|
||||||
"function": "my_plugin.broken_task",
|
|
||||||
"started_at": "2026-07-30T12:00:00+00:00",
|
|
||||||
"exception": "ValueError('something went wrong')"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"launched": true
|
|
||||||
}
|
|
||||||
|
|
||||||
Each entry's ``function`` identifies the callable by its dotted module and qualified name.
|
|
||||||
|
|
||||||
Each entry's ``state`` is one of ``registered`` (added but not yet launched), ``running``, ``completed``, ``crashed`` or ``cancelled``. ``exception`` is a one-line ``repr()`` of the exception for a ``crashed`` task, or ``null`` otherwise - the full traceback is written to the ``datasette.background_tasks`` logger instead, to keep this payload skimmable.
|
|
||||||
|
|
||||||
The top-level ``launched`` flag reports whether the instance has run its one-time background task launch (after ``startup`` hooks finish, or via lifespan/first-request/:ref:`start_background_tasks() <datasette_start_background_tasks>`). It distinguishes "no tasks have been registered" (``tasks`` is empty either way) from "tasks are registered but nothing has armed the launch yet" (``launched`` is ``false`` and every task's ``state`` is still ``registered``) - useful when debugging a host that never triggers Datasette's lifespan events.
|
|
||||||
|
|
||||||
.. _JsonDataView_actor:
|
.. _JsonDataView_actor:
|
||||||
|
|
||||||
/-/actor
|
/-/actor
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
.. _javascript_plugins:
|
.. _javascript_plugins:
|
||||||
|
|
||||||
JavaScript in plugins
|
JavaScript plugins
|
||||||
=====================
|
==================
|
||||||
|
|
||||||
Datasette can run custom JavaScript in several different ways:
|
Datasette can run custom JavaScript in several different ways:
|
||||||
|
|
||||||
|
|
@ -474,136 +474,6 @@ Custom fields are responsible for preserving the accessibility of the form:
|
||||||
|
|
||||||
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
|
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
|
||||||
|
|
||||||
.. _javascript_plugins_modals:
|
|
||||||
|
|
||||||
Reusable modal dialogs
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
Plugins can use ``DatasetteModal`` to create dialogs with the same appearance and keyboard behavior as Datasette's built-in dialogs. The component provides a native modal dialog, shared styles, Escape and backdrop dismissal, busy-state dismissal guards and focus restoration.
|
|
||||||
|
|
||||||
Creating a dialog
|
|
||||||
~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
``DatasetteModal.create()`` returns a detached ``<datasette-modal>`` element containing a native ``<dialog>``. Access that native element through ``modal.dialog``. Populate its content before appending the wrapper to the page, then call ``modal.show()`` to open it.
|
|
||||||
|
|
||||||
This example uses the :ref:`datasette_init event <javascript_datasette_init>` to add a button that opens a dialog:
|
|
||||||
|
|
||||||
.. literalinclude:: shots/modal-example.js
|
|
||||||
:language: javascript
|
|
||||||
|
|
||||||
Clicking that button opens this dialog:
|
|
||||||
|
|
||||||
.. only:: not latex
|
|
||||||
|
|
||||||
.. image:: images/modal-example.webp
|
|
||||||
:width: 584px
|
|
||||||
:alt: A dialog titled Example dialog, with the text "This dialog uses Datasette's shared styles and keyboard behavior." and a Close button in the footer, shown in front of a dimmed Datasette page
|
|
||||||
|
|
||||||
Opening and closing
|
|
||||||
~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
``modal.show(options)``
|
|
||||||
Opens the native dialog using ``showModal()``. ``options`` is an optional object with these optional properties:
|
|
||||||
|
|
||||||
- ``returnFocusTo`` (DOM element): Focus returns to this element when the dialog closes. Defaults to the element with keyboard focus immediately before the dialog opens.
|
|
||||||
- ``initialFocus`` (DOM element or function): An element inside the dialog whose ``focus()`` method will be called, or a function called with no arguments that moves focus itself.
|
|
||||||
|
|
||||||
Use this to focus on an input field when the dialog opens.
|
|
||||||
|
|
||||||
``modal.close(options)``
|
|
||||||
Closes the dialog directly. ``options`` is an optional object with one optional property:
|
|
||||||
|
|
||||||
- ``restoreFocus`` (boolean): Whether closing returns focus to the element recorded by ``show()``. Defaults to ``true``.
|
|
||||||
|
|
||||||
``modal.requestClose(source)``
|
|
||||||
Alternative to ``.close()`` that requests dismissal through the busy-state and ``beforeClose`` guards described below. Returns ``true`` if it closes the dialog, or ``false`` if the dialog is already closed or a guard prevents dismissal. Close and Cancel buttons should use this method.
|
|
||||||
|
|
||||||
``source`` is an optional string that is passed to ``beforeClose`` and identifies what requested dismissal. Datasette supplies ``"escape"`` for the Escape key or a native cancel event and ``"backdrop"`` for a click outside the dialog. ``source`` defaults to ``"cancel"``.
|
|
||||||
|
|
||||||
Listen for the native dialog's ``close`` event to clean up resources such as pending requests or custom fields:
|
|
||||||
|
|
||||||
.. code-block:: javascript
|
|
||||||
|
|
||||||
modal.dialog.addEventListener("close", () => {
|
|
||||||
// Clean up content-specific resources here.
|
|
||||||
});
|
|
||||||
|
|
||||||
If the dialog is no longer needed, remove the wrapper with ``modal.remove()``.
|
|
||||||
|
|
||||||
Dismissal guards and busy state
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
You can set ``modal.beforeClose`` to a synchronous function that receives the ``source`` string described above and returns ``false`` in order to keep the dialog open.
|
|
||||||
|
|
||||||
Use ``source`` to decide what to do. This example prompts the user to ask if they want to discard unsaved changes - for example if they click outside the modal or hit Escape - but doesn't prompt them if they clicked a button like the Close one above that sets the ``source`` string to ``cancel``.
|
|
||||||
|
|
||||||
.. code-block:: javascript
|
|
||||||
|
|
||||||
modal.beforeClose = (source) => {
|
|
||||||
if (source === "cancel") return true;
|
|
||||||
return confirm("Discard unsaved changes?");
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
Set ``modal.busy = true`` while saving to prevent user dismissal. While busy, ``requestClose()`` returns ``false`` without calling ``beforeClose``.
|
|
||||||
|
|
||||||
If an operation fails, set ``modal.busy = false`` so the user can retry or close the dialog. A successful operation can call ``modal.close()`` even while busy.
|
|
||||||
|
|
||||||
.. _javascript_plugins_modal_classes:
|
|
||||||
|
|
||||||
Shared CSS classes
|
|
||||||
~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
The classes in the example above provide built-in styling. This dialog uses every class listed below, including a ``modal-meta`` count in the header and ``footer-info`` text next to ``modal-btn-ghost`` and ``modal-btn-primary`` buttons in the footer:
|
|
||||||
|
|
||||||
.. only:: not latex
|
|
||||||
|
|
||||||
.. image:: images/modal-classes.webp
|
|
||||||
:width: 584px
|
|
||||||
:alt: A dialog titled Export rows with a "3 selected" badge in its header, a list of three plant names in the body, and a footer containing the text "CSV, UTF-8", a Cancel button and a blue Export button
|
|
||||||
|
|
||||||
The following classes can be used by your modal:
|
|
||||||
|
|
||||||
``datasette-modal``
|
|
||||||
Added automatically to the native ``<dialog>`` when the wrapper is connected to the page. Provides the dialog's sizing, background, rounded corners, shadow, backdrop and animations.
|
|
||||||
|
|
||||||
``modal-header``
|
|
||||||
Adds padding, a bottom border and a horizontal layout for the title and optional metadata.
|
|
||||||
|
|
||||||
``modal-title``
|
|
||||||
Sets the title's font size, weight and color. Use ``aria-labelledby`` to associate the title with the dialog.
|
|
||||||
|
|
||||||
``modal-meta``
|
|
||||||
Styles optional metadata, such as a selected-item count, as small monospace text with a rounded background.
|
|
||||||
|
|
||||||
``modal-body``
|
|
||||||
Adds padding and makes overflowing content scroll while the header and footer remain visible. Sets ``min-height: 0``, ``overflow: auto`` and ``padding: 16px 24px 24px``.
|
|
||||||
|
|
||||||
``modal-footer``
|
|
||||||
Adds padding, a top border and a background to the action area. Arranges its contents horizontally, with buttons aligned to the right.
|
|
||||||
|
|
||||||
``footer-info``
|
|
||||||
Styles supporting text in the footer and lets it fill the space before the action buttons.
|
|
||||||
|
|
||||||
``modal-btn``
|
|
||||||
Provides base button styling, including padding, rounded corners, font and disabled appearance. Use it together with ``modal-btn-primary`` or ``modal-btn-ghost``.
|
|
||||||
|
|
||||||
``modal-btn-primary``
|
|
||||||
Gives a button an accent-colored background and white text, suitable for a primary action such as Save.
|
|
||||||
|
|
||||||
``modal-btn-ghost``
|
|
||||||
Gives a button a transparent background, muted text and a border, suitable for a secondary action such as Close or Cancel.
|
|
||||||
|
|
||||||
These button classes are also used by Datasette's built-in dialogs.
|
|
||||||
|
|
||||||
You can customize layout and sizing without adding extra classes. For example, this CSS uses the dialog's existing ID to widen it while keeping it inside the viewport:
|
|
||||||
|
|
||||||
.. code-block:: css
|
|
||||||
|
|
||||||
dialog#my-plugin-dialog {
|
|
||||||
width: min(720px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.. _javascript_datasette_manager_selectors:
|
.. _javascript_datasette_manager_selectors:
|
||||||
|
|
||||||
Selectors
|
Selectors
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ Some JSON endpoints are **exempt** from this promise:
|
||||||
debug playground.
|
debug playground.
|
||||||
- Debug and support endpoints are documented so you can use them, but their
|
- Debug and support endpoints are documented so you can use them, but their
|
||||||
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
|
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
|
||||||
:ref:`/-/tasks <JsonDataView_tasks>`,
|
|
||||||
:ref:`/-/actions <JsonDataView_actions>`,
|
:ref:`/-/actions <JsonDataView_actions>`,
|
||||||
the :ref:`permission debug endpoints <PermissionsDebugView>`
|
the :ref:`permission debug endpoints <PermissionsDebugView>`
|
||||||
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the
|
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the
|
||||||
|
|
@ -1327,23 +1326,6 @@ The following extras are available for arbitrary SQL query responses and stored,
|
||||||
|
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
.. _TableCountView:
|
|
||||||
|
|
||||||
Counting all matching rows
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
``POST /<database>/<table>/-/count`` returns an exact count of the rows matching the table's query string filters::
|
|
||||||
|
|
||||||
POST /fixtures/facetable/-/count?state=CA
|
|
||||||
|
|
||||||
{"ok": true, "count": 10}
|
|
||||||
|
|
||||||
The endpoint supports the same column, search and plugin filters as the table page. Pagination and display options such as ``_next``, ``_size`` and ``_sort`` do not affect the count.
|
|
||||||
|
|
||||||
This requires ``view-table`` permission. ``execute-sql`` permission is only needed if using ``_where`` filters.
|
|
||||||
|
|
||||||
Unlike the ``count`` extra, this count is not capped by the row count limit. The usual SQL time limit still applies; a timed-out count returns a 400 JSON error.
|
|
||||||
|
|
||||||
.. _TableAutocompleteView:
|
.. _TableAutocompleteView:
|
||||||
|
|
||||||
Table autocomplete
|
Table autocomplete
|
||||||
|
|
@ -1679,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.
|
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:
|
.. _ExecuteWriteView:
|
||||||
|
|
||||||
Executing write SQL
|
Executing write SQL
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ Extra template variables that should be made available in the rendered template
|
||||||
``datasette`` - :ref:`internals_datasette`
|
``datasette`` - :ref:`internals_datasette`
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
|
||||||
|
|
||||||
This hook supports the following return values:
|
This hook can return one of three different types:
|
||||||
|
|
||||||
Dictionary
|
Dictionary
|
||||||
If you return a dictionary its keys and values will be merged into the template context.
|
If you return a dictionary its keys and values will be merged into the template context.
|
||||||
|
|
@ -228,9 +228,6 @@ Function that returns a dictionary
|
||||||
Function that returns an awaitable function that returns a dictionary
|
Function that returns an awaitable function that returns a dictionary
|
||||||
You can also return a function which returns an awaitable function which returns a dictionary.
|
You can also return a function which returns an awaitable function which returns a dictionary.
|
||||||
|
|
||||||
``None``
|
|
||||||
The hook itself, or a function or awaitable it returns, can return ``None`` when no extra variables are needed. Variables returned by other plugins are still included.
|
|
||||||
|
|
||||||
Datasette runs Jinja2 in `async mode <https://jinja.palletsprojects.com/en/2.10.x/api/#async-support>`__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template.
|
Datasette runs Jinja2 in `async mode <https://jinja.palletsprojects.com/en/2.10.x/api/#async-support>`__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template.
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
@ -257,6 +254,8 @@ This example returns an awaitable function which adds a list of ``hidden_table_n
|
||||||
return {
|
return {
|
||||||
"hidden_table_names": await db.hidden_table_names()
|
"hidden_table_names": await db.hidden_table_names()
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
return hidden_table_names
|
return hidden_table_names
|
||||||
|
|
||||||
|
|
@ -496,10 +495,10 @@ Lets you customize the display of values within table cells in the HTML table vi
|
||||||
The name of the column being rendered
|
The name of the column being rendered
|
||||||
|
|
||||||
``table`` - string or None
|
``table`` - string or None
|
||||||
The name of the table or view - or ``None`` if this is a custom SQL query
|
The name of the table - or ``None`` if this is a custom SQL query
|
||||||
|
|
||||||
``pks`` - list of strings
|
``pks`` - list of strings
|
||||||
The primary key column names for the table being rendered. For tables without an explicitly defined primary key, this will be ``["rowid"]``. For custom SQL queries and views, this will be an empty list ``[]``.
|
The primary key column names for the table being rendered. For tables without an explicitly defined primary key, this will be ``["rowid"]``. For custom SQL queries and views (where ``table`` is ``None``), this will be an empty list ``[]``.
|
||||||
|
|
||||||
``database`` - string
|
``database`` - string
|
||||||
The name of the database
|
The name of the database
|
||||||
|
|
@ -1108,7 +1107,7 @@ Return an `ASGI <https://asgi.readthedocs.io/>`__ middleware wrapper function th
|
||||||
|
|
||||||
This is a very powerful hook. You can use it to manipulate the entire Datasette response, or even to configure new URL routes that will be handled by your own custom code.
|
This is a very powerful hook. You can use it to manipulate the entire Datasette response, or even to configure new URL routes that will be handled by your own custom code.
|
||||||
|
|
||||||
You can write your ASGI code directly against the low-level specification, or you can use the middleware utilities provided by an ASGI framework such as `Starlette <https://starlette.dev/middleware/>`__.
|
You can write your ASGI code directly against the low-level specification, or you can use the middleware utilities provided by an ASGI framework such as `Starlette <https://www.starlette.io/middleware/>`__.
|
||||||
|
|
||||||
This example plugin adds a ``x-databases`` HTTP header listing the currently attached databases:
|
This example plugin adds a ``x-databases`` HTTP header listing the currently attached databases:
|
||||||
|
|
||||||
|
|
@ -1158,7 +1157,7 @@ Examples: `datasette-cors <https://datasette.io/plugins/datasette-cors>`__, `dat
|
||||||
startup(datasette)
|
startup(datasette)
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
This hook fires when the Datasette application server first starts up. It runs on the same event loop that goes on to serve requests, so it is safe to create loop-bound primitives and register background work here — see :ref:`datasette_lifecycle` for the full guarantee and the three ways startup can be triggered.
|
This hook fires when the Datasette application server first starts up.
|
||||||
|
|
||||||
Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails:
|
Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails:
|
||||||
|
|
||||||
|
|
@ -1196,7 +1195,6 @@ Potential use-cases:
|
||||||
* Create database tables that a plugin needs on startup
|
* Create database tables that a plugin needs on startup
|
||||||
* Validate the configuration for a plugin on startup, and raise an error if it is invalid
|
* Validate the configuration for a plugin on startup, and raise an error if it is invalid
|
||||||
* Raise a ``datasette.utils.StartupError("message")`` exception to prevent Datasette from starting and display that message to the user.
|
* Raise a ``datasette.utils.StartupError("message")`` exception to prevent Datasette from starting and display that message to the user.
|
||||||
* Register supervised long-lived background work using :ref:`datasette_add_background_task`, which core launches once every plugin's ``startup()`` hook has finished.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
|
|
@ -1213,31 +1211,6 @@ Potential use-cases:
|
||||||
|
|
||||||
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
|
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
|
||||||
|
|
||||||
.. _plugin_hook_shutdown:
|
|
||||||
|
|
||||||
shutdown(datasette)
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
This hook fires once, when the Datasette application server is shutting down gracefully - triggered by the ASGI ``lifespan.shutdown`` event, which includes pressing Ctrl-C or sending ``SIGTERM`` to a ``datasette serve`` process. It is not called on a hard kill (``SIGKILL``), since there is no opportunity to run any code in that case.
|
|
||||||
|
|
||||||
Like ``startup()``, this can be a regular function or it can return an async function to be awaited.
|
|
||||||
|
|
||||||
It runs before Datasette cancels any background tasks it is supervising (see :ref:`datasette_add_background_task`) and before it closes its database connections, so you can use it to tell your plugin's own background work to stop gracefully while a database connection is still available to write out any final state. See :ref:`datasette_lifecycle` for exactly where this fits into the full startup-to-shutdown sequence:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def shutdown(datasette):
|
|
||||||
async def inner():
|
|
||||||
db = datasette.get_database()
|
|
||||||
await db.execute_write(
|
|
||||||
"insert into shutdown_log (at) values (datetime('now'))"
|
|
||||||
)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
If your ``shutdown()`` hook raises an exception it will be logged but not re-raised, so one plugin's broken shutdown code cannot prevent other plugins - or Datasette itself - from finishing their own teardown.
|
|
||||||
|
|
||||||
.. _plugin_hook_actor_from_request:
|
.. _plugin_hook_actor_from_request:
|
||||||
|
|
||||||
actor_from_request(datasette, request)
|
actor_from_request(datasette, request)
|
||||||
|
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
.. _plugin_telemetry:
|
|
||||||
|
|
||||||
Telemetry for plugin authors
|
|
||||||
============================
|
|
||||||
|
|
||||||
Datasette core emits OpenTelemetry spans and metrics for the work it does itself - see :ref:`internals_telemetry` for what those are and how an operator turns them on. This page is about the other half: instrumenting the work **your plugin** does, so that a plugin's queries, background jobs and custom operations show up in the same traces and the same metrics pipeline, using the same conventions.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_scope:
|
|
||||||
|
|
||||||
Use your own instrumentation scope
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
Create a tracer and meter using your plugin's own instrumentation scope:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from opentelemetry import metrics, trace
|
|
||||||
|
|
||||||
from my_plugin import __version__
|
|
||||||
|
|
||||||
tracer = trace.get_tracer("my_plugin", __version__)
|
|
||||||
meter = metrics.get_meter("my_plugin", __version__)
|
|
||||||
|
|
||||||
Use these naming rules:
|
|
||||||
|
|
||||||
- **Scope**: use your plugin's import package name, such as ``my_plugin``. This lets users filter telemetry by plugin.
|
|
||||||
- **Signal prefix**: prefix spans, metrics and custom attributes with your package name (``my_plugin.*``) or a product name (``paper.*``). The ``datasette.*`` prefix is reserved for core.
|
|
||||||
|
|
||||||
Reuse shared attribute names where they describe the same thing: ``db.namespace`` for a database name, or ``error.type`` for an exception class.
|
|
||||||
|
|
||||||
If you pass ``schema_url=`` when creating a tracer or meter, choose the semantic-convention version that matches your attributes. Datasette's version is available as ``datasette.telemetry.SCHEMA_URL``. Omit ``schema_url`` if you are unsure which version applies.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_registry:
|
|
||||||
|
|
||||||
Declare a registry
|
|
||||||
------------------
|
|
||||||
|
|
||||||
Use ``Attribute``, ``SpanName`` and ``MetricName`` from ``datasette.telemetry_registry`` to describe your plugin's telemetry. Registry entries are strings and can be passed directly to OpenTelemetry:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.telemetry_registry import (
|
|
||||||
Attribute,
|
|
||||||
MetricName,
|
|
||||||
SpanName,
|
|
||||||
)
|
|
||||||
|
|
||||||
OUTCOME = Attribute(
|
|
||||||
"my_plugin.outcome",
|
|
||||||
"How the job ended.",
|
|
||||||
values={"ok", "error", "skipped"},
|
|
||||||
)
|
|
||||||
JOB_NAME = Attribute(
|
|
||||||
"my_plugin.job", "The registered job name."
|
|
||||||
)
|
|
||||||
|
|
||||||
JOB_RUN = SpanName(
|
|
||||||
"my_plugin.job.run",
|
|
||||||
"One execution of a scheduled job.",
|
|
||||||
(OUTCOME, JOB_NAME),
|
|
||||||
)
|
|
||||||
|
|
||||||
# A span family with a variable suffix - emitted as "my_plugin.chat gpt-5"
|
|
||||||
CHAT = SpanName(
|
|
||||||
"my_plugin.chat ",
|
|
||||||
"One model call, named ``my_plugin.chat {model}``.",
|
|
||||||
prefix=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
SPANS = (JOB_RUN, CHAT)
|
|
||||||
|
|
||||||
JOB_DURATION = MetricName(
|
|
||||||
"my_plugin.job.duration",
|
|
||||||
"Histogram",
|
|
||||||
"s",
|
|
||||||
"How long each job took.",
|
|
||||||
(JOB_NAME, OUTCOME),
|
|
||||||
buckets=(0.01, 0.1, 1, 10, 60, 600, 3600),
|
|
||||||
)
|
|
||||||
|
|
||||||
METRICS = (JOB_DURATION,)
|
|
||||||
|
|
||||||
The example uses these optional arguments:
|
|
||||||
|
|
||||||
``values`` - iterable
|
|
||||||
Allowed values for an ``Attribute``. The :ref:`conformance helpers <plugin_telemetry_testing>` check that emitted values belong to this set. Omit it to allow any value.
|
|
||||||
|
|
||||||
``prefix`` - boolean
|
|
||||||
For ``SpanName``, match emitted names by prefix. Defaults to ``False``. Exact names take precedence over prefix matches. Avoid overlapping prefixes: the first matching entry in the registry wins.
|
|
||||||
|
|
||||||
``buckets`` - iterable
|
|
||||||
Histogram boundaries for a ``MetricName``, expressed in the metric's unit. Pass these to ``meter.create_histogram()`` using ``explicit_bucket_boundaries_advisory=JOB_DURATION.buckets``. Choose boundaries suitable for the operations you measure. For SQLite timings, ``datasette.telemetry_registry.DURATION_BUCKETS`` provides boundaries from 0.0001 to 10 seconds.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_privacy:
|
|
||||||
|
|
||||||
Privacy and cardinality rules
|
|
||||||
-----------------------------
|
|
||||||
|
|
||||||
Core does not explicitly attach bound SQL parameter values, actor identifiers, cookies, authorization headers, client IP addresses or URL query strings as attributes. It does record SQL text, URL paths, host names, User-Agent headers and exception details, which may contain sensitive information. See :ref:`internals_telemetry_privacy`.
|
|
||||||
|
|
||||||
- Prefer closed enums, booleans, counts and durations for attribute values. Avoid recording personal information, tokens or other secrets.
|
|
||||||
- If you record SQL, use ``datasette.telemetry.sql_attribute()`` on spans only. It truncates SQL text but does not redact literal values. Do not add bound parameter values.
|
|
||||||
- Keep metric dimensions bounded. For user input or other unbounded values, record a count, a byte size, a truncation flag or an enum outcome instead.
|
|
||||||
|
|
||||||
Use ``assert_no_forbidden_values()`` in :ref:`plugin_telemetry_testing` to check for specific sensitive values in captured telemetry. This helper does not automatically identify all sensitive information.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_callbacks:
|
|
||||||
|
|
||||||
Your database work is already traced
|
|
||||||
------------------------------------
|
|
||||||
|
|
||||||
Every call your plugin makes through :ref:`db.execute() <database_execute>`, :ref:`db.execute_fn() <database_execute_fn>`, :ref:`db.execute_write() <database_execute_write>` and :ref:`db.execute_write_fn() <database_execute_write_fn>` already emits core's ``db.query`` spans and is counted in the ``db.client.operation.duration`` histogram. Two consequences:
|
|
||||||
|
|
||||||
- **Pass named callables**, not lambdas: the span for a callback-style call is identified by ``datasette.callback``, the callable's qualified name, and a lambda reports ``<lambda>``.
|
|
||||||
- If you also wrap those calls in your own span or histogram, you are creating a *second* series in *your* scope - that is fine and sometimes right (yours can carry plugin-level attributes core cannot know), but it is a deliberate two-series design, not a substitute for core's.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_request_span:
|
|
||||||
|
|
||||||
Enriching the request span
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
Inside a view or ASGI middleware, ``datasette.telemetry.request_span(scope)`` returns the recording ``SERVER`` span for the current request, or ``None`` when nothing is recording - which is also your signal to skip any work done only to compute attributes:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.telemetry import request_span
|
|
||||||
|
|
||||||
|
|
||||||
async def my_view(request):
|
|
||||||
span = request_span(request.scope)
|
|
||||||
if span is not None:
|
|
||||||
span.set_attribute("my_plugin.cache", "hit")
|
|
||||||
...
|
|
||||||
|
|
||||||
.. _plugin_telemetry_background:
|
|
||||||
|
|
||||||
Background work: roots with links
|
|
||||||
---------------------------------
|
|
||||||
|
|
||||||
For background work that can outlive a request, create a root span linked to the span that scheduled it. Call ``linked_root_span_kwargs()`` when scheduling the work, then pass the result when starting its span. If there is no valid span context to capture, the new span has no link:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.telemetry import linked_root_span_kwargs
|
|
||||||
|
|
||||||
# Capture the current span when scheduling the work:
|
|
||||||
kwargs = linked_root_span_kwargs()
|
|
||||||
|
|
||||||
# Later, wherever the work actually runs:
|
|
||||||
with tracer.start_as_current_span(
|
|
||||||
"my_plugin.job.run", **kwargs
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(OUTCOME, "ok")
|
|
||||||
|
|
||||||
For periodic tasks, create a root span and increment a counter on each iteration, including iterations with no work. Record the result in an outcome attribute. A gauge reporting the time since the last iteration can help monitor tasks with long intervals.
|
|
||||||
|
|
||||||
``asyncio.create_task()`` inherits the current trace context. Use ``linked_root_span_kwargs()`` to start background work with its own root span and a link to that context.
|
|
||||||
|
|
||||||
Tracers and meters can be created at module scope. In embedded deployments, configure the application's providers before the work you want to record begins.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_gauges:
|
|
||||||
|
|
||||||
Observable gauges
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
Use an observable gauge for current values such as the number of open streams or the length of a queue. The SDK calls its callback when collecting metrics:
|
|
||||||
|
|
||||||
- Track live objects using weak references, such as a ``weakref.WeakSet``, and unregister them when they close.
|
|
||||||
- Callbacks may run on a different thread from request handlers. Protect shared state and avoid waiting on locks held by request handlers.
|
|
||||||
- Read cached state and yield ``Observation`` values. Keep callbacks synchronous and free of I/O. Refresh cached values outside the callback; use a separate gauge to report their age if needed.
|
|
||||||
|
|
||||||
Without a provider, gauge callbacks are not invoked.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_testing:
|
|
||||||
|
|
||||||
Testing your instrumentation
|
|
||||||
----------------------------
|
|
||||||
|
|
||||||
Use ``datasette.telemetry_testing`` to capture telemetry in your tests and check it against your registry. Add `opentelemetry-sdk <https://github.com/open-telemetry/opentelemetry-python/tree/main/opentelemetry-sdk>`__ to your test dependencies, then import these fixtures in ``conftest.py``:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.telemetry_testing import ( # noqa: F401
|
|
||||||
otel_metrics,
|
|
||||||
otel_meter_provider,
|
|
||||||
otel_provider,
|
|
||||||
otel_reset,
|
|
||||||
otel_spans,
|
|
||||||
)
|
|
||||||
|
|
||||||
``otel_provider`` and ``otel_meter_provider``
|
|
||||||
Automatically configure in-memory recording for spans and metrics once per test session.
|
|
||||||
|
|
||||||
``otel_reset``
|
|
||||||
Automatically clears recorded spans and drains collected metrics after every test.
|
|
||||||
|
|
||||||
``otel_spans``
|
|
||||||
Provides an ``InMemorySpanExporter``. Call ``get_finished_spans()`` to retrieve spans recorded during the test.
|
|
||||||
|
|
||||||
``otel_metrics``
|
|
||||||
Provides a metrics collector. Call ``collect()`` to capture a snapshot, then use ``point()`` or ``points()`` to inspect it.
|
|
||||||
|
|
||||||
Tests requesting ``otel_spans`` or ``otel_metrics`` skip if the SDK is unavailable or another provider has already been installed.
|
|
||||||
|
|
||||||
The assertion helpers check the recorded telemetry against your registry:
|
|
||||||
|
|
||||||
``assert_spans_conform()``
|
|
||||||
Checks that emitted spans and attributes are registered, and attribute values match any declared ``values=`` enums.
|
|
||||||
|
|
||||||
``assert_metrics_conform()``
|
|
||||||
Checks that emitted metrics and attributes are registered, attribute values match any declared enums, and instrument kinds and units match the registry.
|
|
||||||
|
|
||||||
``assert_spans_covered()`` and ``assert_metrics_covered()``
|
|
||||||
Check that every registered span or metric and its required attributes appeared during the test. Attributes marked ``optional=True`` are excluded from this check; test those separately.
|
|
||||||
|
|
||||||
Pass your plugin's instrumentation scope as ``scope_name`` to these helpers, since the fixtures also record Datasette's own telemetry.
|
|
||||||
|
|
||||||
Run a workload that exercises your instrumentation, then call ``otel_metrics.collect()`` once before checking the metrics. Counters and histograms report measurements since the previous collection. Keep the Datasette instance open until collection so observable gauges can report its state:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.telemetry_testing import (
|
|
||||||
assert_metrics_conform,
|
|
||||||
assert_metrics_covered,
|
|
||||||
assert_package_never_imports_sdk,
|
|
||||||
assert_spans_covered,
|
|
||||||
assert_spans_conform,
|
|
||||||
)
|
|
||||||
|
|
||||||
from my_plugin.telemetry import METRICS, SPANS
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_only_dependency():
|
|
||||||
assert_package_never_imports_sdk("my_plugin")
|
|
||||||
|
|
||||||
|
|
||||||
def test_conformance(otel_spans, otel_metrics):
|
|
||||||
run_a_workload_that_exercises_everything()
|
|
||||||
finished = otel_spans.get_finished_spans()
|
|
||||||
# Everything emitted is registered (and enum values are legal):
|
|
||||||
assert_spans_conform(
|
|
||||||
SPANS, finished, scope_name="my_plugin"
|
|
||||||
)
|
|
||||||
# Everything registered was emitted:
|
|
||||||
assert_spans_covered(
|
|
||||||
SPANS, finished, scope_name="my_plugin"
|
|
||||||
)
|
|
||||||
# Collect once, then check the metrics:
|
|
||||||
otel_metrics.collect()
|
|
||||||
assert_metrics_conform(
|
|
||||||
METRICS, otel_metrics, scope_name="my_plugin"
|
|
||||||
)
|
|
||||||
assert_metrics_covered(
|
|
||||||
METRICS, otel_metrics, scope_name="my_plugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
``assert_package_never_imports_sdk()`` checks that importing your plugin does not import the OpenTelemetry SDK. Run this test early in your suite; see the helper's docstring for a macOS threading limitation.
|
|
||||||
|
|
||||||
Use ``assert_no_forbidden_values()`` to check for private data in telemetry. Include fake email addresses, tokens or usernames in your test workload, then pass those values, the finished spans and the collected metrics to the helper. It checks span names, attributes, events, status descriptions and metric attributes.
|
|
||||||
|
|
||||||
Leave ``scope_name`` unset for privacy checks so they include both your plugin's telemetry and Datasette's own.
|
|
||||||
|
|
||||||
.. _plugin_telemetry_caveats:
|
|
||||||
|
|
||||||
Known caveats
|
|
||||||
-------------
|
|
||||||
|
|
||||||
- **Streaming responses hold the request span open.** Core's request span ends when the response body finishes, so for an SSE or long-streaming route its duration is the connection lifetime. If you need per-message timing on a stream, emit your own child spans or span events per message, and use gauges for concurrent-stream counts.
|
|
||||||
- **A plugin timing core's work double-measures by design.** See :ref:`plugin_telemetry_callbacks` above.
|
|
||||||
- ``datasette.client`` requests made from inside a request produce a nested ``SERVER`` span. Those spans carry ``datasette.internal_client: true`` - filter on it to keep kind-based dashboards from double-counting requests.
|
|
||||||
|
|
@ -261,15 +261,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
||||||
"permission_resources_sql"
|
"permission_resources_sql"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "datasette.default_permissions.sqlite_statistics",
|
|
||||||
"static": false,
|
|
||||||
"templates": false,
|
|
||||||
"version": null,
|
|
||||||
"hooks": [
|
|
||||||
"permission_resources_sql"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "datasette.default_permissions.tokens",
|
"name": "datasette.default_permissions.tokens",
|
||||||
"static": false,
|
"static": false,
|
||||||
|
|
|
||||||
|
|
@ -67,21 +67,10 @@ The following options can be set using ``--setting name value``, or by storing t
|
||||||
default_allow_sql
|
default_allow_sql
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
from settings_doc import setting_default
|
|
||||||
setting_default(cog, "default_allow_sql")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Should users be able to execute arbitrary SQL queries by default?
|
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.
|
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
|
datasette mydatabase.db --setting default_allow_sql off
|
||||||
|
|
@ -93,14 +82,6 @@ Another way to achieve this is to add ``"allow_sql": false`` to your ``datasette
|
||||||
default_page_size
|
default_page_size
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "default_page_size")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``100``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
The default number of rows returned by the table page. You can over-ride this on a per-page basis using the ``?_size=80`` query string parameter, provided you do not specify a value higher than the ``max_returned_rows`` setting. You can set this default using ``--setting`` like so::
|
The default number of rows returned by the table page. You can over-ride this on a per-page basis using the ``?_size=80`` query string parameter, provided you do not specify a value higher than the ``max_returned_rows`` setting. You can set this default using ``--setting`` like so::
|
||||||
|
|
||||||
datasette mydatabase.db --setting default_page_size 50
|
datasette mydatabase.db --setting default_page_size 50
|
||||||
|
|
@ -110,15 +91,7 @@ The default number of rows returned by the table page. You can over-ride this on
|
||||||
sql_time_limit_ms
|
sql_time_limit_ms
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
By default, queries have a time limit of one second. If a query takes longer than this to run Datasette will terminate the query and return an error.
|
||||||
setting_default(cog, "sql_time_limit_ms")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``1000``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Time limit for SQL queries, in milliseconds. If a query takes longer than this to run Datasette will terminate the query and return an error.
|
|
||||||
|
|
||||||
If this time limit is too short for you, you can customize it using the ``sql_time_limit_ms`` limit - for example, to increase it to 3.5 seconds::
|
If this time limit is too short for you, you can customize it using the ``sql_time_limit_ms`` limit - for example, to increase it to 3.5 seconds::
|
||||||
|
|
||||||
|
|
@ -135,15 +108,7 @@ This would set the time limit to 100ms for that specific query. This feature is
|
||||||
max_returned_rows
|
max_returned_rows
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Datasette returns a maximum of 1,000 rows of data at a time. If you execute a query that returns more than 1,000 rows, Datasette will return the first 1,000 and include a warning that the result set has been truncated. You can use OFFSET/LIMIT or other methods in your SQL to implement pagination if you need to return more than 1,000 rows.
|
||||||
setting_default(cog, "max_returned_rows")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``1000``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
The maximum number of rows Datasette returns at a time. If you execute a query that exceeds this limit, Datasette will truncate the result set and include a warning. You can use OFFSET/LIMIT or other methods in your SQL to implement pagination if you need to return more rows.
|
|
||||||
|
|
||||||
You can increase or decrease this limit like so::
|
You can increase or decrease this limit like so::
|
||||||
|
|
||||||
|
|
@ -154,15 +119,7 @@ You can increase or decrease this limit like so::
|
||||||
max_insert_rows
|
max_insert_rows
|
||||||
~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Maximum rows that can be inserted at a time using the bulk insert API, see :ref:`TableInsertView`. Defaults to 100.
|
||||||
setting_default(cog, "max_insert_rows")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``100``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Maximum rows that can be inserted at a time using the bulk insert API, see :ref:`TableInsertView`.
|
|
||||||
|
|
||||||
You can increase or decrease this limit like so::
|
You can increase or decrease this limit like so::
|
||||||
|
|
||||||
|
|
@ -173,15 +130,7 @@ You can increase or decrease this limit like so::
|
||||||
max_post_body_bytes
|
max_post_body_bytes
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Maximum size in bytes for a POST body that Datasette reads fully into memory, such as JSON submitted to the :ref:`write API <json_api_write>`. Requests with larger bodies are rejected with an HTTP 413 error. Defaults to 2,097,152 (2MB).
|
||||||
setting_default(cog, "max_post_body_bytes")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``2097152``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Maximum size in bytes for a POST body that Datasette reads fully into memory, such as JSON submitted to the :ref:`write API <json_api_write>`. Requests with larger bodies are rejected with an HTTP 413 error.
|
|
||||||
|
|
||||||
This limit exists to protect against memory exhaustion: unlike file uploads handled by ``request.form()``, which stream to disk, these bodies are held entirely in memory and parsing them as JSON can multiply their memory footprint several times over.
|
This limit exists to protect against memory exhaustion: unlike file uploads handled by ``request.form()``, which stream to disk, these bodies are held entirely in memory and parsing them as JSON can multiply their memory footprint several times over.
|
||||||
|
|
||||||
|
|
@ -198,15 +147,7 @@ Set it to 0 to disable the limit entirely::
|
||||||
num_sql_threads
|
num_sql_threads
|
||||||
~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Maximum number of threads in the thread pool Datasette uses to execute SQLite queries. Defaults to 3.
|
||||||
setting_default(cog, "num_sql_threads")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``3``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Maximum number of threads in the thread pool Datasette uses to execute SQLite queries.
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
|
|
@ -219,17 +160,9 @@ Setting this to 0 turns off threaded SQL queries entirely - useful for environme
|
||||||
allow_facet
|
allow_facet
|
||||||
~~~~~~~~~~~
|
~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "allow_facet")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Allow users to specify columns they would like to facet on using the ``?_facet=COLNAME`` URL parameter to the table view.
|
Allow users to specify columns they would like to facet on using the ``?_facet=COLNAME`` URL parameter to the table view.
|
||||||
|
|
||||||
If disabled, facets will still be displayed if they have been specifically enabled in ``metadata.json`` configuration for the table.
|
This is enabled by default. If disabled, facets will still be displayed if they have been specifically enabled in ``metadata.json`` configuration for the table.
|
||||||
|
|
||||||
Here's how to disable this feature::
|
Here's how to disable this feature::
|
||||||
|
|
||||||
|
|
@ -240,15 +173,7 @@ Here's how to disable this feature::
|
||||||
default_facet_size
|
default_facet_size
|
||||||
~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
The default number of unique rows returned by :ref:`facets` is 30. You can customize it like this::
|
||||||
setting_default(cog, "default_facet_size")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``30``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
The default number of unique rows returned by :ref:`facets`. You can customize it like this::
|
|
||||||
|
|
||||||
datasette mydatabase.db --setting default_facet_size 50
|
datasette mydatabase.db --setting default_facet_size 50
|
||||||
|
|
||||||
|
|
@ -257,15 +182,7 @@ The default number of unique rows returned by :ref:`facets`. You can customize i
|
||||||
facet_time_limit_ms
|
facet_time_limit_ms
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
This is the time limit Datasette allows for calculating a facet, which defaults to 200ms::
|
||||||
setting_default(cog, "facet_time_limit_ms")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``200``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
The time limit in milliseconds Datasette allows for calculating a facet. You can customize it like this::
|
|
||||||
|
|
||||||
datasette mydatabase.db --setting facet_time_limit_ms 1000
|
datasette mydatabase.db --setting facet_time_limit_ms 1000
|
||||||
|
|
||||||
|
|
@ -274,15 +191,7 @@ The time limit in milliseconds Datasette allows for calculating a facet. You can
|
||||||
facet_suggest_time_limit_ms
|
facet_suggest_time_limit_ms
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
When Datasette calculates suggested facets it needs to run a SQL query for every column in your table. The default for this time limit is 50ms to account for the fact that it needs to run once for every column. If the time limit is exceeded the column will not be suggested as a facet.
|
||||||
setting_default(cog, "facet_suggest_time_limit_ms")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``50``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
When Datasette calculates suggested facets it needs to run a SQL query for every column in your table. This time limit, in milliseconds, applies separately to each query. If the time limit is exceeded the column will not be suggested as a facet.
|
|
||||||
|
|
||||||
You can increase this time limit like so::
|
You can increase this time limit like so::
|
||||||
|
|
||||||
|
|
@ -293,15 +202,7 @@ You can increase this time limit like so::
|
||||||
suggest_facets
|
suggest_facets
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Should Datasette calculate suggested facets? On by default, turn this off like so::
|
||||||
setting_default(cog, "suggest_facets")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Should Datasette calculate suggested facets? Turn this off like so::
|
|
||||||
|
|
||||||
datasette mydatabase.db --setting suggest_facets off
|
datasette mydatabase.db --setting suggest_facets off
|
||||||
|
|
||||||
|
|
@ -310,15 +211,7 @@ Should Datasette calculate suggested facets? Turn this off like so::
|
||||||
allow_download
|
allow_download
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Should users be able to download the original SQLite database using a link on the database index page? This is turned on by default. However, databases can only be downloaded if they are served in immutable mode and not in-memory. If downloading is unavailable for either of these reasons, the download link is hidden even if ``allow_download`` is on. To disable database downloads, use the following::
|
||||||
setting_default(cog, "allow_download")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Should users be able to download the original SQLite database using a link on the database index page? Databases can only be downloaded if they are served in immutable mode and not in-memory. If downloading is unavailable for either of these reasons, the download link is hidden even if ``allow_download`` is on. To disable database downloads, use the following::
|
|
||||||
|
|
||||||
datasette mydatabase.db --setting allow_download off
|
datasette mydatabase.db --setting allow_download off
|
||||||
|
|
||||||
|
|
@ -327,17 +220,9 @@ Should users be able to download the original SQLite database using a link on th
|
||||||
allow_signed_tokens
|
allow_signed_tokens
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "allow_signed_tokens")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Should users be able to create signed API tokens to access Datasette?
|
Should users be able to create signed API tokens to access Datasette?
|
||||||
|
|
||||||
Use the following to turn it off::
|
This is turned on by default. Use the following to turn it off::
|
||||||
|
|
||||||
datasette mydatabase.db --setting allow_signed_tokens off
|
datasette mydatabase.db --setting allow_signed_tokens off
|
||||||
|
|
||||||
|
|
@ -348,17 +233,9 @@ Turning this setting off will disable the ``/-/create-token`` page, :ref:`descri
|
||||||
max_signed_tokens_ttl
|
max_signed_tokens_ttl
|
||||||
~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "max_signed_tokens_ttl")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``0``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Maximum allowed expiry time for signed API tokens created by users.
|
Maximum allowed expiry time for signed API tokens created by users.
|
||||||
|
|
||||||
A value of ``0`` means no limit - tokens can be created that will never expire.
|
Defaults to ``0`` which means no limit - tokens can be created that will never expire.
|
||||||
|
|
||||||
Set this to a value in seconds to limit the maximum expiry time. For example, to set that limit to 24 hours you would use::
|
Set this to a value in seconds to limit the maximum expiry time. For example, to set that limit to 24 hours you would use::
|
||||||
|
|
||||||
|
|
@ -371,36 +248,18 @@ This setting is enforced when incoming tokens are processed.
|
||||||
default_cache_ttl
|
default_cache_ttl
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-age=X``. Can be over-ridden on a per-request basis using the ``?_ttl=`` query string parameter. Set this to ``0`` to disable HTTP caching entirely. Defaults to 5 seconds.
|
||||||
setting_default(cog, "default_cache_ttl")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``5``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-age=X``. Can be over-ridden on a per-request basis using the ``?_ttl=`` query string parameter. Set this to ``0`` to disable HTTP caching entirely.
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
datasette mydatabase.db --setting default_cache_ttl 60
|
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:
|
.. _setting_cache_size_kb:
|
||||||
|
|
||||||
cache_size_kb
|
cache_size_kb
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
Sets the amount of memory SQLite uses for its `per-connection cache <https://www.sqlite.org/pragma.html#pragma_cache_size>`_, in KB.
|
||||||
setting_default(cog, "cache_size_kb")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``0``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Sets the amount of memory SQLite uses for its `per-connection cache <https://www.sqlite.org/pragma.html#pragma_cache_size>`_, in KB. Set this to ``0`` to use SQLite's default cache size.
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
|
|
@ -411,17 +270,9 @@ Sets the amount of memory SQLite uses for its `per-connection cache <https://www
|
||||||
allow_csv_stream
|
allow_csv_stream
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "allow_csv_stream")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``on``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Enables :ref:`the CSV export feature <csv_export>` where an entire table
|
Enables :ref:`the CSV export feature <csv_export>` where an entire table
|
||||||
(potentially hundreds of thousands of rows) can be exported as a single CSV
|
(potentially hundreds of thousands of rows) can be exported as a single CSV
|
||||||
file. You can turn it off like this:
|
file. This is turned on by default - you can turn it off like this:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
|
|
@ -432,16 +283,8 @@ file. You can turn it off like this:
|
||||||
max_csv_mb
|
max_csv_mb
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
The maximum size of CSV that can be exported, in megabytes. Defaults to 100MB.
|
||||||
setting_default(cog, "max_csv_mb")
|
You can disable the limit entirely by settings this to 0:
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``100``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
The maximum size of CSV that can be exported, in megabytes.
|
|
||||||
You can disable the limit entirely by setting this to 0:
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
|
|
@ -452,14 +295,6 @@ You can disable the limit entirely by setting this to 0:
|
||||||
truncate_cells_html
|
truncate_cells_html
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "truncate_cells_html")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``2048``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
In the HTML table view, truncate any strings that are longer than this value.
|
In the HTML table view, truncate any strings that are longer than this value.
|
||||||
The full value will still be available in CSV, JSON and on the individual row
|
The full value will still be available in CSV, JSON and on the individual row
|
||||||
HTML page. Set this to 0 to disable truncation.
|
HTML page. Set this to 0 to disable truncation.
|
||||||
|
|
@ -473,14 +308,6 @@ HTML page. Set this to 0 to disable truncation.
|
||||||
force_https_urls
|
force_https_urls
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "force_https_urls")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``off``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
Forces self-referential URLs in the JSON output to always use the ``https://``
|
Forces self-referential URLs in the JSON output to always use the ``https://``
|
||||||
protocol. This is useful for cases where the application itself is hosted using
|
protocol. This is useful for cases where the application itself is hosted using
|
||||||
HTTP but is served to the outside world via a proxy that enables HTTPS.
|
HTTP but is served to the outside world via a proxy that enables HTTPS.
|
||||||
|
|
@ -494,14 +321,6 @@ HTTP but is served to the outside world via a proxy that enables HTTPS.
|
||||||
template_debug
|
template_debug
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "template_debug")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``off``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
This setting enables template context debug mode, which is useful to help understand what variables are available to custom templates when you are writing them.
|
This setting enables template context debug mode, which is useful to help understand what variables are available to custom templates when you are writing them.
|
||||||
|
|
||||||
Enable it like this::
|
Enable it like this::
|
||||||
|
|
@ -521,14 +340,6 @@ Some examples:
|
||||||
trace_debug
|
trace_debug
|
||||||
~~~~~~~~~~~
|
~~~~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "trace_debug")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``off``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
This setting enables appending ``?_trace=1`` to any page in order to see the SQL queries and other trace information that was used to generate that page.
|
This setting enables appending ``?_trace=1`` to any page in order to see the SQL queries and other trace information that was used to generate that page.
|
||||||
|
|
||||||
Enable it like this::
|
Enable it like this::
|
||||||
|
|
@ -547,14 +358,6 @@ See :ref:`internals_tracer` for details on how to hook into this mechanism as a
|
||||||
base_url
|
base_url
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
.. [[[cog
|
|
||||||
setting_default(cog, "base_url")
|
|
||||||
.. ]]]
|
|
||||||
|
|
||||||
Default: ``/``
|
|
||||||
|
|
||||||
.. [[[end]]]
|
|
||||||
|
|
||||||
If you are running Datasette behind a proxy, it may be useful to change the root path used for the Datasette instance.
|
If you are running Datasette behind a proxy, it may be useful to change the root path used for the Datasette instance.
|
||||||
|
|
||||||
For example, if you are sending traffic from ``https://www.example.com/tools/datasette/`` through to a proxied Datasette instance you may wish Datasette to use ``/tools/datasette/`` as its root URL.
|
For example, if you are sending traffic from ``https://www.example.com/tools/datasette/`` through to a proxied Datasette instance you may wish Datasette to use ``/tools/datasette/`` as its root URL.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
"""Cog helper for documenting setting defaults from Datasette's registry."""
|
|
||||||
|
|
||||||
|
|
||||||
def setting_default(cog, name):
|
|
||||||
from datasette.app import DEFAULT_SETTINGS
|
|
||||||
|
|
||||||
default = DEFAULT_SETTINGS[name]
|
|
||||||
if isinstance(default, bool):
|
|
||||||
default = "on" if default else "off"
|
|
||||||
cog.out(f"\nDefault: ``{default}``\n\n")
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# Screenshots used by the documentation, taken using shot-scraper:
|
|
||||||
# https://shot-scraper.datasette.io/en/stable/multi.html
|
|
||||||
#
|
|
||||||
# Run "just shots" from the repository root to create any that are
|
|
||||||
# missing. Existing images are skipped, so delete an image to recreate it.
|
|
||||||
#
|
|
||||||
# Paths are relative to this docs/ directory.
|
|
||||||
|
|
||||||
# Serves the JavaScript in docs/shots/ and loads it on every page.
|
|
||||||
# List form means the datasette process is stopped directly when done.
|
|
||||||
- server:
|
|
||||||
- datasette
|
|
||||||
- --memory
|
|
||||||
- --port
|
|
||||||
- 8755
|
|
||||||
- --static
|
|
||||||
- shots:shots
|
|
||||||
- -s
|
|
||||||
- extra_js_urls
|
|
||||||
- '["/shots/modal-example.js", "/shots/modal-classes.js"]'
|
|
||||||
|
|
||||||
# javascript_plugins.rst - Reusable modal dialogs
|
|
||||||
- output: images/modal-example.webp
|
|
||||||
url: http://localhost:8755/
|
|
||||||
javascript: |
|
|
||||||
document.querySelector('[aria-controls="my-plugin-dialog"]').click();
|
|
||||||
selector: "#my-plugin-dialog"
|
|
||||||
padding: 32
|
|
||||||
quality: 70
|
|
||||||
|
|
||||||
- output: images/modal-classes.webp
|
|
||||||
url: http://localhost:8755/
|
|
||||||
javascript: |
|
|
||||||
document.querySelector('[aria-controls="export-dialog"]').click();
|
|
||||||
document.activeElement.blur();
|
|
||||||
selector: "#export-dialog"
|
|
||||||
padding: 32
|
|
||||||
quality: 70
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
// Demonstrates every shared modal CSS class, for images/modal-classes.webp
|
|
||||||
document.addEventListener("datasette_init", () => {
|
|
||||||
const openButton = document.createElement("button");
|
|
||||||
openButton.type = "button";
|
|
||||||
openButton.textContent = "Open export dialog";
|
|
||||||
openButton.setAttribute("aria-haspopup", "dialog");
|
|
||||||
openButton.setAttribute("aria-controls", "export-dialog");
|
|
||||||
|
|
||||||
const modal = DatasetteModal.create();
|
|
||||||
const dialog = modal.dialog;
|
|
||||||
dialog.id = "export-dialog";
|
|
||||||
dialog.setAttribute("aria-labelledby", "export-dialog-title");
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title" id="export-dialog-title">Export rows</h2>
|
|
||||||
<span class="modal-meta">3 selected</span>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Export these rows from the <strong>plants</strong> table as CSV:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Monstera deliciosa</li>
|
|
||||||
<li>Ficus lyrata</li>
|
|
||||||
<li>Pilea peperomioides</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<span class="footer-info">CSV, UTF-8</span>
|
|
||||||
<button type="button" class="modal-btn modal-btn-ghost">Cancel</button>
|
|
||||||
<button type="button" class="modal-btn modal-btn-primary">Export</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const [cancelButton, exportButton] = dialog.querySelectorAll(".modal-footer button");
|
|
||||||
cancelButton.addEventListener("click", () => modal.requestClose("cancel"));
|
|
||||||
exportButton.addEventListener("click", () => modal.close());
|
|
||||||
openButton.addEventListener("click", () => {
|
|
||||||
modal.show({ returnFocusTo: openButton, initialFocus: exportButton });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.append(modal);
|
|
||||||
document.querySelector("section.content").append(openButton);
|
|
||||||
});
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
document.addEventListener("datasette_init", () => {
|
|
||||||
const openButton = document.createElement("button");
|
|
||||||
openButton.type = "button";
|
|
||||||
openButton.textContent = "Open example dialog";
|
|
||||||
// Indicate that this button opens a dialog:
|
|
||||||
openButton.setAttribute("aria-haspopup", "dialog");
|
|
||||||
// Identify which dialog it controls:
|
|
||||||
openButton.setAttribute("aria-controls", "my-plugin-dialog");
|
|
||||||
|
|
||||||
const modal = DatasetteModal.create();
|
|
||||||
const dialog = modal.dialog;
|
|
||||||
dialog.id = "my-plugin-dialog";
|
|
||||||
// Tell screenreaders the dialog is labelled by #my-plugin-dialog-title
|
|
||||||
dialog.setAttribute("aria-labelledby", "my-plugin-dialog-title");
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title" id="my-plugin-dialog-title">
|
|
||||||
Example dialog
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
This dialog uses Datasette's shared styles and keyboard behavior.
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="modal-btn modal-btn-ghost">Close</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const closeButton = dialog.querySelector("button");
|
|
||||||
closeButton.addEventListener("click", () => {
|
|
||||||
modal.requestClose("cancel");
|
|
||||||
});
|
|
||||||
openButton.addEventListener("click", () => {
|
|
||||||
modal.show({ returnFocusTo: openButton, initialFocus: closeButton });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.append(modal);
|
|
||||||
document.querySelector("section.content").append(openButton);
|
|
||||||
});
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""
|
|
||||||
Cog helpers that render the span and metric reference in ``internals.rst``
|
|
||||||
from ``datasette/telemetry_registry.py``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _attribute_lines(cog, attributes):
|
|
||||||
if not attributes:
|
|
||||||
cog.out(" No attributes.\n\n")
|
|
||||||
return
|
|
||||||
cog.out(" Attributes:\n\n")
|
|
||||||
for attribute in attributes:
|
|
||||||
suffix = " *(optional)*" if attribute.optional else ""
|
|
||||||
line = f" - ``{attribute}``{suffix} - {attribute.description}"
|
|
||||||
if attribute.values is not None:
|
|
||||||
rendered = ", ".join(f"``{value}``" for value in sorted(attribute.values))
|
|
||||||
line += f" One of: {rendered}."
|
|
||||||
cog.out(line + "\n")
|
|
||||||
cog.out("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def spans(cog):
|
|
||||||
from opentelemetry.trace import SpanKind
|
|
||||||
|
|
||||||
from datasette.telemetry_registry import SPANS
|
|
||||||
|
|
||||||
cog.out("\n")
|
|
||||||
for span in SPANS:
|
|
||||||
cog.out(f"``{span}``\n")
|
|
||||||
cog.out(f" {span.description}\n\n")
|
|
||||||
# Only show the kind for spans that are not INTERNAL
|
|
||||||
if span.kind != SpanKind.INTERNAL:
|
|
||||||
cog.out(f" Kind: ``{span.kind.name}``.\n\n")
|
|
||||||
_attribute_lines(cog, span.attributes)
|
|
||||||
|
|
||||||
|
|
||||||
def metrics(cog):
|
|
||||||
from datasette.telemetry_registry import METRICS
|
|
||||||
|
|
||||||
cog.out("\n")
|
|
||||||
for metric in METRICS:
|
|
||||||
cog.out(f"``{metric}``\n")
|
|
||||||
cog.out(f" {metric.kind}, unit ``{metric.unit}``. {metric.description}\n\n")
|
|
||||||
if metric.buckets:
|
|
||||||
boundaries = ", ".join(f"``{boundary}``" for boundary in metric.buckets)
|
|
||||||
cog.out(f" Bucket boundaries: {boundaries}.\n\n")
|
|
||||||
_attribute_lines(cog, metric.attributes)
|
|
||||||
|
|
@ -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.
|
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.
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ Then run the tests using pytest like so::
|
||||||
Setting up a Datasette test instance
|
Setting up a Datasette test instance
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
Use :ref:`datasette.client <internals_datasette_client>` to make requests against a test instance. The first request runs startup hooks and launches registered background tasks automatically:
|
The above example shows the easiest way to start writing tests against a Datasette instance:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
|
@ -71,24 +71,16 @@ Use :ref:`datasette.client <internals_datasette_client>` to make requests agains
|
||||||
response = await datasette.client.get("/-/plugins.json")
|
response = await datasette.client.get("/-/plugins.json")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
If your test uses Datasette directly without making a request, call ``await datasette.invoke_startup()`` to initialize the instance and run its startup hooks:
|
Creating a ``Datasette()`` instance like this as useful shortcut in tests, but there is one detail you need to be aware of. It's important to ensure that the async method ``.invoke_startup()`` is called on that instance. You can do that like this:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
datasette = Datasette(memory=True)
|
datasette = Datasette(memory=True)
|
||||||
await datasette.invoke_startup()
|
await datasette.invoke_startup()
|
||||||
|
|
||||||
This runs the :ref:`plugin_hook_startup` and :ref:`plugin_hook_prepare_jinja2_environment` hooks on the same event loop as your test. It does not launch registered background tasks.
|
This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls.
|
||||||
|
|
||||||
To run tasks registered with :ref:`datasette_add_background_task` without making a request, use ``await datasette.start_background_tasks()``. This runs startup if needed and launches every registered task:
|
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request.
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
datasette = Datasette(memory=True)
|
|
||||||
await datasette.start_background_tasks()
|
|
||||||
# Tasks registered by startup() hooks have been launched
|
|
||||||
|
|
||||||
See :ref:`datasette_lifecycle` for the full startup and shutdown sequence.
|
|
||||||
|
|
||||||
.. _testing_plugins_datasette_fixtures_database:
|
.. _testing_plugins_datasette_fixtures_database:
|
||||||
|
|
||||||
|
|
@ -162,7 +154,7 @@ If you need to opt out of this behavior, add the following to your ``pytest.ini`
|
||||||
Using datasette.client in tests
|
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:
|
A simple test looks like this:
|
||||||
|
|
||||||
|
|
@ -281,22 +273,22 @@ If you want to create that test database repeatedly for every individual test fu
|
||||||
|
|
||||||
.. _testing_plugins_pytest_httpx:
|
.. _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.
|
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
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
import httpx2
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -314,18 +306,27 @@ As an example, here's a very simple plugin which executes an HTTP request and re
|
||||||
</form>""")
|
</form>""")
|
||||||
vars = await request.post_vars()
|
vars = await request.post_vars()
|
||||||
url = vars["url"]
|
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
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
async def test_outbound_http_call(httpx2_mock):
|
@pytest.fixture
|
||||||
httpx2_mock.get("https://www.example.com/").respond(
|
def non_mocked_hosts():
|
||||||
text="Hello world"
|
# 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)
|
datasette = Datasette([], memory=True)
|
||||||
response = await datasette.client.post(
|
response = await datasette.client.post(
|
||||||
|
|
@ -334,13 +335,11 @@ Here's a test for that plugin that mocks the HTTPX2 outbound request:
|
||||||
)
|
)
|
||||||
assert response.text == "Hello world"
|
assert response.text == "Hello world"
|
||||||
|
|
||||||
outbound_request = httpx2_mock.calls.last.request
|
outbound_request = httpx_mock.get_request()
|
||||||
assert (
|
assert (
|
||||||
outbound_request.url == "https://www.example.com/"
|
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:
|
.. _testing_plugins_register_in_test:
|
||||||
|
|
||||||
Registering a plugin for the duration of a test
|
Registering a plugin for the duration of a test
|
||||||
|
|
|
||||||
|
|
@ -203,40 +203,6 @@ Templates should be bundled for distribution using the same ``package_data`` mec
|
||||||
|
|
||||||
You can also use wildcards here such as ``templates/*.html``. See `datasette-edit-schema <https://github.com/simonw/datasette-edit-schema>`__ for an example of this pattern.
|
You can also use wildcards here such as ``templates/*.html``. See `datasette-edit-schema <https://github.com/simonw/datasette-edit-schema>`__ for an example of this pattern.
|
||||||
|
|
||||||
.. _writing_plugins_custom_templates_breadcrumbs:
|
|
||||||
|
|
||||||
Adding breadcrumbs
|
|
||||||
~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Plugin templates that extend ``base.html`` can use the ``crumbs.nav()`` macro to display breadcrumb links back to the Datasette homepage, and optionally to a database and a table. Override the ``crumbs`` block to specify which links to include:
|
|
||||||
|
|
||||||
.. code-block:: html+jinja
|
|
||||||
|
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Manage {{ table }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
<!-- For home / database / table -->
|
|
||||||
{{ crumbs.nav(request=request, database=database, table=table) }}
|
|
||||||
<!-- For home / database -->
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Manage {{ table }}</h1>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
The macro accepts these arguments:
|
|
||||||
|
|
||||||
* ``request``: the current request, used to check the actor's permissions.
|
|
||||||
* ``database``: an optional database name, as a string
|
|
||||||
* ``table``: an optional table name, as a string. If you pass ``table``, you must also pass ``database``.
|
|
||||||
|
|
||||||
For a database-level plugin page, use ``{{ crumbs.nav(request=request, database=database) }}``. For a page with just a homepage link, use ``{{ crumbs.nav(request=request) }}``, which is also the default provided by ``base.html`` if you do not override the block.
|
|
||||||
|
|
||||||
The table-level example renders links in the form ``home / database / table``. Each link is only included if the current actor has permission to view that resource.
|
|
||||||
|
|
||||||
.. _writing_plugins_configuration:
|
.. _writing_plugins_configuration:
|
||||||
|
|
||||||
Writing plugins that accept configuration
|
Writing plugins that accept configuration
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ dependencies = [
|
||||||
"click-default-group>=1.2.3",
|
"click-default-group>=1.2.3",
|
||||||
"Jinja2>=2.10.3",
|
"Jinja2>=2.10.3",
|
||||||
"hupper>=1.9",
|
"hupper>=1.9",
|
||||||
"httpx2>=2.0",
|
"httpx>=0.20,<1.0",
|
||||||
"pluggy>=1.0",
|
"pluggy>=1.0",
|
||||||
"uvicorn>=0.29",
|
"uvicorn>=0.11",
|
||||||
"aiofiles>=0.4",
|
"aiofiles>=0.4",
|
||||||
"PyYAML>=5.3",
|
"PyYAML>=5.3",
|
||||||
"mergedeep>=1.1.1",
|
"mergedeep>=1.1.1",
|
||||||
|
|
@ -40,7 +40,6 @@ dependencies = [
|
||||||
"setuptools",
|
"setuptools",
|
||||||
"pip",
|
"pip",
|
||||||
"pydantic>=2",
|
"pydantic>=2",
|
||||||
"opentelemetry-api>=1.37",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|
@ -64,17 +63,16 @@ dev = [
|
||||||
"pytest-xdist>=2.2.1",
|
"pytest-xdist>=2.2.1",
|
||||||
"pytest-asyncio>=1.2.0",
|
"pytest-asyncio>=1.2.0",
|
||||||
"beautifulsoup4>=4.8.1",
|
"beautifulsoup4>=4.8.1",
|
||||||
"black==26.5.1",
|
"black==26.3.1",
|
||||||
"blacken-docs==1.20.0",
|
"blacken-docs==1.20.0",
|
||||||
"pytest-timeout>=1.4.2",
|
"pytest-timeout>=1.4.2",
|
||||||
"trustme>=0.7",
|
"trustme>=0.7",
|
||||||
"cogapp>=3.3.0",
|
"cogapp>=3.3.0",
|
||||||
"multipart-form-data-conformance==0.1a0",
|
"multipart-form-data-conformance==0.1a0",
|
||||||
"ruff>=0.16.0",
|
"ruff>=0.16.0",
|
||||||
"opentelemetry-sdk>=1.37",
|
|
||||||
# docs
|
# docs
|
||||||
"Sphinx==7.4.7",
|
"Sphinx==7.4.7",
|
||||||
"furo==2025.12.19",
|
"furo==2025.9.25",
|
||||||
"sphinx-autobuild",
|
"sphinx-autobuild",
|
||||||
"codespell>=2.2.5",
|
"codespell>=2.2.5",
|
||||||
"sphinx-copybutton",
|
"sphinx-copybutton",
|
||||||
|
|
@ -87,9 +85,6 @@ dev = [
|
||||||
playwright = [
|
playwright = [
|
||||||
"pytest-playwright>=0.8.0",
|
"pytest-playwright>=0.8.0",
|
||||||
]
|
]
|
||||||
shots = [
|
|
||||||
"shot-scraper>=1.12",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
rich = ["rich"]
|
rich = ["rich"]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
addopts = --ignore=ignored
|
|
||||||
filterwarnings=
|
filterwarnings=
|
||||||
# https://github.com/pallets/jinja/issues/927
|
# https://github.com/pallets/jinja/issues/927
|
||||||
ignore:Using or importing the ABCs::jinja2
|
ignore:Using or importing the ABCs::jinja2
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import importlib.metadata
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import httpx2
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
@ -33,41 +32,17 @@ UNDOCUMENTED_PERMISSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def wait_until_responds(url, timeout=5.0, client=httpx2, process=None, **kwargs):
|
def wait_until_responds(url, timeout=5.0, client=httpx, **kwargs):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
# If the server died there is no point waiting out the timeout - fail
|
|
||||||
# now, with its output, instead of after `timeout` seconds of silence
|
|
||||||
if process is not None and process.poll() is not None:
|
|
||||||
raise AssertionError(
|
|
||||||
"Server exited early with returncode {}\n{}".format(
|
|
||||||
process.returncode, process.stdout.read().decode("utf-8")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
client.get(url, **kwargs)
|
client.get(url, **kwargs)
|
||||||
return
|
return
|
||||||
except httpx2.TransportError:
|
except httpx.ConnectError:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
raise AssertionError(f"Timed out waiting for {url} to respond")
|
raise AssertionError(f"Timed out waiting for {url} to respond")
|
||||||
|
|
||||||
|
|
||||||
def find_free_port():
|
|
||||||
with socket.socket() as sock:
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
return sock.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
from datasette.telemetry_testing import ( # noqa: F401
|
|
||||||
MetricsCollector,
|
|
||||||
otel_meter_provider,
|
|
||||||
otel_metrics,
|
|
||||||
otel_provider,
|
|
||||||
otel_reset,
|
|
||||||
otel_spans,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bare_ds():
|
def bare_ds():
|
||||||
"""
|
"""
|
||||||
|
|
@ -116,10 +91,7 @@ async def ds_client():
|
||||||
|
|
||||||
await db.execute_write_fn(prepare)
|
await db.execute_write_fn(prepare)
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
try:
|
return ds.client
|
||||||
yield ds.client
|
|
||||||
finally:
|
|
||||||
ds.close()
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_report_header(config):
|
def pytest_report_header(config):
|
||||||
|
|
@ -181,11 +153,6 @@ def pytest_collection_modifyitems(config, items):
|
||||||
move_to_front(items, "test_spatialite_error_if_attempt_to_open_spatialite")
|
move_to_front(items, "test_spatialite_error_if_attempt_to_open_spatialite")
|
||||||
move_to_front(items, "test_package")
|
move_to_front(items, "test_package")
|
||||||
move_to_front(items, "test_package_with_port")
|
move_to_front(items, "test_package_with_port")
|
||||||
# These start subprocesses, which can crash on macOS/CPython 3.13 late in
|
|
||||||
# a test run once the pytest process has started many threads
|
|
||||||
move_to_front(items, "test_datasette_package_never_imports_the_sdk")
|
|
||||||
move_to_front(items, "test_kit_module_itself_never_imports_the_sdk")
|
|
||||||
move_to_front(items, "test_no_provider_takes_the_fast_path")
|
|
||||||
|
|
||||||
|
|
||||||
def move_to_front(items, test_name):
|
def move_to_front(items, test_name):
|
||||||
|
|
@ -284,24 +251,12 @@ def ds_localhost_http_server():
|
||||||
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
||||||
cwd=tempfile.gettempdir(),
|
cwd=tempfile.gettempdir(),
|
||||||
)
|
)
|
||||||
try:
|
wait_until_responds("http://localhost:8041/")
|
||||||
wait_until_responds("http://localhost:8041/", process=ds_proc)
|
# Check it started successfully
|
||||||
|
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
||||||
yield ds_proc
|
yield ds_proc
|
||||||
finally:
|
# Shut it down at the end of the pytest session
|
||||||
stop_process(ds_proc)
|
ds_proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
def stop_process(proc):
|
|
||||||
try:
|
|
||||||
if proc.poll() is None:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
proc.wait()
|
|
||||||
finally:
|
|
||||||
proc.stdout.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|
@ -322,27 +277,11 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
||||||
cwd=tempfile.gettempdir(),
|
cwd=tempfile.gettempdir(),
|
||||||
)
|
)
|
||||||
# Poll until available
|
# Poll until available
|
||||||
transport = httpx2.HTTPTransport(uds=uds)
|
transport = httpx.HTTPTransport(uds=uds)
|
||||||
client = httpx2.Client(transport=transport)
|
client = httpx.Client(transport=transport)
|
||||||
try:
|
try:
|
||||||
# Probe with a socket we own: the HTTP transport can leak a socket
|
|
||||||
# when connect() fails before the UDS server has started listening.
|
|
||||||
start = time.monotonic()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
||||||
probe.settimeout(0.1)
|
|
||||||
probe.connect(uds)
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
if ds_proc.poll() is not None or time.monotonic() - start > 30:
|
|
||||||
raise
|
|
||||||
time.sleep(0.1)
|
|
||||||
wait_until_responds(
|
wait_until_responds(
|
||||||
"http://localhost/_memory.json",
|
"http://localhost/_memory.json", timeout=30.0, client=client
|
||||||
timeout=30.0,
|
|
||||||
client=client,
|
|
||||||
process=ds_proc,
|
|
||||||
)
|
)
|
||||||
# Check it started successfully
|
# Check it started successfully
|
||||||
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
||||||
|
|
@ -350,72 +289,18 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
# Shut it down at the end of the pytest session
|
# Shut it down at the end of the pytest session
|
||||||
stop_process(ds_proc)
|
ds_proc.terminate()
|
||||||
|
try:
|
||||||
|
ds_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
ds_proc.kill()
|
||||||
|
ds_proc.wait()
|
||||||
try:
|
try:
|
||||||
os.unlink(uds)
|
os.unlink(uds)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def serve_with_plugins(tmp_path):
|
|
||||||
"""Factory fixture for starting ``datasette serve`` in a subprocess with
|
|
||||||
plugins written to a temporary ``--plugins-dir``.
|
|
||||||
|
|
||||||
For tests that need the real serve path: event-loop wiring, exit codes,
|
|
||||||
signals. The usual in-process ``pm.register`` plugin pattern can't reach
|
|
||||||
a subprocess, so plugin source is written out as importable files instead.
|
|
||||||
|
|
||||||
Unlike ``ds_localhost_http_server`` this is function-scoped and takes a
|
|
||||||
fresh port each time, because each test needs its own plugins. Call it as::
|
|
||||||
|
|
||||||
proc, port = serve_with_plugins({"my_plugin": PLUGIN_SOURCE})
|
|
||||||
|
|
||||||
``plugins`` maps module name to Python source. Pass
|
|
||||||
``wait_for_startup=False`` when the server is expected to fail during
|
|
||||||
startup rather than begin serving. Extra CLI arguments are passed through.
|
|
||||||
Every process started is terminated when the test ends.
|
|
||||||
"""
|
|
||||||
processes = []
|
|
||||||
|
|
||||||
def start(plugins, *extra_args, wait_for_startup=True):
|
|
||||||
plugins_dir = tmp_path / "plugins"
|
|
||||||
plugins_dir.mkdir(exist_ok=True)
|
|
||||||
for module_name, source in plugins.items():
|
|
||||||
(plugins_dir / f"{module_name}.py").write_text(source, "utf-8")
|
|
||||||
port = find_free_port()
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"datasette",
|
|
||||||
"--memory",
|
|
||||||
"--plugins-dir",
|
|
||||||
str(plugins_dir),
|
|
||||||
"-h",
|
|
||||||
"127.0.0.1",
|
|
||||||
"-p",
|
|
||||||
str(port),
|
|
||||||
*extra_args,
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
|
||||||
cwd=tempfile.gettempdir(),
|
|
||||||
)
|
|
||||||
processes.append(proc)
|
|
||||||
if wait_for_startup:
|
|
||||||
wait_until_responds(
|
|
||||||
f"http://127.0.0.1:{port}/-/versions.json", process=proc
|
|
||||||
)
|
|
||||||
return proc, port
|
|
||||||
|
|
||||||
yield start
|
|
||||||
|
|
||||||
for proc in processes:
|
|
||||||
stop_process(proc)
|
|
||||||
|
|
||||||
|
|
||||||
# Import fixtures from fixtures.py to make them available
|
# Import fixtures from fixtures.py to make them available
|
||||||
from .fixtures import ( # noqa: F401
|
from .fixtures import ( # noqa: F401
|
||||||
TEMP_PLUGIN_SECRET_FILE,
|
TEMP_PLUGIN_SECRET_FILE,
|
||||||
|
|
|
||||||
|
|
@ -169,10 +169,12 @@ def make_app_client(
|
||||||
template_dir=template_dir,
|
template_dir=template_dir,
|
||||||
crossdb=crossdb,
|
crossdb=crossdb,
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
yield TestClient(ds)
|
yield TestClient(ds)
|
||||||
finally:
|
# Close as many database connections as possible
|
||||||
ds.close()
|
# to try and avoid too many open files error
|
||||||
|
for db in ds.databases.values():
|
||||||
|
if not db.is_memory:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|
@ -184,10 +186,9 @@ def app_client():
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def app_client_no_files():
|
def app_client_no_files():
|
||||||
ds = Datasette([])
|
ds = Datasette([])
|
||||||
try:
|
|
||||||
yield TestClient(ds)
|
yield TestClient(ds)
|
||||||
finally:
|
for db in ds.databases.values():
|
||||||
ds.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import pytest
|
||||||
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.plugins import DEFAULT_PLUGINS
|
from datasette.plugins import DEFAULT_PLUGINS
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
|
||||||
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
|
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
|
||||||
from datasette.utils.sqlite import sqlite_version
|
from datasette.utils.sqlite import sqlite_version
|
||||||
from datasette.version import __version__
|
from datasette.version import __version__
|
||||||
|
|
@ -102,11 +101,14 @@ async def test_database_page(ds_client):
|
||||||
"tags",
|
"tags",
|
||||||
}
|
}
|
||||||
|
|
||||||
# The external-content index is visible, but its shadow tables need a
|
# Expected hidden tables
|
||||||
# second dependency hop and are excluded by the one-hop permission policy.
|
|
||||||
expected_hidden_tables = {
|
expected_hidden_tables = {
|
||||||
"no_primary_key",
|
"no_primary_key",
|
||||||
"searchable_fts",
|
"searchable_fts",
|
||||||
|
"searchable_fts_config",
|
||||||
|
"searchable_fts_data",
|
||||||
|
"searchable_fts_docsize",
|
||||||
|
"searchable_fts_idx",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify all expected tables exist
|
# Verify all expected tables exist
|
||||||
|
|
@ -456,67 +458,6 @@ async def test_row_foreign_key_tables(ds_client):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_row_foreign_key_tables_omit_denied_tables(request):
|
|
||||||
actor = {"id": "reader"}
|
|
||||||
ds = Datasette(
|
|
||||||
memory=True,
|
|
||||||
default_deny=True,
|
|
||||||
config={
|
|
||||||
"databases": {
|
|
||||||
"data": {
|
|
||||||
"tables": {
|
|
||||||
"parents": {"permissions": {"view-table": True}},
|
|
||||||
"private_children": {"permissions": {"view-table": False}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
request.addfinalizer(ds.close)
|
|
||||||
db = ds.add_memory_database("fk_count_leak", name="data")
|
|
||||||
await db.execute_write("create table parents (id integer primary key, name text)")
|
|
||||||
await db.execute_write("""
|
|
||||||
create table private_children (
|
|
||||||
id integer primary key,
|
|
||||||
parent_id integer references parents(id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.execute_write("insert into parents values (1, 'Public parent')")
|
|
||||||
await db.execute_write("""
|
|
||||||
insert into private_children (id, parent_id) values
|
|
||||||
(1, 1),
|
|
||||||
(2, 1),
|
|
||||||
(3, 1)
|
|
||||||
""")
|
|
||||||
await ds.invoke_startup()
|
|
||||||
|
|
||||||
parent = TableResource(database="data", table="parents")
|
|
||||||
private_children = TableResource(database="data", table="private_children")
|
|
||||||
assert await ds.allowed(action="view-table", resource=parent, actor=actor)
|
|
||||||
assert not await ds.allowed(
|
|
||||||
action="view-table", resource=private_children, actor=actor
|
|
||||||
)
|
|
||||||
assert not await ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(database="data"),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
direct_child = await ds.client.get("/data/private_children.json", actor=actor)
|
|
||||||
assert direct_child.status_code == 403
|
|
||||||
parent_response = await ds.client.get(
|
|
||||||
"/data/parents/1.json?_extra=foreign_key_tables", actor=actor
|
|
||||||
)
|
|
||||||
assert parent_response.status_code == 200
|
|
||||||
|
|
||||||
foreign_key_tables = parent_response.json().get("foreign_key_tables", [])
|
|
||||||
assert foreign_key_tables == [], (
|
|
||||||
"denied child table name, foreign-key column, and row count disclosed: "
|
|
||||||
f"{foreign_key_tables}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_row_extras(ds_client):
|
async def test_row_extras(ds_client):
|
||||||
response = await ds_client.get(
|
response = await ds_client.get(
|
||||||
|
|
@ -953,7 +894,10 @@ async def test_hidden_sqlite_stat1_table():
|
||||||
await db.execute_write("analyze")
|
await db.execute_write("analyze")
|
||||||
data = (await ds.client.get("/db.json?_show_hidden=1")).json()
|
data = (await ds.client.get("/db.json?_show_hidden=1")).json()
|
||||||
tables = [(t["name"], t["hidden"]) for t in data["tables"]]
|
tables = [(t["name"], t["hidden"]) for t in data["tables"]]
|
||||||
assert tables == [("normal", False)]
|
assert tables in (
|
||||||
|
[("normal", False), ("sqlite_stat1", True)],
|
||||||
|
[("normal", False), ("sqlite_stat1", True), ("sqlite_stat4", True)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlite_utils
|
|
||||||
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.events import RenameTableEvent
|
from datasette.events import RenameTableEvent
|
||||||
|
|
@ -56,105 +55,6 @@ def _headers(token):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("operation", ["read", "read_row", "rename"])
|
|
||||||
async def test_trailing_lf_table_permissions(tmp_path, operation):
|
|
||||||
# SQLite treats "secret" and "secret\n" as different table names. Permission
|
|
||||||
# checks and SQL execution must agree on which table a request targets.
|
|
||||||
db_path = tmp_path / "data.db"
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.executescript(
|
|
||||||
"create table secret (id integer primary key, value text);"
|
|
||||||
"insert into secret values (1, 'private');"
|
|
||||||
)
|
|
||||||
conn.close()
|
|
||||||
# Allow builder to create and use tables generally, but explicitly deny
|
|
||||||
# access to the existing secret table below. Disable arbitrary SQL access.
|
|
||||||
grants = {
|
|
||||||
action: {"id": "builder"}
|
|
||||||
for action in (
|
|
||||||
"view-database",
|
|
||||||
"create-table",
|
|
||||||
"view-table",
|
|
||||||
"insert-row",
|
|
||||||
"alter-table",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ds = Datasette(
|
|
||||||
[str(db_path)],
|
|
||||||
default_deny=True,
|
|
||||||
settings={"default_allow_sql": False},
|
|
||||||
config={
|
|
||||||
"permissions": {"view-instance": {"id": "builder"}},
|
|
||||||
"databases": {
|
|
||||||
"data": {
|
|
||||||
"permissions": grants,
|
|
||||||
"tables": {
|
|
||||||
"secret": {
|
|
||||||
"permissions": {
|
|
||||||
"view-table": False,
|
|
||||||
"insert-row": False,
|
|
||||||
"alter-table": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
headers = _headers(write_token(ds, actor_id="builder"))
|
|
||||||
try:
|
|
||||||
# Establish that the protected table is inaccessible before creating
|
|
||||||
# a second table whose name differs only by a trailing line feed.
|
|
||||||
response = await ds.client.get("/data/secret.json", headers=headers)
|
|
||||||
assert response.status_code == 403
|
|
||||||
response = await ds.client.get(
|
|
||||||
"/data/-/query.json?sql=select+*+from+secret", headers=headers
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
# Distinct values let us detect if an operation targets secret
|
|
||||||
# instead of the newly created secret\n table.
|
|
||||||
response = await ds.client.post(
|
|
||||||
"/data/-/create",
|
|
||||||
json={"table": "secret\n", "row": {"id": 1, "value": "decoy"}, "pk": "id"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
# ~0A is Datasette's URL encoding for the line feed in the table name.
|
|
||||||
if operation in ("read", "read_row"):
|
|
||||||
# Both table and row endpoints must return only the permitted row.
|
|
||||||
path = "/1.json" if operation == "read_row" else ".json"
|
|
||||||
response = await ds.client.get(
|
|
||||||
"/data/secret~0A" + path + "?_shape=array", headers=headers
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
assert response.json() == [{"id": 1, "value": "decoy"}]
|
|
||||||
else:
|
|
||||||
# Renaming must move the permitted table, preserving its contents
|
|
||||||
# and removing its old name from the database.
|
|
||||||
response = await ds.client.post(
|
|
||||||
"/data/secret~0A/-/alter",
|
|
||||||
json={"operations": [{"op": "rename_table", "args": {"to": "moved"}}]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
db = ds.get_database("data")
|
|
||||||
assert (
|
|
||||||
await db.execute('select value from "moved"')
|
|
||||||
).single_value() == "decoy"
|
|
||||||
assert "secret\n" not in await db.table_names()
|
|
||||||
# Verify that the protected table and its data are unchanged, and that
|
|
||||||
# the API still denies access to it.
|
|
||||||
db = ds.get_database("data")
|
|
||||||
assert (
|
|
||||||
await db.execute('select value from "secret"')
|
|
||||||
).single_value() == "private"
|
|
||||||
response = await ds.client.get("/data/secret.json", headers=headers)
|
|
||||||
assert response.status_code == 403
|
|
||||||
finally:
|
|
||||||
ds.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_and_fetch_created(conn, table, insert_sql):
|
def _insert_and_fetch_created(conn, table, insert_sql):
|
||||||
cursor = conn.execute(insert_sql)
|
cursor = conn.execute(insert_sql)
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
|
|
@ -167,82 +67,6 @@ BASE64_WRITE_API_VALUE = {"$base64": True, "encoded": "AAEC/f7/"}
|
||||||
BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
|
BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("use_fallback", (False, True))
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"operation", ("insert", "upsert", "update", "delete", "create", "create_uppercase")
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"module,definition,values,shadow_suffix",
|
|
||||||
(
|
|
||||||
("fts5", "body", "'original'", "_content"),
|
|
||||||
("fts4", "body", "'original'", "_content"),
|
|
||||||
("rtree", "id, minx, maxx", "1, 0, 1", "_rowid"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize("shadow", (False, True))
|
|
||||||
async def test_structured_writes_require_ordinary_tables(
|
|
||||||
ds_write,
|
|
||||||
monkeypatch,
|
|
||||||
use_fallback,
|
|
||||||
operation,
|
|
||||||
module,
|
|
||||||
definition,
|
|
||||||
values,
|
|
||||||
shadow_suffix,
|
|
||||||
shadow,
|
|
||||||
):
|
|
||||||
if use_fallback:
|
|
||||||
monkeypatch.setattr("datasette.utils.sqlite.supports_table_list", lambda: False)
|
|
||||||
db = ds_write.get_database("data")
|
|
||||||
await db.execute_write(f"create virtual table indexed using {module}({definition})")
|
|
||||||
await db.execute_write(f"insert into indexed values ({values})")
|
|
||||||
table = "indexed" + (shadow_suffix if shadow else "")
|
|
||||||
row = (await db.execute(f"select rowid, * from {escape_sqlite(table)}")).dicts()[0]
|
|
||||||
pks = await db.primary_keys(table)
|
|
||||||
pk_value = row[pks[0] if pks else "rowid"]
|
|
||||||
before = await db.execute_fn(lambda conn: list(conn.iterdump()))
|
|
||||||
|
|
||||||
if operation in ("create", "create_uppercase"):
|
|
||||||
path = "/data/-/create"
|
|
||||||
body = {
|
|
||||||
"table": table.upper() if operation == "create_uppercase" else table,
|
|
||||||
"rows": [row],
|
|
||||||
}
|
|
||||||
elif operation in ("update", "delete"):
|
|
||||||
path = f"/data/{table}/{pk_value}/-/{operation}"
|
|
||||||
body = {"update": row} if operation == "update" else {}
|
|
||||||
else:
|
|
||||||
path = f"/data/{table}/-/{operation}"
|
|
||||||
body = {"rows": [row]}
|
|
||||||
response = await ds_write.client.post(
|
|
||||||
path, json=body, headers=_headers(write_token(ds_write))
|
|
||||||
)
|
|
||||||
assert response.status_code == 400, response.text
|
|
||||||
assert response.json()["errors"] == ["Structured writes require an ordinary table"]
|
|
||||||
assert await db.execute_fn(lambda conn: list(conn.iterdump())) == before
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_structured_writes_to_content_table_maintain_fts(ds_write):
|
|
||||||
db = ds_write.get_database("data")
|
|
||||||
await db.execute_write_fn(
|
|
||||||
lambda conn: sqlite_utils.Database(conn)["docs"].enable_fts(
|
|
||||||
["title"], create_triggers=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
response = await ds_write.client.post(
|
|
||||||
"/data/docs/-/insert",
|
|
||||||
json={"row": {"id": 1, "title": "ordinary content"}},
|
|
||||||
headers=_headers(write_token(ds_write)),
|
|
||||||
)
|
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
matches = await db.execute(
|
|
||||||
"select rowid from docs_fts where docs_fts match ?", ["ordinary"]
|
|
||||||
)
|
|
||||||
assert [row[0] for row in matches.rows] == [1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
|
async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
|
||||||
token = write_token(ds_write)
|
token = write_token(ds_write)
|
||||||
|
|
@ -1471,7 +1295,7 @@ async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_w
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_foreign_key_suggestions(ds_write):
|
async def test_foreign_key_suggestions(ds_write):
|
||||||
token = write_token(ds_write, permissions=["alter-table", "view-table"])
|
token = write_token(ds_write, permissions=["at"])
|
||||||
db = ds_write.get_database("data")
|
db = ds_write.get_database("data")
|
||||||
await db.execute_write("create table owners (id integer primary key)")
|
await db.execute_write("create table owners (id integer primary key)")
|
||||||
await db.execute_write("insert into owners (id) values (1), (2), (3)")
|
await db.execute_write("insert into owners (id) values (1), (2), (3)")
|
||||||
|
|
@ -1537,7 +1361,7 @@ async def test_foreign_key_suggestions_permission_denied(ds_write):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch):
|
async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch):
|
||||||
token = write_token(ds_write, permissions=["alter-table", "view-table"])
|
token = write_token(ds_write, permissions=["at"])
|
||||||
db = ds_write.get_database("data")
|
db = ds_write.get_database("data")
|
||||||
await db.execute_write("create table owners (id integer primary key)")
|
await db.execute_write("create table owners (id integer primary key)")
|
||||||
|
|
||||||
|
|
@ -1568,7 +1392,7 @@ async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_foreign_key_targets(ds_write):
|
async def test_foreign_key_targets(ds_write):
|
||||||
token = write_token(ds_write, permissions=["create-table", "view-table"])
|
token = write_token(ds_write, permissions=["ct"])
|
||||||
db = ds_write.get_database("data")
|
db = ds_write.get_database("data")
|
||||||
await db.execute_write("create table owners (id integer primary key)")
|
await db.execute_write("create table owners (id integer primary key)")
|
||||||
await db.execute_write("create table categories (slug varchar(30) primary key)")
|
await db.execute_write("create table categories (slug varchar(30) primary key)")
|
||||||
|
|
@ -1901,42 +1725,6 @@ async def test_drop_table(ds_write, scenario):
|
||||||
assert (await ds_write.client.get("/data/docs")).status_code == 404
|
assert (await ds_write.client.get("/data/docs")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_drop_table_cleans_up_fts(ds_write):
|
|
||||||
db = ds_write.get_database("data")
|
|
||||||
|
|
||||||
def enable_fts(conn):
|
|
||||||
sqlite_utils.Database(conn)["docs"].enable_fts(["title"], create_triggers=True)
|
|
||||||
|
|
||||||
await db.execute_write_fn(enable_fts)
|
|
||||||
assert {
|
|
||||||
row[0]
|
|
||||||
for row in await db.execute(
|
|
||||||
"select name from sqlite_master where type = 'table' and name like 'docs_fts%'"
|
|
||||||
)
|
|
||||||
} == {
|
|
||||||
"docs_fts",
|
|
||||||
"docs_fts_config",
|
|
||||||
"docs_fts_data",
|
|
||||||
"docs_fts_docsize",
|
|
||||||
"docs_fts_idx",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await ds_write.client.post(
|
|
||||||
"/data/docs/-/drop",
|
|
||||||
json={"confirm": True},
|
|
||||||
headers=_headers(write_token(ds_write)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.json() == {"ok": True}
|
|
||||||
assert [
|
|
||||||
row[0]
|
|
||||||
for row in await db.execute(
|
|
||||||
"select name from sqlite_master where type = 'table' and name like 'docs_fts%'"
|
|
||||||
)
|
|
||||||
] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"input,expected_status,expected_response,expected_events",
|
"input,expected_status,expected_response,expected_events",
|
||||||
|
|
@ -2920,119 +2708,3 @@ async def test_create_using_alter_against_existing_table(
|
||||||
insert_rows_event = ds_write._tracked_events[1]
|
insert_rows_event = ds_write._tracked_events[1]
|
||||||
assert insert_rows_event.name == "insert-rows"
|
assert insert_rows_event.name == "insert-rows"
|
||||||
assert insert_rows_event.num_rows == 1
|
assert insert_rows_event.num_rows == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("denied_action", "request_body"),
|
|
||||||
(
|
|
||||||
(
|
|
||||||
"insert-row",
|
|
||||||
{
|
|
||||||
"table": "salaries",
|
|
||||||
"rows": [{"id": 9, "note": "INJ-VIA-CREATE"}],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"update-row",
|
|
||||||
{
|
|
||||||
"table": "salaries",
|
|
||||||
"rows": [{"id": 1, "note": "REPLACED"}],
|
|
||||||
"pk": "id",
|
|
||||||
"replace": True,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"alter-table",
|
|
||||||
{
|
|
||||||
"table": "salaries",
|
|
||||||
"rows": [{"id": 9, "note": "INSERTED", "extra": "NEW"}],
|
|
||||||
"alter": True,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def test_create_table_existing_table_respects_table_level_denial(
|
|
||||||
denied_action, request_body
|
|
||||||
):
|
|
||||||
# GHSA-53fc-rhfg-h7qp issue 2: POST /db/-/create against an existing table
|
|
||||||
# inserts rows into it, so insert-row (and update-row / alter-table) must be
|
|
||||||
# checked against the TableResource, not just the DatabaseResource.
|
|
||||||
ds = Datasette(
|
|
||||||
memory=True,
|
|
||||||
config={
|
|
||||||
"databases": {
|
|
||||||
# id=editor user has each permission at the database level, but
|
|
||||||
# the selected action is explicitly denied on the salaries table
|
|
||||||
"data": {
|
|
||||||
"permissions": {
|
|
||||||
"create-table": {"id": "editor"},
|
|
||||||
"insert-row": {"id": "editor"},
|
|
||||||
"update-row": {"id": "editor"},
|
|
||||||
"alter-table": {"id": "editor"},
|
|
||||||
},
|
|
||||||
"tables": {
|
|
||||||
"salaries": {"permissions": {denied_action: False}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
db = ds.add_memory_database(
|
|
||||||
f"create_table_existing_table_denied_{denied_action}", name="data"
|
|
||||||
)
|
|
||||||
await db.execute_write("create table salaries (id integer primary key, note text)")
|
|
||||||
await db.execute_write("insert into salaries values (1, 'TOPSECRET-A')")
|
|
||||||
await ds.invoke_startup()
|
|
||||||
|
|
||||||
if denied_action == "insert-row":
|
|
||||||
# Sanity: direct insert into salaries is denied for this actor
|
|
||||||
direct = await ds.client.post(
|
|
||||||
"/data/salaries/-/insert",
|
|
||||||
actor={"id": "editor"},
|
|
||||||
json={"row": {"id": 9, "note": "INJ-DIRECT"}},
|
|
||||||
)
|
|
||||||
assert direct.status_code == 403
|
|
||||||
|
|
||||||
response = await ds.client.post(
|
|
||||||
"/data/-/create",
|
|
||||||
actor={"id": "editor"},
|
|
||||||
json=request_body,
|
|
||||||
)
|
|
||||||
assert response.status_code == 403, response.json()
|
|
||||||
assert response.json()["errors"] == [f"Permission denied: need {denied_action}"]
|
|
||||||
rows = (await db.execute("select id, note from salaries order by id")).rows
|
|
||||||
assert [tuple(r) for r in rows] == [(1, "TOPSECRET-A")]
|
|
||||||
assert await db.table_columns("salaries") == ["id", "note"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_table_respects_predeclared_table_level_denial():
|
|
||||||
ds = Datasette(
|
|
||||||
memory=True,
|
|
||||||
config={
|
|
||||||
"databases": {
|
|
||||||
"data": {
|
|
||||||
"permissions": {
|
|
||||||
"create-table": {"id": "editor"},
|
|
||||||
"insert-row": {"id": "editor"},
|
|
||||||
},
|
|
||||||
"tables": {
|
|
||||||
"planned_table": {"permissions": {"insert-row": False}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
db = ds.add_memory_database("create_table_predeclared_denial", name="data")
|
|
||||||
await ds.invoke_startup()
|
|
||||||
|
|
||||||
response = await ds.client.post(
|
|
||||||
"/data/-/create",
|
|
||||||
actor={"id": "editor"},
|
|
||||||
json={"table": "planned_table", "rows": [{"id": 1}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403, response.json()
|
|
||||||
assert response.json()["errors"] == ["Permission denied: need insert-row"]
|
|
||||||
assert not await db.table_exists("planned_table")
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup as Soup
|
from bs4 import BeautifulSoup as Soup
|
||||||
|
|
@ -238,35 +237,6 @@ def test_auth_create_token(
|
||||||
assert response3.json["actor"]["id"] == "test"
|
assert response3.json["actor"]["id"] == "test"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("method", ["GET", "POST"])
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"restrictions",
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
{"a": ["vi"]},
|
|
||||||
{"d": {"db": ["vd"]}},
|
|
||||||
{"r": {"db": {"t1": ["vt"]}}},
|
|
||||||
],
|
|
||||||
ids=["empty", "instance", "database", "table"],
|
|
||||||
)
|
|
||||||
async def test_auth_create_token_not_allowed_for_restricted_actors(
|
|
||||||
bare_ds, monkeypatch, method, restrictions
|
|
||||||
):
|
|
||||||
create_token = AsyncMock()
|
|
||||||
monkeypatch.setattr(bare_ds, "create_token", create_token)
|
|
||||||
|
|
||||||
response = await bare_ds.client.request(
|
|
||||||
method,
|
|
||||||
"/-/create-token",
|
|
||||||
actor={"id": "test", "_r": restrictions},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "Restricted actors cannot create API tokens" in response.text
|
|
||||||
create_token.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
|
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
|
||||||
ds_tok = ds_client.ds.sign(
|
ds_tok = ds_client.ds.sign(
|
||||||
|
|
@ -554,25 +524,3 @@ async def test_root_without_root_enabled_no_special_permissions(ds_client):
|
||||||
)
|
)
|
||||||
is not True
|
is not True
|
||||||
), "Root without root_enabled should not automatically get set-column-type"
|
), "Root without root_enabled should not automatically get set-column-type"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("expire_after", (1, 300, 3600, 30 * 24 * 60 * 60))
|
|
||||||
def test_set_actor_cookie_honours_expire_after(expire_after):
|
|
||||||
# GHSA-53fc-rhfg-h7qp issue 4: expire_after is documented as a number of
|
|
||||||
# seconds, but every value was being replaced with 24 hours.
|
|
||||||
from datasette.app import Datasette
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
response = Response.text("")
|
|
||||||
before = int(time.time())
|
|
||||||
ds.set_actor_cookie(response, {"id": "test"}, expire_after=expire_after)
|
|
||||||
after = int(time.time())
|
|
||||||
|
|
||||||
(header,) = response._set_cookie_headers
|
|
||||||
assert header.startswith("ds_actor=")
|
|
||||||
value = header[len("ds_actor=") :].split(";", 1)[0]
|
|
||||||
data = ds.unsign(value, "actor")
|
|
||||||
assert data["a"] == {"id": "test"}
|
|
||||||
expires_at = baseconv.base62.decode(data["e"])
|
|
||||||
assert before + expire_after <= expires_at <= after + expire_after
|
|
||||||
|
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
"""
|
|
||||||
Tests for datasette.add_background_task() / start_background_tasks() and the
|
|
||||||
BackgroundTask / BackgroundTaskSupervisor machinery in
|
|
||||||
datasette/background_tasks.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx2
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.app import Datasette
|
|
||||||
from datasette.plugins import pm
|
|
||||||
|
|
||||||
|
|
||||||
async def _drive_lifespan_startup(app):
|
|
||||||
"""Send a single lifespan.startup message into app's ASGI lifespan loop
|
|
||||||
and return the list of messages sent back, without ever sending
|
|
||||||
lifespan.shutdown. Copied from tests/test_lifespan.py's helper of the
|
|
||||||
same name - mirrors what a real server does: after startup completes
|
|
||||||
it parks waiting for the next event, and we cancel that wait once
|
|
||||||
we've observed the startup response.
|
|
||||||
"""
|
|
||||||
messages_sent = []
|
|
||||||
startup_responded = asyncio.Event()
|
|
||||||
delivered = False
|
|
||||||
|
|
||||||
async def receive():
|
|
||||||
nonlocal delivered
|
|
||||||
if not delivered:
|
|
||||||
delivered = True
|
|
||||||
return {"type": "lifespan.startup"}
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
async def send(message):
|
|
||||||
messages_sent.append(message)
|
|
||||||
startup_responded.set()
|
|
||||||
|
|
||||||
task = asyncio.create_task(app({"type": "lifespan"}, receive, send))
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(startup_responded.wait(), timeout=5)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
return messages_sent
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tasks_registered_in_startup_hook_run_after_lifespan_startup():
|
|
||||||
# Two tasks registered by one plugin's startup hook - order preserved,
|
|
||||||
# both running after lifespan startup completes, and no HTTP request
|
|
||||||
# of any kind is issued anywhere in this test.
|
|
||||||
events = []
|
|
||||||
|
|
||||||
async def task_one(datasette):
|
|
||||||
events.append("task_one")
|
|
||||||
# Wait indefinitely to simulate long-lived background work, keeping
|
|
||||||
# the task "running" for the assertions below until cleanup cancels it.
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
async def task_two(datasette):
|
|
||||||
events.append("task_two")
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
class TwoTaskPlugin:
|
|
||||||
__name__ = "TwoTaskPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
datasette.add_background_task(task_one, name="task-one")
|
|
||||||
datasette.add_background_task(task_two, name="task-two")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(TwoTaskPlugin(), name="two_task_plugin")
|
|
||||||
try:
|
|
||||||
app = ds.app()
|
|
||||||
messages = await _drive_lifespan_startup(app)
|
|
||||||
assert {"type": "lifespan.startup.complete"} in messages
|
|
||||||
|
|
||||||
handles = ds._background_tasks.tasks()
|
|
||||||
assert [h.name for h in handles] == ["task-one", "task-two"]
|
|
||||||
|
|
||||||
# Let both tasks run their first line of code.
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert handles[0].state == "running"
|
|
||||||
assert handles[1].state == "running"
|
|
||||||
assert events == ["task_one", "task_two"]
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="two_task_plugin")
|
|
||||||
await ds._background_tasks.cancel_all(grace=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_launch_waits_for_every_startup_hook_before_running_any_task():
|
|
||||||
# PluginA registers a task from its startup hook; PluginB does the
|
|
||||||
# same from ITS startup hook, which runs after PluginA's (forced with
|
|
||||||
# tryfirst=True on A). Even though A's registration happens first,
|
|
||||||
# A's task body must not actually execute until every startup hook -
|
|
||||||
# including B's - has finished, since launch only happens after
|
|
||||||
# invoke_startup() completes. This is the ordering guarantee that
|
|
||||||
# dissolves datasette-cron's tryfirst=True launch hack.
|
|
||||||
hook_call_order = []
|
|
||||||
seen_names_when_a_ran = {}
|
|
||||||
|
|
||||||
async def task_a(datasette):
|
|
||||||
seen_names_when_a_ran["names"] = [
|
|
||||||
h.name for h in datasette._background_tasks.tasks()
|
|
||||||
]
|
|
||||||
|
|
||||||
async def task_b(datasette):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class PluginA:
|
|
||||||
__name__ = "PluginA"
|
|
||||||
|
|
||||||
@hookimpl(tryfirst=True)
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
hook_call_order.append("A")
|
|
||||||
datasette.add_background_task(task_a, name="task-a")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
class PluginB:
|
|
||||||
__name__ = "PluginB"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
hook_call_order.append("B")
|
|
||||||
datasette.add_background_task(task_b, name="task-b")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(PluginA(), name="plugin_a")
|
|
||||||
pm.register(PluginB(), name="plugin_b")
|
|
||||||
try:
|
|
||||||
await ds.start_background_tasks()
|
|
||||||
# Confirm A's startup hook really did run (and register task-a)
|
|
||||||
# strictly before B's startup hook ran.
|
|
||||||
assert hook_call_order == ["A", "B"]
|
|
||||||
|
|
||||||
handles = ds._background_tasks.tasks()
|
|
||||||
await asyncio.wait_for(asyncio.gather(*[h.task for h in handles]), timeout=5)
|
|
||||||
# Yet by the time task-a's own body executed (after launch, which
|
|
||||||
# only happens once every startup hook - including B's - has
|
|
||||||
# finished), task-b was already registered.
|
|
||||||
assert "task-b" in seen_names_when_a_ran["names"]
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="plugin_a")
|
|
||||||
pm.unregister(name="plugin_b")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_first_requests_launch_background_tasks_exactly_once():
|
|
||||||
launch_count = {"n": 0}
|
|
||||||
|
|
||||||
async def counting_task(datasette):
|
|
||||||
launch_count["n"] += 1
|
|
||||||
|
|
||||||
class CountingTaskPlugin:
|
|
||||||
__name__ = "CountingTaskPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
datasette.add_background_task(counting_task, name="counting-task")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
pm.register(CountingTaskPlugin(), name="counting_task_plugin")
|
|
||||||
try:
|
|
||||||
app = ds.app()
|
|
||||||
transport = httpx2.ASGITransport(app=app)
|
|
||||||
async with httpx2.AsyncClient(
|
|
||||||
transport=transport, base_url="http://localhost"
|
|
||||||
) as client:
|
|
||||||
responses = await asyncio.gather(
|
|
||||||
*[client.get("/-/versions.json") for _ in range(10)]
|
|
||||||
)
|
|
||||||
assert all(response.status_code == 200 for response in responses)
|
|
||||||
|
|
||||||
handles = ds._background_tasks.tasks()
|
|
||||||
assert len(handles) == 1
|
|
||||||
await asyncio.wait_for(handles[0].task, timeout=5)
|
|
||||||
assert launch_count["n"] == 1
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="counting_task_plugin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_post_launch_registration_starts_immediately_and_cancel_works():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
await ds.start_background_tasks() # nothing registered yet, but launched
|
|
||||||
|
|
||||||
started = asyncio.Event()
|
|
||||||
|
|
||||||
async def long_running(datasette):
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
handle = ds.add_background_task(long_running, name="dynamic-task")
|
|
||||||
# Registered after launch: starts immediately rather than sitting in
|
|
||||||
# "registered" limbo.
|
|
||||||
assert handle.state == "running"
|
|
||||||
assert handle.task is not None
|
|
||||||
|
|
||||||
await asyncio.wait_for(started.wait(), timeout=5)
|
|
||||||
assert handle.state == "running"
|
|
||||||
|
|
||||||
handle.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await handle.task
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert handle.state == "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pre_launch_registration_starts_as_registered():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
|
|
||||||
async def task(datasette):
|
|
||||||
pass
|
|
||||||
|
|
||||||
handle = ds.add_background_task(task, name="buffered-task")
|
|
||||||
assert handle.state == "registered"
|
|
||||||
assert handle.task is None
|
|
||||||
|
|
||||||
handle.cancel() # not yet launched: deregisters instead of cancelling
|
|
||||||
assert handle not in ds._background_tasks.tasks()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_crashing_task_logs_traceback_and_state_is_crashed(caplog):
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
await ds.start_background_tasks()
|
|
||||||
|
|
||||||
survivor_ran = asyncio.Event()
|
|
||||||
|
|
||||||
async def crashing_task(datasette):
|
|
||||||
raise RuntimeError("kaboom")
|
|
||||||
|
|
||||||
async def survivor(datasette):
|
|
||||||
survivor_ran.set()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR, logger="datasette.background_tasks"):
|
|
||||||
crash_handle = ds.add_background_task(crashing_task, name="crashing_task")
|
|
||||||
survivor_handle = ds.add_background_task(survivor, name="survivor")
|
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.gather(
|
|
||||||
crash_handle.task, survivor_handle.task, return_exceptions=True
|
|
||||||
),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert crash_handle.state == "crashed"
|
|
||||||
assert isinstance(crash_handle.exception, RuntimeError)
|
|
||||||
assert str(crash_handle.exception) == "kaboom"
|
|
||||||
|
|
||||||
# The crash must not affect any other task.
|
|
||||||
assert survivor_ran.is_set()
|
|
||||||
assert survivor_handle.state == "completed"
|
|
||||||
|
|
||||||
assert "crashing_task" in caplog.text
|
|
||||||
assert "kaboom" in caplog.text
|
|
||||||
assert "Traceback" in caplog.text
|
|
||||||
assert "RuntimeError" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_name_collisions_get_suffixed_and_explicit_names_are_respected():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
|
|
||||||
async def noop(datasette):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def another_noop(datasette):
|
|
||||||
pass
|
|
||||||
|
|
||||||
h1 = ds.add_background_task(noop, name="dup")
|
|
||||||
h2 = ds.add_background_task(another_noop, name="dup")
|
|
||||||
h3 = ds.add_background_task(noop, name="dup")
|
|
||||||
assert [h1.name, h2.name, h3.name] == ["dup", "dup-2", "dup-3"]
|
|
||||||
|
|
||||||
h_explicit = ds.add_background_task(noop, name="explicit-name")
|
|
||||||
assert h_explicit.name == "explicit-name"
|
|
||||||
|
|
||||||
h_default = ds.add_background_task(noop)
|
|
||||||
assert h_default.name == noop.__qualname__
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_background_tasks_on_bare_datasette():
|
|
||||||
# The headless-CLI path (datasette-rss's `fetch --due` shape): no
|
|
||||||
# server, no lifespan, no first HTTP request - just an explicit call.
|
|
||||||
ran = asyncio.Event()
|
|
||||||
|
|
||||||
async def task(datasette):
|
|
||||||
ran.set()
|
|
||||||
|
|
||||||
ds = Datasette([])
|
|
||||||
assert ds._startup_invoked is False
|
|
||||||
|
|
||||||
handle = ds.add_background_task(task, name="headless-task")
|
|
||||||
assert handle.state == "registered"
|
|
||||||
|
|
||||||
await ds.start_background_tasks()
|
|
||||||
|
|
||||||
assert ds._startup_invoked is True
|
|
||||||
await asyncio.wait_for(ran.wait(), timeout=5)
|
|
||||||
await asyncio.wait_for(handle.task, timeout=5)
|
|
||||||
# handle.task being done only guarantees the coroutine has returned,
|
|
||||||
# not that our done-callback (which updates handle.state) has run yet -
|
|
||||||
# asyncio schedules done-callbacks via call_soon, and awaiting an
|
|
||||||
# already-done future/task returns immediately without giving the loop
|
|
||||||
# a chance to drain its ready queue. Yield once to let it run.
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert handle.state == "completed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_all_cancels_running_tasks_and_leaves_completed_alone():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
await ds.start_background_tasks()
|
|
||||||
|
|
||||||
async def forever(datasette):
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
async def quick(datasette):
|
|
||||||
return "done"
|
|
||||||
|
|
||||||
forever_handle = ds.add_background_task(forever, name="forever")
|
|
||||||
quick_handle = ds.add_background_task(quick, name="quick")
|
|
||||||
await asyncio.wait_for(quick_handle.task, timeout=5)
|
|
||||||
assert quick_handle.state == "completed"
|
|
||||||
|
|
||||||
await ds._background_tasks.cancel_all(grace=1.0)
|
|
||||||
|
|
||||||
assert forever_handle.state == "cancelled"
|
|
||||||
assert quick_handle.state == "completed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_all_logs_stragglers_that_outlive_the_grace_period(caplog):
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
await ds.start_background_tasks()
|
|
||||||
|
|
||||||
async def stubborn(datasette):
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
# Swallowing CancelledError above and returning normally simulates
|
|
||||||
# a task that ignores cancellation for longer than the grace period.
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
|
|
||||||
handle = ds.add_background_task(stubborn, name="stubborn-task")
|
|
||||||
# Let the task actually start running and reach its first sleep (inside
|
|
||||||
# the CancelledError-suppressing block) before cancelling it - a task
|
|
||||||
# cancelled before it has ever run its first step never enters that
|
|
||||||
# block at all (the throw happens before the coroutine body starts),
|
|
||||||
# so it would finish cancelling immediately instead of behaving like a
|
|
||||||
# straggler.
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="datasette.background_tasks"):
|
|
||||||
await ds._background_tasks.cancel_all(grace=0.1)
|
|
||||||
|
|
||||||
assert "stubborn-task" in caplog.text
|
|
||||||
|
|
||||||
# Clean up: actually cancel it now that the test has made its
|
|
||||||
# assertion, so it doesn't leak past the end of the test.
|
|
||||||
handle.task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await handle.task
|
|
||||||
|
|
@ -52,56 +52,6 @@ def test_serve_with_get(tmp_path_factory):
|
||||||
pm.unregister(to_unregister)
|
pm.unregister(to_unregister)
|
||||||
|
|
||||||
|
|
||||||
def test_serve_with_get_does_not_launch_background_tasks(tmp_path_factory):
|
|
||||||
# --get must never launch background tasks, even though its TestClient
|
|
||||||
# request
|
|
||||||
# flows through the full ASGI stack (including the AsgiRunOnFirstRequest
|
|
||||||
# fallback that would otherwise launch them). The plugin's startup hook
|
|
||||||
# itself still runs (registration happens) - only the launch is
|
|
||||||
# suppressed, so the sentinel file the background task would write must
|
|
||||||
# never appear.
|
|
||||||
plugins_dir = tmp_path_factory.mktemp("plugins_for_get_background_tasks")
|
|
||||||
sentinel = plugins_dir / "sentinel.txt"
|
|
||||||
(plugins_dir / "bg_task_for_get.py").write_text(
|
|
||||||
textwrap.dedent(
|
|
||||||
f"""
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
async def inner():
|
|
||||||
async def task(datasette):
|
|
||||||
with open("{sentinel!s}", "w") as fp:
|
|
||||||
fp.write("ran")
|
|
||||||
|
|
||||||
datasette.add_background_task(task, name="get-sentinel-task")
|
|
||||||
|
|
||||||
return inner
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
"utf-8",
|
|
||||||
)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
[
|
|
||||||
"serve",
|
|
||||||
"--memory",
|
|
||||||
"--plugins-dir",
|
|
||||||
str(plugins_dir),
|
|
||||||
"--get",
|
|
||||||
"/_memory/-/query.json?sql=select+1",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert not sentinel.exists()
|
|
||||||
|
|
||||||
to_unregister = next(
|
|
||||||
p for p in pm.get_plugins() if p.__name__ == "bg_task_for_get.py"
|
|
||||||
)
|
|
||||||
pm.unregister(to_unregister)
|
|
||||||
|
|
||||||
|
|
||||||
def test_serve_with_get_headers():
|
def test_serve_with_get_headers():
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import signal
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx2
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
@pytest.mark.serial
|
||||||
def test_serve_localhost_http(ds_localhost_http_server):
|
def test_serve_localhost_http(ds_localhost_http_server):
|
||||||
response = httpx2.get("http://localhost:8041/_memory.json")
|
response = httpx.get("http://localhost:8041/_memory.json")
|
||||||
assert {
|
assert {
|
||||||
"database": "_memory",
|
"database": "_memory",
|
||||||
"path": "/_memory",
|
"path": "/_memory",
|
||||||
|
|
@ -23,205 +20,11 @@ def test_serve_localhost_http(ds_localhost_http_server):
|
||||||
)
|
)
|
||||||
def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
|
def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
|
||||||
_, uds = ds_unix_domain_socket_server
|
_, uds = ds_unix_domain_socket_server
|
||||||
transport = httpx2.HTTPTransport(uds=uds)
|
transport = httpx.HTTPTransport(uds=uds)
|
||||||
with httpx2.Client(transport=transport) as client:
|
client = httpx.Client(transport=transport)
|
||||||
response = client.get("http://localhost/_memory.json")
|
response = client.get("http://localhost/_memory.json")
|
||||||
assert {
|
assert {
|
||||||
"database": "_memory",
|
"database": "_memory",
|
||||||
"path": "/_memory",
|
"path": "/_memory",
|
||||||
"tables": [],
|
"tables": [],
|
||||||
}.items() <= response.json().items()
|
}.items() <= response.json().items()
|
||||||
|
|
||||||
|
|
||||||
# Shaped after datasette-litestream's startup hook, which schedules a
|
|
||||||
# background task with asyncio.get_running_loop().create_task(...):
|
|
||||||
# https://github.com/datasette/datasette-litestream
|
|
||||||
MARKER_TASK_PLUGIN = """
|
|
||||||
import asyncio
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
datasette._startup_calls = getattr(datasette, "_startup_calls", 0) + 1
|
|
||||||
|
|
||||||
async def _mark():
|
|
||||||
# Must await before setting the flag: a task with no internal
|
|
||||||
# await point could finish on the throwaway loop before it
|
|
||||||
# closed, masking the regression this test guards against.
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
datasette._marker_task_ran = True
|
|
||||||
|
|
||||||
asyncio.get_running_loop().create_task(_mark())
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def register_routes():
|
|
||||||
async def marker_status(datasette):
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"marker_task_ran": getattr(datasette, "_marker_task_ran", False),
|
|
||||||
"startup_calls": getattr(datasette, "_startup_calls", 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return [(r"^/-/marker-task-ran$", marker_status)]
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
STARTUP_ERROR_PLUGIN = """
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils import StartupError
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(datasette):
|
|
||||||
raise StartupError("boom from plugin")
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
def test_startup_hook_background_task_runs_on_serving_loop(serve_with_plugins):
|
|
||||||
"""
|
|
||||||
Litestream-shaped regression test: a startup hook that does
|
|
||||||
asyncio.get_running_loop().create_task(...) must have that task
|
|
||||||
actually execute before/while the server is handling requests. This
|
|
||||||
only holds if invoke_startup() and uvicorn.Server.serve() share one
|
|
||||||
event loop. This test fails against unmodified main, where
|
|
||||||
invoke_startup() runs on a throwaway loop that is closed before
|
|
||||||
uvicorn opens its own loop to serve.
|
|
||||||
"""
|
|
||||||
_, port = serve_with_plugins({"marker_task_plugin": MARKER_TASK_PLUGIN})
|
|
||||||
# The fixture has already waited for the server to answer requests. The
|
|
||||||
# marker task deliberately awaits before setting its flag, so poll for a
|
|
||||||
# moment rather than assuming it landed before the first request arrived.
|
|
||||||
deadline = time.time() + 3.0
|
|
||||||
payload = {}
|
|
||||||
while time.time() < deadline:
|
|
||||||
payload = httpx2.get(
|
|
||||||
f"http://127.0.0.1:{port}/-/marker-task-ran", timeout=1.0
|
|
||||||
).json()
|
|
||||||
if payload["marker_task_ran"]:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
assert payload.get("marker_task_ran"), (
|
|
||||||
"The startup hook's asyncio.create_task(...) never ran - "
|
|
||||||
"invoke_startup() and the server are not sharing an event loop"
|
|
||||||
)
|
|
||||||
# Polling above means this test would also pass if the startup hook were
|
|
||||||
# re-run on the serving loop by the first-request fallback - which would
|
|
||||||
# hide exactly the bug being tested. invoke_startup() is idempotent today
|
|
||||||
# so that cannot happen; assert it explicitly so that if the idempotency
|
|
||||||
# guard is ever removed this test fails loudly instead of silently
|
|
||||||
# becoming a no-op.
|
|
||||||
assert payload["startup_calls"] == 1, (
|
|
||||||
"startup hook ran {} times - the marker may have been set by a "
|
|
||||||
"re-run on the serving loop rather than by the original task".format(
|
|
||||||
payload["startup_calls"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
def test_startup_error_fails_fast_before_port_binds(serve_with_plugins):
|
|
||||||
"""
|
|
||||||
A "startup" plugin hook that raises StartupError must fail fast: print
|
|
||||||
the message, exit non-zero, and never accept a connection on the port -
|
|
||||||
the failure must happen before uvicorn.Server binds the socket.
|
|
||||||
"""
|
|
||||||
proc, port = serve_with_plugins(
|
|
||||||
{"startup_error_plugin": STARTUP_ERROR_PLUGIN}, wait_for_startup=False
|
|
||||||
)
|
|
||||||
stdout, _ = proc.communicate(timeout=15)
|
|
||||||
output = stdout.decode("utf-8")
|
|
||||||
assert proc.returncode not in (0, None), output
|
|
||||||
assert "boom from plugin" in output, output
|
|
||||||
|
|
||||||
# Nothing is listening on the port now the process has exited. This
|
|
||||||
# confirms the socket was not left bound; on its own it cannot prove the
|
|
||||||
# failure preceded the bind, since a port nothing ever touched also
|
|
||||||
# refuses connections.
|
|
||||||
with (
|
|
||||||
pytest.raises(OSError),
|
|
||||||
socket.create_connection(("127.0.0.1", port), timeout=0.2),
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Verify that SIGTERM and SIGINT sent to `datasette serve` trigger uvicorn's
|
|
||||||
# lifespan.shutdown event and run the plugin shutdown hooks. The plugin below
|
|
||||||
# writes a sentinel file from its shutdown hook so the tests can check that
|
|
||||||
# cleanup ran after the server subprocess exits.
|
|
||||||
SHUTDOWN_SENTINEL_PLUGIN_TEMPLATE = """
|
|
||||||
import pathlib
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
SENTINEL_PATH = {sentinel_path!r}
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def shutdown(datasette):
|
|
||||||
pathlib.Path(SENTINEL_PATH).write_text("shutdown ran", "utf-8")
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _start_serve_with_shutdown_sentinel(serve_with_plugins, tmp_path):
|
|
||||||
sentinel_path = tmp_path / "shutdown-sentinel.txt"
|
|
||||||
proc, _ = serve_with_plugins(
|
|
||||||
{
|
|
||||||
"shutdown_sentinel_plugin": SHUTDOWN_SENTINEL_PLUGIN_TEMPLATE.format(
|
|
||||||
sentinel_path=str(sentinel_path)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return proc, sentinel_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
def test_sigterm_runs_shutdown_hooks(serve_with_plugins, tmp_path):
|
|
||||||
ds_proc, sentinel_path = _start_serve_with_shutdown_sentinel(
|
|
||||||
serve_with_plugins, tmp_path
|
|
||||||
)
|
|
||||||
assert not sentinel_path.exists()
|
|
||||||
ds_proc.send_signal(signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
ds_proc.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
ds_proc.kill()
|
|
||||||
ds_proc.wait()
|
|
||||||
raise AssertionError(
|
|
||||||
"datasette serve did not exit within 10s of SIGTERM\n"
|
|
||||||
+ ds_proc.stdout.read().decode("utf-8")
|
|
||||||
)
|
|
||||||
output = ds_proc.stdout.read().decode("utf-8")
|
|
||||||
assert sentinel_path.exists(), (
|
|
||||||
"shutdown hook never wrote its sentinel file after SIGTERM\n" + output
|
|
||||||
)
|
|
||||||
assert sentinel_path.read_text("utf-8") == "shutdown ran"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.serial
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
not hasattr(signal, "SIGINT"), reason="Requires signal.SIGINT support"
|
|
||||||
)
|
|
||||||
def test_sigint_runs_shutdown_hooks(serve_with_plugins, tmp_path):
|
|
||||||
ds_proc, sentinel_path = _start_serve_with_shutdown_sentinel(
|
|
||||||
serve_with_plugins, tmp_path
|
|
||||||
)
|
|
||||||
assert not sentinel_path.exists()
|
|
||||||
ds_proc.send_signal(signal.SIGINT)
|
|
||||||
try:
|
|
||||||
ds_proc.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
ds_proc.kill()
|
|
||||||
ds_proc.wait()
|
|
||||||
raise AssertionError(
|
|
||||||
"datasette serve did not exit within 10s of SIGINT\n"
|
|
||||||
+ ds_proc.stdout.read().decode("utf-8")
|
|
||||||
)
|
|
||||||
output = ds_proc.stdout.read().decode("utf-8")
|
|
||||||
assert sentinel_path.exists(), (
|
|
||||||
"shutdown hook never wrote its sentinel file after SIGINT\n" + output
|
|
||||||
)
|
|
||||||
assert sentinel_path.read_text("utf-8") == "shutdown ran"
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ def ds_ct(tmp_path_factory):
|
||||||
"'https://example.com', '{\"key\": \"value\"}')"
|
"'https://example.com', '{\"key\": \"value\"}')"
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
|
||||||
ds = Datasette(
|
ds = Datasette(
|
||||||
[db_path],
|
[db_path],
|
||||||
config={
|
config={
|
||||||
|
|
@ -71,7 +70,6 @@ def ds_ct_editor_permission(tmp_path_factory):
|
||||||
"'https://example.com', '{\"key\": \"value\"}')"
|
"'https://example.com', '{\"key\": \"value\"}')"
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
|
||||||
ds = Datasette(
|
ds = Datasette(
|
||||||
[db_path],
|
[db_path],
|
||||||
config={
|
config={
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue