[
  {
    "path": ".coveragerc",
    "content": "[run]\nomit = datasette/_version.py, datasette/utils/shutil_backport.py\n"
  },
  {
    "path": ".dockerignore",
    "content": ".DS_Store\n.cache\n.eggs\n.gitignore\n.ipynb_checkpoints\nbuild\n*.spec\n*.egg-info\ndist\nscratchpad\nvenv\n*.db\n*.sqlite\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "# Applying Black\n35d6ee2790e41e96f243c1ff58be0c9c0519a8ce\n368638555160fb9ac78f462d0f79b1394163fa30\n2b344f6a34d2adaa305996a1a580ece06397f6e4\n"
  },
  {
    "path": ".gitattributes",
    "content": "datasette/static/codemirror-* linguist-vendored\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [simonw]\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: pip\n  directory: \"/\"\n  schedule:\n    interval: daily\n    time: \"13:00\"\n  groups:\n    python-packages:\n      patterns:\n        - \"*\"\n"
  },
  {
    "path": ".github/workflows/deploy-branch-preview.yml",
    "content": "name: Deploy a Datasette branch preview to Vercel\n\non:\n  workflow_dispatch:\n    inputs:\n      branch:\n        description: \"Branch to deploy\"\n        required: true\n        type: string\n\njobs:\n  deploy-branch-preview:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v3\n    - name: Set up Python 3.11\n      uses: actions/setup-python@v6\n      with:\n        python-version: \"3.11\"\n    - name: Install dependencies\n      run: |\n        pip install datasette-publish-vercel\n    - name: Deploy the preview\n      env:\n        VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }}\n      run: |\n        export BRANCH=\"${{ github.event.inputs.branch }}\"\n        wget https://latest.datasette.io/fixtures.db\n        datasette publish vercel fixtures.db \\\n          --branch $BRANCH \\\n          --project \"datasette-preview-$BRANCH\" \\\n          --token $VERCEL_TOKEN \\\n          --scope datasette \\\n          --about \"Preview of $BRANCH\" \\\n          --about_url \"https://github.com/simonw/datasette/tree/$BRANCH\"\n"
  },
  {
    "path": ".github/workflows/deploy-latest.yml",
    "content": "name: Deploy latest.datasette.io\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n    - main\n    #   - 1.0-dev\n\npermissions:\n  contents: read\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Check out datasette\n      uses: actions/checkout@v5\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: \"3.13\"\n        cache: pip\n    - name: Install Python dependencies\n      run: |\n        python -m pip install --upgrade pip\n        python -m pip install . --group dev\n        python -m pip install sphinx-to-sqlite==0.1a1\n    - name: Run tests\n      if: ${{ github.ref == 'refs/heads/main' }}\n      run: |\n        pytest -n auto -m \"not serial\"\n        pytest -m \"serial\"\n    - name: Build fixtures.db and other files needed to deploy the demo\n      run: |-\n        python tests/fixtures.py \\\n          fixtures.db \\\n          fixtures-config.json \\\n          fixtures-metadata.json \\\n          plugins \\\n          --extra-db-filename extra_database.db\n    - name: Build docs.db\n      if: ${{ github.ref == 'refs/heads/main' }}\n      run: |-\n        cd docs\n        DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build\n        sphinx-to-sqlite ../docs.db _build\n        cd ..\n    - name: Set up the alternate-route demo\n      run: |\n        echo '\n        from datasette import hookimpl\n\n        @hookimpl\n        def startup(datasette):\n            db = datasette.get_database(\"fixtures2\")\n            db.route = \"alternative-route\"\n        ' > plugins/alternative_route.py\n        cp fixtures.db fixtures2.db\n    - name: And the counters writable canned query demo\n      run: |\n        cat > plugins/counters.py <<EOF\n        from datasette import hookimpl\n        @hookimpl\n        def startup(datasette):\n            db = datasette.add_memory_database(\"counters\")\n            async def inner():\n                await db.execute_write(\"create table if not exists counters (name text primary key, value integer)\")\n                await db.execute_write(\"insert or ignore into counters (name, value) values ('counter_a', 0)\")\n                await db.execute_write(\"insert or ignore into counters (name, value) values ('counter_b', 0)\")\n                await db.execute_write(\"insert or ignore into counters (name, value) values ('counter_c', 0)\")\n            return inner\n        @hookimpl\n        def canned_queries(database):\n            if database == \"counters\":\n                queries = {}\n                for name in (\"counter_a\", \"counter_b\", \"counter_c\"):\n                    queries[\"increment_{}\".format(name)] = {\n                        \"sql\": \"update counters set value = value + 1 where name = '{}'\".format(name),\n                        \"on_success_message_sql\": \"select 'Counter {name} incremented to ' || value from counters where name = '{name}'\".format(name=name),\n                        \"write\": True,\n                    }\n                    queries[\"decrement_{}\".format(name)] = {\n                        \"sql\": \"update counters set value = value - 1 where name = '{}'\".format(name),\n                        \"on_success_message_sql\": \"select 'Counter {name} decremented to ' || value from counters where name = '{name}'\".format(name=name),\n                        \"write\": True,\n                    }\n                return queries\n        EOF\n    # - name: Make some modifications to metadata.json\n    #   run: |\n    #     cat fixtures.json | \\\n    #       jq '.databases |= . + {\"ephemeral\": {\"allow\": {\"id\": \"*\"}}}' | \\\n    #       jq '.plugins |= . + {\"datasette-ephemeral-tables\": {\"table_ttl\": 900}}' \\\n    #       > metadata.json\n    #     cat metadata.json\n    - id: auth\n      name: Authenticate to Google Cloud\n      uses: google-github-actions/auth@v3\n      with:\n        credentials_json: ${{ secrets.GCP_SA_KEY }}\n    - name: Set up Cloud SDK\n      uses: google-github-actions/setup-gcloud@v3\n    - name: Deploy to Cloud Run\n      env:\n        LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}\n      run: |-\n        gcloud config set run/region us-central1\n        gcloud config set project datasette-222320\n        export SUFFIX=\"-${GITHUB_REF#refs/heads/}\"\n        export SUFFIX=${SUFFIX#-main}\n        # Replace 1.0 with one-dot-zero in SUFFIX\n        export SUFFIX=${SUFFIX//1.0/one-dot-zero}\n        datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \\\n            -m fixtures-metadata.json \\\n            --plugins-dir=plugins \\\n            --branch=$GITHUB_SHA \\\n            --version-note=$GITHUB_SHA \\\n            --extra-options=\"--setting template_debug 1 --setting trace_debug 1 --crossdb\" \\\n            --install 'datasette-ephemeral-tables>=0.2.2' \\\n            --service \"datasette-latest$SUFFIX\" \\\n            --secret $LATEST_DATASETTE_SECRET\n    - name: Deploy to docs as well (only for main)\n      if: ${{ github.ref == 'refs/heads/main' }}\n      run: |-\n        # Deploy docs.db to a different service\n        datasette publish cloudrun docs.db \\\n            --branch=$GITHUB_SHA \\\n            --version-note=$GITHUB_SHA \\\n            --extra-options=\"--setting template_debug 1\" \\\n            --service=datasette-docs-latest\n"
  },
  {
    "path": ".github/workflows/documentation-links.yml",
    "content": "name: Read the Docs Pull Request Preview\non:\n  pull_request_target:\n    types:\n      - opened\n\npermissions:\n  pull-requests: write\n\njobs:\n  documentation-links:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: readthedocs/actions/preview@v1\n        with:\n          project-slug: \"datasette\"\n"
  },
  {
    "path": ".github/workflows/prettier.yml",
    "content": "name: Check JavaScript for conformance with Prettier\n\non: [push]\n\npermissions:\n  contents: read\n\njobs:\n  prettier:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Check out repo\n      uses: actions/checkout@v4\n    - uses: actions/cache@v4\n      name: Configure npm caching\n      with:\n        path: ~/.npm\n        key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }}\n        restore-keys: |\n          ${{ runner.OS }}-npm-\n    - name: Install dependencies\n      run: npm ci\n    - name: Run prettier\n      run: |-\n        npm run prettier -- --check\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Python Package\n\non:\n  release:\n    types: [created]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n        cache: pip\n        cache-dependency-path: pyproject.toml\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n    - name: Run tests\n      run: |\n        pytest\n\n  deploy:\n    runs-on: ubuntu-latest\n    needs: [test]\n    environment: release\n    permissions:\n      id-token: write\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: '3.13'\n        cache: pip\n        cache-dependency-path: pyproject.toml\n    - name: Install dependencies\n      run: |\n        pip install setuptools wheel build\n    - name: Build\n      run: |\n        python -m build\n    - name: Publish\n      uses: pypa/gh-action-pypi-publish@release/v1\n\n  deploy_static_docs:\n    runs-on: ubuntu-latest\n    needs: [deploy]\n    if: \"!github.event.release.prerelease\"\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: '3.10'\n        cache: pip\n        cache-dependency-path: pyproject.toml\n    - name: Install dependencies\n      run: |\n        python -m pip install . --group dev\n        python -m pip install sphinx-to-sqlite==0.1a1\n    - name: Build docs.db\n      run: |-\n        cd docs\n        DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build\n        sphinx-to-sqlite ../docs.db _build\n        cd ..\n    - id: auth\n      name: Authenticate to Google Cloud\n      uses: google-github-actions/auth@v2\n      with:\n        credentials_json: ${{ secrets.GCP_SA_KEY }}\n    - name: Set up Cloud SDK\n      uses: google-github-actions/setup-gcloud@v3\n    - name: Deploy stable-docs.datasette.io to Cloud Run\n      run: |-\n        gcloud config set run/region us-central1\n        gcloud config set project datasette-222320\n        datasette publish cloudrun docs.db \\\n            --service=datasette-docs-stable\n\n  deploy_docker:\n    runs-on: ubuntu-latest\n    needs: [deploy]\n    if: \"!github.event.release.prerelease\"\n    steps:\n    - uses: actions/checkout@v4\n    - name: Build and push to Docker Hub\n      env:\n        DOCKER_USER: ${{ secrets.DOCKER_USER }}\n        DOCKER_PASS: ${{ secrets.DOCKER_PASS }}\n      run: |-\n        sleep 60 # Give PyPI time to make the new release available\n        docker login -u $DOCKER_USER -p $DOCKER_PASS\n        export REPO=datasetteproject/datasette\n        docker build -f Dockerfile \\\n          -t $REPO:${GITHUB_REF#refs/tags/} \\\n          --build-arg VERSION=${GITHUB_REF#refs/tags/} .\n        docker tag $REPO:${GITHUB_REF#refs/tags/} $REPO:latest\n        docker push $REPO:${GITHUB_REF#refs/tags/}\n        docker push $REPO:latest\n"
  },
  {
    "path": ".github/workflows/push_docker_tag.yml",
    "content": "name: Push specific Docker tag\n\non:\n  workflow_dispatch:\n    inputs:\n      version_tag:\n        description: Tag to build and push\n\npermissions:\n  contents: read\n\njobs:\n  deploy_docker:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v2\n    - name: Build and push to Docker Hub\n      env:\n        DOCKER_USER: ${{ secrets.DOCKER_USER }}\n        DOCKER_PASS: ${{ secrets.DOCKER_PASS }}\n        VERSION_TAG: ${{ github.event.inputs.version_tag }}\n      run: |-\n        docker login -u $DOCKER_USER -p $DOCKER_PASS\n        export REPO=datasetteproject/datasette\n        docker build -f Dockerfile \\\n          -t $REPO:${VERSION_TAG} \\\n          --build-arg VERSION=${VERSION_TAG} .\n        docker push $REPO:${VERSION_TAG}\n"
  },
  {
    "path": ".github/workflows/spellcheck.yml",
    "content": "name: Check spelling in documentation\n\non: [push, pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  spellcheck:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: '3.11'\n        cache: 'pip'\n        cache-dependency-path: '**/pyproject.toml'\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n    - name: Check spelling\n      run: |\n        codespell README.md --ignore-words docs/codespell-ignore-words.txt\n        codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt\n        codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt\n        codespell tests --ignore-words docs/codespell-ignore-words.txt\n"
  },
  {
    "path": ".github/workflows/stable-docs.yml",
    "content": "name: Update Stable Docs\n\non:\n  release:\n    types: [published]\n  push:\n    branches:\n    - main\n\npermissions:\n  contents: write\n\njobs:\n  update_stable_docs:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v5\n      with:\n        fetch-depth: 0  # We need all commits to find docs/ changes\n    - name: Set up Git user\n      run: |\n        git config user.name \"Automated\"\n        git config user.email \"actions@users.noreply.github.com\"\n    - name: Create stable branch if it does not yet exist\n      run: |\n        if ! git ls-remote --heads origin stable | grep -qE '\\bstable\\b'; then\n          # Make sure we have all tags locally\n          git fetch --tags --quiet\n\n          # Latest tag that is just numbers and dots (optionally prefixed with 'v')\n          # e.g., 0.65.2 or v0.65.2 — excludes 1.0a20, 1.0-rc1, etc.\n          LATEST_RELEASE=$(\n            git tag -l --sort=-v:refname \\\n            | grep -E '^v?[0-9]+(\\.[0-9]+){1,3}$' \\\n            | head -n1\n          )\n\n          git checkout -b stable\n\n          # If there are any stable releases, copy docs/ from the most recent\n          if [ -n \"$LATEST_RELEASE\" ]; then\n            rm -rf docs/\n            git checkout \"$LATEST_RELEASE\" -- docs/ || true\n          fi\n\n          git commit -m \"Populate docs/ from $LATEST_RELEASE\" || echo \"No changes\"\n          git push -u origin stable\n        fi\n    - name: Handle Release\n      if: github.event_name == 'release' && !github.event.release.prerelease\n      run: |\n        git fetch --all\n        git checkout stable\n        git reset --hard ${GITHUB_REF#refs/tags/}\n        git push origin stable --force\n    - name: Handle Commit to Main\n      if: contains(github.event.head_commit.message, '!stable-docs')\n      run: |\n        git fetch origin\n        git checkout -b stable origin/stable\n        # Get the list of modified files in docs/ from the current commit\n        FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)\n        # Check if the list of files is non-empty\n        if [[ -n \"$FILES\" ]]; then\n          # Checkout those files to the stable branch to over-write with their contents\n          for FILE in $FILES; do\n            git checkout ${{ github.sha }} -- $FILE\n          done\n          git add docs/\n          git commit -m \"Doc changes from ${{ github.sha }}\"\n          git push origin stable\n        else\n          echo \"No changes to docs/ in this commit.\"\n          exit 0\n        fi\n"
  },
  {
    "path": ".github/workflows/test-coverage.yml",
    "content": "name: Calculate test coverage\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Check out datasette\n      uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: '3.12'\n        cache: 'pip'\n        cache-dependency-path: '**/pyproject.toml'\n    - name: Install Python dependencies\n      run: |\n        python -m pip install --upgrade pip\n        python -m pip install . --group dev\n        python -m pip install pytest-cov\n    - name: Run tests\n      run: |-\n        ls -lah\n        cat .coveragerc\n        pytest -m \"not serial\" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x\n        ls -lah\n    - name: Upload coverage report\n      uses: codecov/codecov-action@v1\n      with:\n        token: ${{ secrets.CODECOV_TOKEN }}\n        file: coverage.xml\n"
  },
  {
    "path": ".github/workflows/test-pyodide.yml",
    "content": "name: Test in Pyodide with shot-scraper\n\non:\n  push:\n  pull_request:\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python 3.10\n      uses: actions/setup-python@v6\n      with:\n        python-version: \"3.10\"\n        cache: 'pip'\n        cache-dependency-path: '**/pyproject.toml'\n    - name: Cache Playwright browsers\n      uses: actions/cache@v4\n      with:\n        path: ~/.cache/ms-playwright/\n        key: ${{ runner.os }}-browsers\n    - name: Install Playwright dependencies\n      run: |\n        pip install shot-scraper build\n        shot-scraper install\n    - name: Run test\n      run: |\n        ./test-in-pyodide-with-shot-scraper.sh\n"
  },
  {
    "path": ".github/workflows/test-sqlite-support.yml",
    "content": "name: Test SQLite versions\n\non: [push, pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ${{ matrix.platform }}\n    continue-on-error: true\n    strategy:\n      matrix:\n        platform: [ubuntu-latest]\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n        sqlite-version: [\n          #\"3\", # latest version\n          \"3.46\",\n          #\"3.45\",\n          #\"3.27\",\n          #\"3.26\",\n          \"3.25\",\n          #\"3.25.3\", # 2018-09-25, window functions breaks test_upsert for some reason on 3.10, skip for now\n          #\"3.24\", # 2018-06-04, added UPSERT support\n          #\"3.23.1\" # 2018-04-10, before UPSERT\n        ]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n        allow-prereleases: true\n        cache: pip\n        cache-dependency-path: pyproject.toml\n    - name: Set up SQLite ${{ matrix.sqlite-version }}\n      uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6\n      with:\n        version: ${{ matrix.sqlite-version }}\n        cflags: \"-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1\"\n    - run: python3 -c \"import sqlite3; print(sqlite3.sqlite_version)\"\n    - run: echo $LD_LIBRARY_PATH\n    - name: Build extension for --load-extension test\n      run: |-\n        (cd tests && gcc ext.c -fPIC -shared -o ext.so)\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n        pip freeze\n    - name: Run tests\n      run: |\n        pytest -n auto -m \"not serial\"\n        pytest -m \"serial\"\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n        allow-prereleases: true\n        cache: pip\n        cache-dependency-path: pyproject.toml\n    - name: Build extension for --load-extension test\n      run: |-\n        (cd tests && gcc ext.c -fPIC -shared -o ext.so)\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n        pip freeze\n    - name: Run tests\n      run: |\n        pytest -n auto -m \"not serial\"\n        pytest -m \"serial\"\n        # And the test that exceeds a localhost HTTPS server\n        tests/test_datasette_https_server.sh\n    - name: Black\n      run: |\n        black --version\n        black --check .\n    - name: Ruff\n      run: ruff check datasette tests\n    - name: Check if cog needs to be run\n      run: |\n        cog --check docs/*.rst\n    - name: Check if blacken-docs needs to be run\n      run: |\n        # This fails on syntax errors, or a diff was applied\n        blacken-docs -l 60 docs/*.rst\n    - name: Test DATASETTE_LOAD_PLUGINS\n      run: |\n        pip install datasette-init datasette-json-html\n        tests/test-datasette-load-plugins.sh\n"
  },
  {
    "path": ".github/workflows/tmate-mac.yml",
    "content": "name: tmate session mac\n\non:\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: macos-latest\n    steps:\n    - uses: actions/checkout@v2\n    - name: Setup tmate session\n      uses: mxschmitt/action-tmate@v3\n"
  },
  {
    "path": ".github/workflows/tmate.yml",
    "content": "name: tmate session\n\non:\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  models: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v2\n    - name: Setup tmate session\n      uses: mxschmitt/action-tmate@v3\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "build-metadata.json\ndatasets.json\n\nscratchpad\n\n.vscode\n\nuv.lock\ndata.db\n\n# test databases\n*.db\n\n# We don't use Pipfile, so ignore them\nPipfile\nPipfile.lock\n\nfixtures.db\n*test.db\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# pyenv\n.python-version\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# dotenv\n.env\n\n# virtualenv\n.venv\nvenv/\nENV/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n\n# macOS files\n.DS_Store\nnode_modules\n.*.swp\n\n# In case someone compiled tests/ext.c for test_load_extensions, don't\n# include it in source control.\ntests/*.dylib\ntests/*.so\ntests/*.dll\n\n.idea"
  },
  {
    "path": ".isort.cfg",
    "content": "[settings]\nmulti_line_output=3\n\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"tabWidth\": 2,\n  \"useTabs\": false\n}\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "version: 2\n\nsphinx:\n  configuration: docs/conf.py\n\nbuild:\n  os: ubuntu-24.04\n  tools:\n    python: \"3.13\"\n  jobs:\n    install:\n    - pip install --upgrade pip\n    - pip install . --group dev\n\nformats:\n- pdf\n- epub\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n`swillison+datasette-code-of-conduct@gmail.com`.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.11.0-slim-bullseye as build\n\n# Version of Datasette to install, e.g. 0.55\n#   docker build . -t datasette --build-arg VERSION=0.55\nARG VERSION\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends libsqlite3-mod-spatialite && \\\n    apt clean && \\\n    rm -rf /var/lib/apt && \\\n    rm -rf /var/lib/dpkg/info/*\n\nRUN pip install https://github.com/simonw/datasette/archive/refs/tags/${VERSION}.zip && \\\n    find /usr/local/lib -name '__pycache__' | xargs rm -r && \\\n    rm -rf /root/.cache/pip\n\nEXPOSE 8001\nCMD [\"datasette\"]\n"
  },
  {
    "path": "Justfile",
    "content": "export DATASETTE_SECRET := \"not_a_secret\"\n\n# Run tests and linters\n@default: test lint\n\n# Setup project\n@init:\n  uv sync\n\n# Run pytest with supplied options\n@test *options: init\n  uv run pytest -n auto {{options}}\n\n@codespell:\n  uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt\n  uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt\n  uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt\n  uv run codespell tests --ignore-words docs/codespell-ignore-words.txt\n\n# Run linters: black, ruff, cog\n@lint: codespell\n  uv run black datasette tests --check\n  uv run ruff check datasette tests\n  uv run cog --check README.md docs/*.rst\n\n# Apply ruff fixes\n@fix:\n  uv run ruff check --fix datasette tests\n\n# Rebuild docs with cog\n@cog:\n  uv run cog -r README.md docs/*.rst\n\n# Serve live docs on localhost:8000\n@docs: cog blacken-docs\n  uv run make -C docs livehtml\n\n# Build docs as static HTML\n@docs-build: cog blacken-docs\n  rm -rf docs/_build && cd docs && uv run make html\n\n# Apply Black\n@black:\n  uv run black datasette tests\n\n# Apply blacken-docs\n@blacken-docs:\n  uv run blacken-docs -l 60 docs/*.rst\n\n# Apply prettier\n@prettier:\n  npm run fix\n\n# Format code with both black and prettier\n@format: black prettier blacken-docs\n\n@serve *options:\n  uv run sqlite-utils create-database data.db\n  uv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore\n  uv run python -m datasette data.db --root --reload {{options}}\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include datasette/static *\nrecursive-include datasette/templates *\ninclude versioneer.py\ninclude datasette/_version.py\ninclude LICENSE\n"
  },
  {
    "path": "README.md",
    "content": "<img src=\"https://datasette.io/static/datasette-logo.svg\" alt=\"Datasette\">\n\n[![PyPI](https://img.shields.io/pypi/v/datasette.svg)](https://pypi.org/project/datasette/)\n[![Changelog](https://img.shields.io/github/v/release/simonw/datasette?label=changelog)](https://docs.datasette.io/en/latest/changelog.html)\n[![Python 3.x](https://img.shields.io/pypi/pyversions/datasette.svg?logo=python&logoColor=white)](https://pypi.org/project/datasette/)\n[![Tests](https://github.com/simonw/datasette/workflows/Test/badge.svg)](https://github.com/simonw/datasette/actions?query=workflow%3ATest)\n[![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette/blob/main/LICENSE)\n[![docker: datasette](https://img.shields.io/badge/docker-datasette-blue)](https://hub.docker.com/r/datasetteproject/datasette)\n[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord)\n\n*An open source multi-tool for exploring and publishing data*\n\nDatasette is a tool for exploring and publishing data. It helps people take data of any shape or size and publish that as an interactive, explorable website and accompanying API.\n\nDatasette is aimed at data journalists, museum curators, archivists, local governments, scientists, researchers and anyone else who has data that they wish to share with the world.\n\n[Explore a demo](https://datasette.io/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out [on GitHub Codespaces](https://github.com/datasette/datasette-studio).\n\n* [datasette.io](https://datasette.io/) is the official project website\n* Latest [Datasette News](https://datasette.io/news)\n* Comprehensive documentation: https://docs.datasette.io/\n* Examples: https://datasette.io/examples\n* Live demo of current `main` branch: https://latest.datasette.io/\n* Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord)\n\nWant to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem.\n\n## Installation\n\nIf you are on a Mac, [Homebrew](https://brew.sh/) is the easiest way to install Datasette:\n\n    brew install datasette\n\nYou can also install it using `pip` or `pipx`:\n\n    pip install datasette\n\nDatasette 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.\n\n## Basic usage\n\n    datasette serve path/to/database.db\n\nThis will start a web server on port 8001 - visit http://localhost:8001/ to access the web interface.\n\n`serve` is the default subcommand, you can omit it if you like.\n\nUse Chrome on OS X? You can run datasette against your browser history like so:\n\n     datasette ~/Library/Application\\ Support/Google/Chrome/Default/History --nolock\n\nNow visiting http://localhost:8001/History/downloads will show you a web interface to browse your downloads data:\n\n![Downloads table rendered by datasette](https://static.simonwillison.net/static/2017/datasette-downloads.png)\n\n## metadata.json\n\nIf you want to include licensing and source information in the generated datasette website you can do so using a JSON file that looks something like this:\n\n    {\n        \"title\": \"Five Thirty Eight\",\n        \"license\": \"CC Attribution 4.0 License\",\n        \"license_url\": \"http://creativecommons.org/licenses/by/4.0/\",\n        \"source\": \"fivethirtyeight/data on GitHub\",\n        \"source_url\": \"https://github.com/fivethirtyeight/data\"\n    }\n\nSave this in `metadata.json` and run Datasette like so:\n\n    datasette serve fivethirtyeight.db -m metadata.json\n\nThe license and source information will be displayed on the index page and in the footer. They will also be included in the JSON produced by the API.\n\n## datasette publish\n\nIf you have [Heroku](https://heroku.com/) or [Google Cloud Run](https://cloud.google.com/run/) configured, Datasette can deploy one or more SQLite databases to the internet with a single command:\n\n    datasette publish heroku database.db\n\nOr:\n\n    datasette publish cloudrun database.db\n\nThis will create a docker image containing both the datasette application and the specified SQLite database files. It will then deploy that image to Heroku or Cloud Run and give you a URL to access the resulting website and API.\n\nSee [Publishing data](https://docs.datasette.io/en/stable/publish.html) in the documentation for more details.\n\n## Datasette Lite\n\n[Datasette Lite](https://lite.datasette.io/) is Datasette packaged using WebAssembly so that it runs entirely in your browser, no Python web application server required. Read more about that in the [Datasette Lite documentation](https://github.com/simonw/datasette-lite/blob/main/README.md).\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project:\n      default:\n        informational: true\n    patch:\n      default:\n        informational: true\n"
  },
  {
    "path": "datasette/__init__.py",
    "content": "from datasette.permissions import Permission  # noqa\nfrom datasette.version import __version_info__, __version__  # noqa\nfrom datasette.events import Event  # noqa\nfrom datasette.tokens import TokenHandler, TokenRestrictions  # noqa\nfrom datasette.utils.asgi import Forbidden, NotFound, Request, Response  # noqa\nfrom datasette.utils import actor_matches_allow  # noqa\nfrom datasette.views import Context  # noqa\nfrom .hookspecs import hookimpl  # noqa\nfrom .hookspecs import hookspec  # noqa\n"
  },
  {
    "path": "datasette/__main__.py",
    "content": "from datasette.cli import cli\n\nif __name__ == \"__main__\":\n    cli()\n"
  },
  {
    "path": "datasette/actor_auth_cookie.py",
    "content": "from datasette import hookimpl\nfrom itsdangerous import BadSignature\nfrom datasette.utils import baseconv\nimport time\n\n\n@hookimpl\ndef actor_from_request(datasette, request):\n    if \"ds_actor\" not in request.cookies:\n        return None\n    try:\n        decoded = datasette.unsign(request.cookies[\"ds_actor\"], \"actor\")\n        # If it has \"e\" and \"a\" keys process the \"e\" expiry\n        if not isinstance(decoded, dict) or \"a\" not in decoded:\n            return None\n        expires_at = decoded.get(\"e\")\n        if expires_at:\n            timestamp = int(baseconv.base62.decode(expires_at))\n            if time.time() > timestamp:\n                return None\n        return decoded[\"a\"]\n    except BadSignature:\n        return None\n"
  },
  {
    "path": "datasette/app.py",
    "content": "from __future__ import annotations\n\nfrom asgi_csrf import Errors\nimport asyncio\nimport contextvars\nfrom typing import TYPE_CHECKING, Any, Dict, Iterable, List\n\nif TYPE_CHECKING:\n    from datasette.permissions import Resource\n    from datasette.tokens import TokenRestrictions\nimport asgi_csrf\nimport collections\nimport dataclasses\nimport datetime\nimport functools\nimport glob\nimport hashlib\nimport httpx\nimport importlib.metadata\nimport inspect\nfrom itsdangerous import BadSignature\nimport json\nimport os\nimport re\nimport secrets\nimport sys\nimport threading\nimport time\nimport types\nimport urllib.parse\nfrom concurrent import futures\nfrom pathlib import Path\n\nfrom markupsafe import Markup, escape\nfrom itsdangerous import URLSafeSerializer\nfrom jinja2 import (\n    ChoiceLoader,\n    Environment,\n    FileSystemLoader,\n    PrefixLoader,\n)\nfrom jinja2.environment import Template\nfrom jinja2.exceptions import TemplateNotFound\n\nfrom .events import Event\nfrom .column_types import SQLiteType\nfrom .views import Context\nfrom .views.database import database_download, DatabaseView, TableCreateView, QueryView\nfrom .views.index import IndexView\nfrom .views.special import (\n    JsonDataView,\n    PatternPortfolioView,\n    AuthTokenView,\n    ApiExplorerView,\n    CreateTokenView,\n    LogoutView,\n    AllowDebugView,\n    PermissionsDebugView,\n    MessagesDebugView,\n    AllowedResourcesView,\n    PermissionRulesView,\n    PermissionCheckView,\n    TablesView,\n    InstanceSchemaView,\n    DatabaseSchemaView,\n    TableSchemaView,\n)\nfrom .views.table import (\n    TableInsertView,\n    TableUpsertView,\n    TableSetColumnTypeView,\n    TableDropView,\n    table_view,\n)\nfrom .views.row import RowView, RowDeleteView, RowUpdateView\nfrom .renderer import json_renderer\nfrom .url_builder import Urls\nfrom .database import Database, QueryInterrupted\n\nfrom .utils import (\n    PaginatedResources,\n    PrefixedUrlString,\n    SPATIALITE_FUNCTIONS,\n    StartupError,\n    async_call_with_supported_arguments,\n    await_me_maybe,\n    baseconv,\n    call_with_supported_arguments,\n    detect_json1,\n    display_actor,\n    escape_css_string,\n    escape_sqlite,\n    find_spatialite,\n    format_bytes,\n    module_from_path,\n    move_plugins_and_allow,\n    move_table_config,\n    parse_metadata,\n    resolve_env_secrets,\n    resolve_routes,\n    tilde_decode,\n    tilde_encode,\n    to_css_class,\n    urlsafe_components,\n    redact_keys,\n    row_sql_params_pks,\n)\nfrom .utils.asgi import (\n    AsgiLifespan,\n    Forbidden,\n    NotFound,\n    DatabaseNotFound,\n    TableNotFound,\n    RowNotFound,\n    Request,\n    Response,\n    AsgiRunOnFirstRequest,\n    asgi_static,\n    asgi_send,\n    asgi_send_file,\n    asgi_send_redirect,\n)\nfrom .utils.internal_db import init_internal_db, populate_schema_tables\nfrom .utils.sqlite import (\n    sqlite3,\n    using_pysqlite3,\n)\nfrom .tracer import AsgiTracer\nfrom .plugins import pm, DEFAULT_PLUGINS, get_plugins\nfrom .version import __version__\n\nfrom .resources import DatabaseResource, TableResource\n\napp_root = Path(__file__).parent.parent\n\n\n# Context variable to track when code is executing within a datasette.client request\n_in_datasette_client = contextvars.ContextVar(\"in_datasette_client\", default=False)\n\n\nclass _DatasetteClientContext:\n    \"\"\"Context manager to mark code as executing within a datasette.client request.\"\"\"\n\n    def __enter__(self):\n        self.token = _in_datasette_client.set(True)\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        _in_datasette_client.reset(self.token)\n        return False\n\n\n@dataclasses.dataclass\nclass PermissionCheck:\n    \"\"\"Represents a logged permission check for debugging purposes.\"\"\"\n\n    when: str\n    actor: Dict[str, Any] | None\n    action: str\n    parent: str | None\n    child: str | None\n    result: bool\n\n\n# https://github.com/simonw/datasette/issues/283#issuecomment-781591015\nSQLITE_LIMIT_ATTACHED = 10\n\nINTERNAL_DB_NAME = \"__INTERNAL__\"\n\nSetting = collections.namedtuple(\"Setting\", (\"name\", \"default\", \"help\"))\nSETTINGS = (\n    Setting(\"default_page_size\", 100, \"Default page size for the table view\"),\n    Setting(\n        \"max_returned_rows\",\n        1000,\n        \"Maximum rows that can be returned from a table or custom query\",\n    ),\n    Setting(\n        \"max_insert_rows\",\n        100,\n        \"Maximum rows that can be inserted at a time using the bulk insert API\",\n    ),\n    Setting(\n        \"num_sql_threads\",\n        3,\n        \"Number of threads in the thread pool for executing SQLite queries\",\n    ),\n    Setting(\"sql_time_limit_ms\", 1000, \"Time limit for a SQL query in milliseconds\"),\n    Setting(\n        \"default_facet_size\", 30, \"Number of values to return for requested facets\"\n    ),\n    Setting(\"facet_time_limit_ms\", 200, \"Time limit for calculating a requested facet\"),\n    Setting(\n        \"facet_suggest_time_limit_ms\",\n        50,\n        \"Time limit for calculating a suggested facet\",\n    ),\n    Setting(\n        \"allow_facet\",\n        True,\n        \"Allow users to specify columns to facet using ?_facet= parameter\",\n    ),\n    Setting(\n        \"allow_download\",\n        True,\n        \"Allow users to download the original SQLite database files\",\n    ),\n    Setting(\n        \"allow_signed_tokens\",\n        True,\n        \"Allow users to create and use signed API tokens\",\n    ),\n    Setting(\n        \"default_allow_sql\",\n        True,\n        \"Allow anyone to run arbitrary SQL queries\",\n    ),\n    Setting(\n        \"max_signed_tokens_ttl\",\n        0,\n        \"Maximum allowed expiry time for signed API tokens\",\n    ),\n    Setting(\"suggest_facets\", True, \"Calculate and display suggested facets\"),\n    Setting(\n        \"default_cache_ttl\",\n        5,\n        \"Default HTTP cache TTL (used in Cache-Control: max-age= header)\",\n    ),\n    Setting(\"cache_size_kb\", 0, \"SQLite cache size in KB (0 == use SQLite default)\"),\n    Setting(\n        \"allow_csv_stream\",\n        True,\n        \"Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)\",\n    ),\n    Setting(\n        \"max_csv_mb\",\n        100,\n        \"Maximum size allowed for CSV export in MB - set 0 to disable this limit\",\n    ),\n    Setting(\n        \"truncate_cells_html\",\n        2048,\n        \"Truncate cells longer than this in HTML table view - set 0 to disable\",\n    ),\n    Setting(\n        \"force_https_urls\",\n        False,\n        \"Force URLs in API output to always use https:// protocol\",\n    ),\n    Setting(\n        \"template_debug\",\n        False,\n        \"Allow display of template debug information with ?_context=1\",\n    ),\n    Setting(\n        \"trace_debug\",\n        False,\n        \"Allow display of SQL trace debug information with ?_trace=1\",\n    ),\n    Setting(\"base_url\", \"/\", \"Datasette URLs should use this base path\"),\n)\n_HASH_URLS_REMOVED = \"The hash_urls setting has been removed, try the datasette-hashed-urls plugin instead\"\nOBSOLETE_SETTINGS = {\n    \"hash_urls\": _HASH_URLS_REMOVED,\n    \"default_cache_ttl_hashed\": _HASH_URLS_REMOVED,\n}\nDEFAULT_SETTINGS = {option.name: option.default for option in SETTINGS}\n\nFAVICON_PATH = app_root / \"datasette\" / \"static\" / \"favicon.png\"\n\nDEFAULT_NOT_SET = object()\n\n\nResourcesSQL = collections.namedtuple(\"ResourcesSQL\", (\"sql\", \"params\"))\n\n\nasync def favicon(request, send):\n    await asgi_send_file(\n        send,\n        str(FAVICON_PATH),\n        content_type=\"image/png\",\n        headers={\"Cache-Control\": \"max-age=3600, immutable, public\"},\n    )\n\n\nResolvedTable = collections.namedtuple(\"ResolvedTable\", (\"db\", \"table\", \"is_view\"))\nResolvedRow = collections.namedtuple(\n    \"ResolvedRow\", (\"db\", \"table\", \"sql\", \"params\", \"pks\", \"pk_values\", \"row\")\n)\n\n\ndef _to_string(value):\n    if isinstance(value, str):\n        return value\n    else:\n        return json.dumps(value, default=str)\n\n\nclass Datasette:\n    # Message constants:\n    INFO = 1\n    WARNING = 2\n    ERROR = 3\n\n    def __init__(\n        self,\n        files=None,\n        immutables=None,\n        cache_headers=True,\n        cors=False,\n        inspect_data=None,\n        config=None,\n        metadata=None,\n        sqlite_extensions=None,\n        template_dir=None,\n        plugins_dir=None,\n        static_mounts=None,\n        memory=False,\n        settings=None,\n        secret=None,\n        version_note=None,\n        config_dir=None,\n        pdb=False,\n        crossdb=False,\n        nolock=False,\n        internal=None,\n        default_deny=False,\n    ):\n        self._startup_invoked = False\n        assert config_dir is None or isinstance(\n            config_dir, Path\n        ), \"config_dir= should be a pathlib.Path\"\n        self.config_dir = config_dir\n        self.pdb = pdb\n        self._secret = secret or secrets.token_hex(32)\n        if files is not None and isinstance(files, str):\n            raise ValueError(\"files= must be a list of paths, not a string\")\n        self.files = tuple(files or []) + tuple(immutables or [])\n        if config_dir:\n            db_files = []\n            for ext in (\"db\", \"sqlite\", \"sqlite3\"):\n                db_files.extend(config_dir.glob(\"*.{}\".format(ext)))\n            self.files += tuple(str(f) for f in db_files)\n        if (\n            config_dir\n            and (config_dir / \"inspect-data.json\").exists()\n            and not inspect_data\n        ):\n            inspect_data = json.loads((config_dir / \"inspect-data.json\").read_text())\n            if not immutables:\n                immutable_filenames = [i[\"file\"] for i in inspect_data.values()]\n                immutables = [\n                    f for f in self.files if Path(f).name in immutable_filenames\n                ]\n        self.inspect_data = inspect_data\n        self.immutables = set(immutables or [])\n        self.databases = collections.OrderedDict()\n        self.actions = {}  # .invoke_startup() will populate this\n        self._column_types = {}  # .invoke_startup() will populate this\n        try:\n            self._refresh_schemas_lock = asyncio.Lock()\n        except RuntimeError as rex:\n            # Workaround for intermittent test failure, see:\n            # https://github.com/simonw/datasette/issues/1802\n            if \"There is no current event loop in thread\" in str(rex):\n                loop = asyncio.new_event_loop()\n                asyncio.set_event_loop(loop)\n                self._refresh_schemas_lock = asyncio.Lock()\n            else:\n                raise\n        self.crossdb = crossdb\n        self.nolock = nolock\n        if memory or crossdb or not self.files:\n            self.add_database(\n                Database(self, is_mutable=False, is_memory=True), name=\"_memory\"\n            )\n        for file in self.files:\n            self.add_database(\n                Database(self, file, is_mutable=file not in self.immutables)\n            )\n\n        self.internal_db_created = False\n        if internal is None:\n            self._internal_database = Database(self, memory_name=secrets.token_hex())\n        else:\n            self._internal_database = Database(self, path=internal, mode=\"rwc\")\n        self._internal_database.name = INTERNAL_DB_NAME\n\n        self.cache_headers = cache_headers\n        self.cors = cors\n        config_files = []\n        metadata_files = []\n        if config_dir:\n            metadata_files = [\n                config_dir / filename\n                for filename in (\"metadata.json\", \"metadata.yaml\", \"metadata.yml\")\n                if (config_dir / filename).exists()\n            ]\n            config_files = [\n                config_dir / filename\n                for filename in (\"datasette.json\", \"datasette.yaml\", \"datasette.yml\")\n                if (config_dir / filename).exists()\n            ]\n        if config_dir and metadata_files and not metadata:\n            with metadata_files[0].open() as fp:\n                metadata = parse_metadata(fp.read())\n\n        if config_dir and config_files and not config:\n            with config_files[0].open() as fp:\n                config = parse_metadata(fp.read())\n\n        # Move any \"plugins\" and \"allow\" settings from metadata to config - updates them in place\n        metadata = metadata or {}\n        config = config or {}\n        metadata, config = move_plugins_and_allow(metadata, config)\n        # Now migrate any known table configuration settings over as well\n        metadata, config = move_table_config(metadata, config)\n\n        self._metadata_local = metadata or {}\n        self.sqlite_extensions = []\n        for extension in sqlite_extensions or []:\n            # Resolve spatialite, if requested\n            if extension == \"spatialite\":\n                # Could raise SpatialiteNotFound\n                self.sqlite_extensions.append(find_spatialite())\n            else:\n                self.sqlite_extensions.append(extension)\n        if config_dir and (config_dir / \"templates\").is_dir() and not template_dir:\n            template_dir = str((config_dir / \"templates\").resolve())\n        self.template_dir = template_dir\n        if config_dir and (config_dir / \"plugins\").is_dir() and not plugins_dir:\n            plugins_dir = str((config_dir / \"plugins\").resolve())\n        self.plugins_dir = plugins_dir\n        if config_dir and (config_dir / \"static\").is_dir() and not static_mounts:\n            static_mounts = [(\"static\", str((config_dir / \"static\").resolve()))]\n        self.static_mounts = static_mounts or []\n        if config_dir and (config_dir / \"datasette.json\").exists() and not config:\n            config = json.loads((config_dir / \"datasette.json\").read_text())\n\n        config = config or {}\n        config_settings = config.get(\"settings\") or {}\n\n        # Validate settings from config file\n        for key, value in config_settings.items():\n            if key not in DEFAULT_SETTINGS:\n                raise StartupError(f\"Invalid setting '{key}' in config file\")\n            # Validate type matches expected type from DEFAULT_SETTINGS\n            if value is not None:  # Allow None/null values\n                expected_type = type(DEFAULT_SETTINGS[key])\n                actual_type = type(value)\n                if actual_type != expected_type:\n                    raise StartupError(\n                        f\"Setting '{key}' in config file has incorrect type. \"\n                        f\"Expected {expected_type.__name__}, got {actual_type.__name__}. \"\n                        f\"Value: {value!r}. \"\n                        f\"Hint: In YAML/JSON config files, remove quotes from boolean and integer values.\"\n                    )\n\n        # Validate settings from constructor parameter\n        if settings:\n            for key, value in settings.items():\n                if key not in DEFAULT_SETTINGS:\n                    raise StartupError(f\"Invalid setting '{key}' in settings parameter\")\n                if value is not None:\n                    expected_type = type(DEFAULT_SETTINGS[key])\n                    actual_type = type(value)\n                    if actual_type != expected_type:\n                        raise StartupError(\n                            f\"Setting '{key}' in settings parameter has incorrect type. \"\n                            f\"Expected {expected_type.__name__}, got {actual_type.__name__}. \"\n                            f\"Value: {value!r}\"\n                        )\n\n        self.config = config\n        # CLI settings should overwrite datasette.json settings\n        self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {}))\n        self.renderers = {}  # File extension -> (renderer, can_render) functions\n        self.version_note = version_note\n        if self.setting(\"num_sql_threads\") == 0:\n            self.executor = None\n        else:\n            self.executor = futures.ThreadPoolExecutor(\n                max_workers=self.setting(\"num_sql_threads\")\n            )\n        self.max_returned_rows = self.setting(\"max_returned_rows\")\n        self.sql_time_limit_ms = self.setting(\"sql_time_limit_ms\")\n        self.page_size = self.setting(\"default_page_size\")\n        # Execute plugins in constructor, to ensure they are available\n        # when the rest of `datasette inspect` executes\n        if self.plugins_dir:\n            for filepath in glob.glob(os.path.join(self.plugins_dir, \"*.py\")):\n                if not os.path.isfile(filepath):\n                    continue\n                mod = module_from_path(filepath, name=os.path.basename(filepath))\n                try:\n                    pm.register(mod)\n                except ValueError:\n                    # Plugin already registered\n                    pass\n\n        # Configure Jinja\n        default_templates = str(app_root / \"datasette\" / \"templates\")\n        template_paths = []\n        if self.template_dir:\n            template_paths.append(self.template_dir)\n        plugin_template_paths = [\n            plugin[\"templates_path\"]\n            for plugin in get_plugins()\n            if plugin[\"templates_path\"]\n        ]\n        template_paths.extend(plugin_template_paths)\n        template_paths.append(default_templates)\n        template_loader = ChoiceLoader(\n            [\n                FileSystemLoader(template_paths),\n                # Support {% extends \"default:table.html\" %}:\n                PrefixLoader(\n                    {\"default\": FileSystemLoader(default_templates)}, delimiter=\":\"\n                ),\n            ]\n        )\n        environment = Environment(\n            loader=template_loader,\n            autoescape=True,\n            enable_async=True,\n            # undefined=StrictUndefined,\n        )\n        environment.filters[\"escape_css_string\"] = escape_css_string\n        environment.filters[\"quote_plus\"] = urllib.parse.quote_plus\n        self._jinja_env = environment\n        environment.filters[\"escape_sqlite\"] = escape_sqlite\n        environment.filters[\"to_css_class\"] = to_css_class\n        self._register_renderers()\n        self._permission_checks = collections.deque(maxlen=200)\n        self._root_token = secrets.token_hex(32)\n        self.root_enabled = False\n        self.default_deny = default_deny\n        self.client = DatasetteClient(self)\n\n    async def apply_metadata_json(self):\n        # Apply any metadata entries from metadata.json to the internal tables\n        # step 1: top-level metadata\n        for key in self._metadata_local or {}:\n            if key == \"databases\":\n                continue\n            value = self._metadata_local[key]\n            await self.set_instance_metadata(key, _to_string(value))\n\n        # step 2: database-level metadata\n        for dbname, db in self._metadata_local.get(\"databases\", {}).items():\n            for key, value in db.items():\n                if key in (\"tables\", \"queries\"):\n                    continue\n                await self.set_database_metadata(dbname, key, _to_string(value))\n\n            # step 3: table-level metadata\n            for tablename, table in db.get(\"tables\", {}).items():\n                for key, value in table.items():\n                    if key == \"columns\":\n                        continue\n                    await self.set_resource_metadata(\n                        dbname, tablename, key, _to_string(value)\n                    )\n\n                # step 4: column-level metadata (only descriptions in metadata.json)\n                for columnname, column_description in table.get(\"columns\", {}).items():\n                    await self.set_column_metadata(\n                        dbname, tablename, columnname, \"description\", column_description\n                    )\n\n            # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log\n            # a warning to user that they should delete their metadata.json file\n\n    def get_jinja_environment(self, request: Request = None) -> Environment:\n        environment = self._jinja_env\n        if request:\n            for environment in pm.hook.jinja2_environment_from_request(\n                datasette=self, request=request, env=environment\n            ):\n                pass\n        return environment\n\n    def get_action(self, name_or_abbr: str):\n        \"\"\"\n        Returns an Action object for the given name or abbreviation. Returns None if not found.\n        \"\"\"\n        if name_or_abbr in self.actions:\n            return self.actions[name_or_abbr]\n        # Try abbreviation\n        for action in self.actions.values():\n            if action.abbr == name_or_abbr:\n                return action\n        return None\n\n    async def refresh_schemas(self):\n        # Throttle schema refreshes to at most once per second\n        if time.monotonic() - getattr(self, \"_last_schema_refresh\", 0) < 1.0:\n            return\n        self._last_schema_refresh = time.monotonic()\n        if self._refresh_schemas_lock.locked():\n            return\n        async with self._refresh_schemas_lock:\n            await self._refresh_schemas()\n\n    async def _refresh_schemas(self):\n        internal_db = self.get_internal_database()\n        if not self.internal_db_created:\n            await init_internal_db(internal_db)\n            await self.apply_metadata_json()\n            self.internal_db_created = True\n        current_schema_versions = {\n            row[\"database_name\"]: row[\"schema_version\"]\n            for row in await internal_db.execute(\n                \"select database_name, schema_version from catalog_databases\"\n            )\n        }\n        # Delete stale entries for databases that are no longer attached\n        stale_databases = set(current_schema_versions.keys()) - set(\n            self.databases.keys()\n        )\n        for stale_db_name in stale_databases:\n            await internal_db.execute_write(\n                \"DELETE FROM catalog_databases WHERE database_name = ?\",\n                [stale_db_name],\n            )\n        for database_name, db in self.databases.items():\n            schema_version = (await db.execute(\"PRAGMA schema_version\")).first()[0]\n            # Compare schema versions to see if we should skip it\n            if schema_version == current_schema_versions.get(database_name):\n                continue\n            placeholders = \"(?, ?, ?, ?)\"\n            values = [database_name, str(db.path), db.is_memory, schema_version]\n            if db.path is None:\n                placeholders = \"(?, null, ?, ?)\"\n                values = [database_name, db.is_memory, schema_version]\n            await internal_db.execute_write(\n                \"\"\"\n                INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)\n                VALUES {}\n            \"\"\".format(placeholders),\n                values,\n            )\n            await populate_schema_tables(internal_db, db)\n\n    @property\n    def urls(self):\n        return Urls(self)\n\n    @property\n    def pm(self):\n        \"\"\"\n        Return the global plugin manager instance.\n\n        This provides access to the pluggy PluginManager that manages all\n        Datasette plugins and hooks. Use datasette.pm.hook.hook_name() to\n        call plugin hooks.\n        \"\"\"\n        return pm\n\n    async def invoke_startup(self):\n        # This must be called for Datasette to be in a usable state\n        if self._startup_invoked:\n            return\n        # Register event classes\n        event_classes = []\n        for hook in pm.hook.register_events(datasette=self):\n            extra_classes = await await_me_maybe(hook)\n            if extra_classes:\n                event_classes.extend(extra_classes)\n        self.event_classes = tuple(event_classes)\n\n        # Register actions, but watch out for duplicate name/abbr\n        action_names = {}\n        action_abbrs = {}\n        for hook in pm.hook.register_actions(datasette=self):\n            if hook:\n                for action in hook:\n                    if (\n                        action.name in action_names\n                        and action != action_names[action.name]\n                    ):\n                        raise StartupError(\n                            \"Duplicate action name: {}\".format(action.name)\n                        )\n                    if (\n                        action.abbr\n                        and action.abbr in action_abbrs\n                        and action != action_abbrs[action.abbr]\n                    ):\n                        raise StartupError(\n                            \"Duplicate action abbr: {}\".format(action.abbr)\n                        )\n                    action_names[action.name] = action\n                    if action.abbr:\n                        action_abbrs[action.abbr] = action\n                    self.actions[action.name] = action\n\n        # Register column types (classes, not instances)\n        self._column_types = {}\n        for hook in pm.hook.register_column_types(datasette=self):\n            if hook:\n                for ct_cls in hook:\n                    if ct_cls.name in self._column_types:\n                        raise StartupError(f\"Duplicate column type name: {ct_cls.name}\")\n                    self._column_types[ct_cls.name] = ct_cls\n\n        for hook in pm.hook.prepare_jinja2_environment(\n            env=self._jinja_env, datasette=self\n        ):\n            await await_me_maybe(hook)\n        # Ensure internal tables and metadata are populated before startup hooks\n        await self._refresh_schemas()\n        # Load column_types from config into internal DB\n        await self._apply_column_types_config()\n        for hook in pm.hook.startup(datasette=self):\n            await await_me_maybe(hook)\n        self._startup_invoked = True\n\n    def sign(self, value, namespace=\"default\"):\n        return URLSafeSerializer(self._secret, namespace).dumps(value)\n\n    def unsign(self, signed, namespace=\"default\"):\n        return URLSafeSerializer(self._secret, namespace).loads(signed)\n\n    def in_client(self) -> bool:\n        \"\"\"Check if the current code is executing within a datasette.client request.\n\n        Returns:\n            bool: True if currently executing within a datasette.client request, False otherwise.\n        \"\"\"\n        return _in_datasette_client.get()\n\n    def _token_handlers(self):\n        \"\"\"Collect all registered token handlers from plugins.\"\"\"\n        from datasette.tokens import TokenHandler\n\n        handlers = []\n        for result in pm.hook.register_token_handler(datasette=self):\n            if isinstance(result, TokenHandler):\n                handlers.append(result)\n            elif isinstance(result, list):\n                handlers.extend(h for h in result if isinstance(h, TokenHandler))\n        return handlers\n\n    async def create_token(\n        self,\n        actor_id: str,\n        *,\n        expires_after: int | None = None,\n        restrictions: \"TokenRestrictions | None\" = None,\n        handler: str | None = None,\n    ) -> str:\n        \"\"\"\n        Create an API token for the given actor.\n\n        Uses the first registered token handler by default, or a specific\n        handler if ``handler`` is provided (matched by handler name).\n\n        Pass a :class:`TokenRestrictions` to limit which actions the token\n        can perform.\n        \"\"\"\n        handlers = self._token_handlers()\n        if not handlers:\n            raise RuntimeError(\"No token handlers are registered\")\n\n        if handler is not None:\n            matched = [h for h in handlers if h.name == handler]\n            if not matched:\n                available = [h.name for h in handlers]\n                raise ValueError(\n                    f\"Token handler {handler!r} not found. \"\n                    f\"Available handlers: {available}\"\n                )\n            chosen = matched[0]\n        else:\n            chosen = handlers[0]\n\n        return await chosen.create_token(\n            self,\n            actor_id,\n            expires_after=expires_after,\n            restrictions=restrictions,\n        )\n\n    async def verify_token(self, token: str) -> dict | None:\n        \"\"\"\n        Verify an API token by trying all registered token handlers.\n\n        Returns an actor dict from the first handler that recognizes the\n        token, or None if no handler accepts it.\n        \"\"\"\n        for token_handler in self._token_handlers():\n            result = await token_handler.verify_token(self, token)\n            if result is not None:\n                return result\n        return None\n\n    def get_database(self, name=None, route=None):\n        if route is not None:\n            matches = [db for db in self.databases.values() if db.route == route]\n            if not matches:\n                raise KeyError\n            return matches[0]\n        if name is None:\n            name = [key for key in self.databases.keys()][0]\n        return self.databases[name]\n\n    def add_database(self, db, name=None, route=None):\n        new_databases = self.databases.copy()\n        if name is None:\n            # Pick a unique name for this database\n            suggestion = db.suggest_name()\n            name = suggestion\n        else:\n            suggestion = name\n        i = 2\n        while name in self.databases:\n            name = \"{}_{}\".format(suggestion, i)\n            i += 1\n        db.name = name\n        db.route = route or name\n        new_databases[name] = db\n        # don't mutate! that causes race conditions with live import\n        self.databases = new_databases\n        return db\n\n    def add_memory_database(self, memory_name, name=None, route=None):\n        return self.add_database(\n            Database(self, memory_name=memory_name), name=name, route=route\n        )\n\n    def remove_database(self, name):\n        self.get_database(name).close()\n        new_databases = self.databases.copy()\n        new_databases.pop(name)\n        self.databases = new_databases\n\n    def setting(self, key):\n        return self._settings.get(key, None)\n\n    def settings_dict(self):\n        # Returns a fully resolved settings dictionary, useful for templates\n        return {option.name: self.setting(option.name) for option in SETTINGS}\n\n    def _metadata_recursive_update(self, orig, updated):\n        if not isinstance(orig, dict) or not isinstance(updated, dict):\n            return orig\n\n        for key, upd_value in updated.items():\n            if isinstance(upd_value, dict) and isinstance(orig.get(key), dict):\n                orig[key] = self._metadata_recursive_update(orig[key], upd_value)\n            else:\n                orig[key] = upd_value\n        return orig\n\n    async def get_instance_metadata(self):\n        rows = await self.get_internal_database().execute(\"\"\"\n              SELECT\n                key,\n                value\n              FROM metadata_instance\n            \"\"\")\n        return dict(rows)\n\n    async def get_database_metadata(self, database_name: str):\n        rows = await self.get_internal_database().execute(\n            \"\"\"\n              SELECT\n                key,\n                value\n              FROM metadata_databases\n              WHERE database_name = ?\n            \"\"\",\n            [database_name],\n        )\n        return dict(rows)\n\n    async def get_resource_metadata(self, database_name: str, resource_name: str):\n        rows = await self.get_internal_database().execute(\n            \"\"\"\n              SELECT\n                key,\n                value\n              FROM metadata_resources\n              WHERE database_name = ?\n                AND resource_name = ?\n            \"\"\",\n            [database_name, resource_name],\n        )\n        return dict(rows)\n\n    async def get_column_metadata(\n        self, database_name: str, resource_name: str, column_name: str\n    ):\n        rows = await self.get_internal_database().execute(\n            \"\"\"\n              SELECT\n                key,\n                value\n              FROM metadata_columns\n              WHERE database_name = ?\n                AND resource_name = ?\n                AND column_name = ?\n            \"\"\",\n            [database_name, resource_name, column_name],\n        )\n        return dict(rows)\n\n    async def set_instance_metadata(self, key: str, value: str):\n        # TODO upsert only supported on SQLite 3.24.0 (2018-06-04)\n        await self.get_internal_database().execute_write(\n            \"\"\"\n              INSERT INTO metadata_instance(key, value)\n                VALUES(?, ?)\n                ON CONFLICT(key) DO UPDATE SET value = excluded.value;\n            \"\"\",\n            [key, value],\n        )\n\n    async def set_database_metadata(self, database_name: str, key: str, value: str):\n        # TODO upsert only supported on SQLite 3.24.0 (2018-06-04)\n        await self.get_internal_database().execute_write(\n            \"\"\"\n              INSERT INTO metadata_databases(database_name, key, value)\n                VALUES(?, ?, ?)\n                ON CONFLICT(database_name, key) DO UPDATE SET value = excluded.value;\n            \"\"\",\n            [database_name, key, value],\n        )\n\n    async def set_resource_metadata(\n        self, database_name: str, resource_name: str, key: str, value: str\n    ):\n        # TODO upsert only supported on SQLite 3.24.0 (2018-06-04)\n        await self.get_internal_database().execute_write(\n            \"\"\"\n              INSERT INTO metadata_resources(database_name, resource_name, key, value)\n                VALUES(?, ?, ?, ?)\n                ON CONFLICT(database_name, resource_name, key) DO UPDATE SET value = excluded.value;\n            \"\"\",\n            [database_name, resource_name, key, value],\n        )\n\n    async def set_column_metadata(\n        self,\n        database_name: str,\n        resource_name: str,\n        column_name: str,\n        key: str,\n        value: str,\n    ):\n        # TODO upsert only supported on SQLite 3.24.0 (2018-06-04)\n        await self.get_internal_database().execute_write(\n            \"\"\"\n              INSERT INTO metadata_columns(database_name, resource_name, column_name, key, value)\n                VALUES(?, ?, ?, ?, ?)\n                ON CONFLICT(database_name, resource_name, column_name, key) DO UPDATE SET value = excluded.value;\n            \"\"\",\n            [database_name, resource_name, column_name, key, value],\n        )\n\n    # Column types API\n\n    async def _get_resource_column_details(self, database: str, resource: str):\n        db = self.databases.get(database)\n        if db is None:\n            return {}\n        try:\n            return {\n                column.name: column\n                for column in await db.table_column_details(resource)\n            }\n        except sqlite3.OperationalError:\n            return {}\n\n    @staticmethod\n    def _column_type_is_applicable(ct_cls, column_detail) -> bool:\n        sqlite_types = getattr(ct_cls, \"sqlite_types\", None)\n        if sqlite_types is None:\n            return True\n        if column_detail is None:\n            return False\n        actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)\n        return actual_sqlite_type in sqlite_types\n\n    async def _validate_column_type_assignment(\n        self, database: str, resource: str, column: str, ct_cls\n    ) -> None:\n        sqlite_types = getattr(ct_cls, \"sqlite_types\", None)\n        if sqlite_types is None:\n            return\n\n        column_detail = (\n            await self._get_resource_column_details(database, resource)\n        ).get(column)\n        if column_detail is None:\n            return\n\n        actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)\n        if actual_sqlite_type in sqlite_types:\n            return\n\n        allowed = \", \".join(sqlite_type.value for sqlite_type in sqlite_types)\n        actual = (\n            actual_sqlite_type.value\n            if actual_sqlite_type is not None\n            else \"unrecognized {!r}\".format(column_detail.type)\n        )\n        raise ValueError(\n            \"Column type {!r} is only applicable to SQLite types {} but {}.{}.{} \"\n            \"has SQLite type {}\".format(\n                ct_cls.name,\n                allowed,\n                database,\n                resource,\n                column,\n                actual,\n            )\n        )\n\n    async def _apply_column_types_config(self):\n        \"\"\"Load column_types from datasette.json config into the internal DB.\"\"\"\n        import logging\n\n        for db_name, db_conf in (self.config or {}).get(\"databases\", {}).items():\n            for table_name, table_conf in db_conf.get(\"tables\", {}).items():\n                for col_name, ct in table_conf.get(\"column_types\", {}).items():\n                    if isinstance(ct, str):\n                        col_type, config = ct, None\n                    else:\n                        col_type = ct[\"type\"]\n                        config = ct.get(\"config\")\n                    if col_type not in self._column_types:\n                        logging.warning(\n                            \"column_types config references unknown type %r \"\n                            \"for %s.%s.%s\",\n                            col_type,\n                            db_name,\n                            table_name,\n                            col_name,\n                        )\n                    try:\n                        await self.set_column_type(\n                            db_name, table_name, col_name, col_type, config\n                        )\n                    except ValueError as ex:\n                        logging.warning(str(ex))\n\n    async def get_column_type(self, database: str, resource: str, column: str):\n        \"\"\"\n        Return a ColumnType instance (with config baked in) for a specific\n        column, or None if no column type is assigned.\n        \"\"\"\n        row = await self.get_internal_database().execute(\n            \"SELECT column_type, config FROM column_types \"\n            \"WHERE database_name = ? AND resource_name = ? AND column_name = ?\",\n            [database, resource, column],\n        )\n        rows = row.rows\n        if not rows:\n            return None\n        ct_name, config = rows[0]\n        ct_cls = self._column_types.get(ct_name)\n        if ct_cls is None:\n            return None\n        column_detail = (\n            await self._get_resource_column_details(database, resource)\n        ).get(column)\n        if not self._column_type_is_applicable(ct_cls, column_detail):\n            return None\n        return ct_cls(config=json.loads(config) if config else None)\n\n    async def get_column_types(self, database: str, resource: str) -> dict:\n        \"\"\"\n        Return {column_name: ColumnType instance (with config)}\n        for all columns with assigned types on the given resource.\n        \"\"\"\n        rows = await self.get_internal_database().execute(\n            \"SELECT column_name, column_type, config FROM column_types \"\n            \"WHERE database_name = ? AND resource_name = ?\",\n            [database, resource],\n        )\n        column_details = await self._get_resource_column_details(database, resource)\n        result = {}\n        for row in rows.rows:\n            col_name, ct_name, config = row\n            ct_cls = self._column_types.get(ct_name)\n            if ct_cls is not None and self._column_type_is_applicable(\n                ct_cls, column_details.get(col_name)\n            ):\n                result[col_name] = ct_cls(config=json.loads(config) if config else None)\n        return result\n\n    async def set_column_type(\n        self,\n        database: str,\n        resource: str,\n        column: str,\n        column_type: str,\n        config: dict = None,\n    ) -> None:\n        \"\"\"Assign a column type. Overwrites any existing assignment.\"\"\"\n        ct_cls = self._column_types.get(column_type)\n        if ct_cls is not None:\n            await self._validate_column_type_assignment(\n                database, resource, column, ct_cls\n            )\n        await self.get_internal_database().execute_write(\n            \"\"\"INSERT OR REPLACE INTO column_types\n               (database_name, resource_name, column_name, column_type, config)\n               VALUES (?, ?, ?, ?, ?)\"\"\",\n            [\n                database,\n                resource,\n                column,\n                column_type,\n                json.dumps(config) if config else None,\n            ],\n        )\n\n    async def remove_column_type(\n        self, database: str, resource: str, column: str\n    ) -> None:\n        \"\"\"Remove a column type assignment.\"\"\"\n        await self.get_internal_database().execute_write(\n            \"DELETE FROM column_types \"\n            \"WHERE database_name = ? AND resource_name = ? AND column_name = ?\",\n            [database, resource, column],\n        )\n\n    def get_internal_database(self):\n        return self._internal_database\n\n    def plugin_config(self, plugin_name, database=None, table=None, fallback=True):\n        \"\"\"Return config for plugin, falling back from specified database/table\"\"\"\n        if database is None and table is None:\n            config = self._plugin_config_top(plugin_name)\n        else:\n            config = self._plugin_config_nested(plugin_name, database, table, fallback)\n\n        return resolve_env_secrets(config, os.environ)\n\n    def _plugin_config_top(self, plugin_name):\n        \"\"\"Returns any top-level plugin configuration for the specified plugin.\"\"\"\n        return ((self.config or {}).get(\"plugins\") or {}).get(plugin_name)\n\n    def _plugin_config_nested(self, plugin_name, database, table=None, fallback=True):\n        \"\"\"Returns any database or table-level plugin configuration for the specified plugin.\"\"\"\n        db_config = ((self.config or {}).get(\"databases\") or {}).get(database)\n\n        # if there's no db-level configuration, then return early, falling back to top-level if needed\n        if not db_config:\n            return self._plugin_config_top(plugin_name) if fallback else None\n\n        db_plugin_config = (db_config.get(\"plugins\") or {}).get(plugin_name)\n\n        if table:\n            table_plugin_config = (\n                ((db_config.get(\"tables\") or {}).get(table) or {}).get(\"plugins\") or {}\n            ).get(plugin_name)\n\n            # fallback to db_config or top-level config, in that order, if needed\n            if table_plugin_config is None and fallback:\n                return db_plugin_config or self._plugin_config_top(plugin_name)\n\n            return table_plugin_config\n\n        # fallback to top-level if needed\n        if db_plugin_config is None and fallback:\n            self._plugin_config_top(plugin_name)\n\n        return db_plugin_config\n\n    def app_css_hash(self):\n        if not hasattr(self, \"_app_css_hash\"):\n            with open(os.path.join(str(app_root), \"datasette/static/app.css\")) as fp:\n                self._app_css_hash = hashlib.sha1(fp.read().encode(\"utf8\")).hexdigest()[\n                    :6\n                ]\n        return self._app_css_hash\n\n    async def get_canned_queries(self, database_name, actor):\n        queries = {}\n        for more_queries in pm.hook.canned_queries(\n            datasette=self,\n            database=database_name,\n            actor=actor,\n        ):\n            more_queries = await await_me_maybe(more_queries)\n            queries.update(more_queries or {})\n        # Fix any {\"name\": \"select ...\"} queries to be {\"name\": {\"sql\": \"select ...\"}}\n        for key in queries:\n            if not isinstance(queries[key], dict):\n                queries[key] = {\"sql\": queries[key]}\n            # Also make sure \"name\" is available:\n            queries[key][\"name\"] = key\n        return queries\n\n    async def get_canned_query(self, database_name, query_name, actor):\n        queries = await self.get_canned_queries(database_name, actor)\n        query = queries.get(query_name)\n        if query:\n            return query\n\n    def _prepare_connection(self, conn, database):\n        conn.row_factory = sqlite3.Row\n        conn.text_factory = lambda x: str(x, \"utf-8\", \"replace\")\n        if self.sqlite_extensions and database != INTERNAL_DB_NAME:\n            conn.enable_load_extension(True)\n            for extension in self.sqlite_extensions:\n                # \"extension\" is either a string path to the extension\n                # or a 2-item tuple that specifies which entrypoint to load.\n                if isinstance(extension, tuple):\n                    path, entrypoint = extension\n                    conn.execute(\"SELECT load_extension(?, ?)\", [path, entrypoint])\n                else:\n                    conn.execute(\"SELECT load_extension(?)\", [extension])\n        if self.setting(\"cache_size_kb\"):\n            conn.execute(f\"PRAGMA cache_size=-{self.setting('cache_size_kb')}\")\n        # pylint: disable=no-member\n        if database != INTERNAL_DB_NAME:\n            pm.hook.prepare_connection(conn=conn, database=database, datasette=self)\n        # If self.crossdb and this is _memory, connect the first SQLITE_LIMIT_ATTACHED databases\n        if self.crossdb and database == \"_memory\":\n            count = 0\n            for db_name, db in self.databases.items():\n                if count >= SQLITE_LIMIT_ATTACHED or db.is_memory:\n                    continue\n                sql = 'ATTACH DATABASE \"file:{path}?{qs}\" AS [{name}];'.format(\n                    path=db.path,\n                    qs=\"mode=ro\" if db.is_mutable else \"immutable=1\",\n                    name=db_name,\n                )\n                conn.execute(sql)\n                count += 1\n\n    def add_message(self, request, message, type=INFO):\n        if not hasattr(request, \"_messages\"):\n            request._messages = []\n            request._messages_should_clear = False\n        request._messages.append((message, type))\n\n    def _write_messages_to_response(self, request, response):\n        if getattr(request, \"_messages\", None):\n            # Set those messages\n            response.set_cookie(\"ds_messages\", self.sign(request._messages, \"messages\"))\n        elif getattr(request, \"_messages_should_clear\", False):\n            response.set_cookie(\"ds_messages\", \"\", expires=0, max_age=0)\n\n    def _show_messages(self, request):\n        if getattr(request, \"_messages\", None):\n            request._messages_should_clear = True\n            messages = request._messages\n            request._messages = []\n            return messages\n        else:\n            return []\n\n    async def _crumb_items(self, request, table=None, database=None):\n        crumbs = []\n        actor = None\n        if request:\n            actor = request.actor\n        # Top-level link\n        if await self.allowed(action=\"view-instance\", actor=actor):\n            crumbs.append({\"href\": self.urls.instance(), \"label\": \"home\"})\n        # Database link\n        if database:\n            if await self.allowed(\n                action=\"view-database\",\n                resource=DatabaseResource(database=database),\n                actor=actor,\n            ):\n                crumbs.append(\n                    {\n                        \"href\": self.urls.database(database),\n                        \"label\": database,\n                    }\n                )\n        # Table link\n        if table:\n            assert database, \"table= requires database=\"\n            if await self.allowed(\n                action=\"view-table\",\n                resource=TableResource(database=database, table=table),\n                actor=actor,\n            ):\n                crumbs.append(\n                    {\n                        \"href\": self.urls.table(database, table),\n                        \"label\": table,\n                    }\n                )\n        return crumbs\n\n    async def actors_from_ids(\n        self, actor_ids: Iterable[str | int]\n    ) -> Dict[int | str, Dict]:\n        result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)\n        if result is None:\n            # Do the default thing\n            return {actor_id: {\"id\": actor_id} for actor_id in actor_ids}\n        result = await await_me_maybe(result)\n        return result\n\n    async def track_event(self, event: Event):\n        assert isinstance(event, self.event_classes), \"Invalid event type: {}\".format(\n            type(event)\n        )\n        for hook in pm.hook.track_event(datasette=self, event=event):\n            await await_me_maybe(hook)\n\n    def resource_for_action(self, action: str, parent: str | None, child: str | None):\n        \"\"\"\n        Create a Resource instance for the given action with parent/child values.\n\n        Looks up the action's resource_class and instantiates it with the\n        provided parent and child identifiers.\n\n        Args:\n            action: The action name (e.g., \"view-table\", \"view-query\")\n            parent: The parent resource identifier (e.g., database name)\n            child: The child resource identifier (e.g., table/query name)\n\n        Returns:\n            A Resource instance of the appropriate subclass\n\n        Raises:\n            ValueError: If the action is unknown\n        \"\"\"\n        from datasette.permissions import Resource\n\n        action_obj = self.actions.get(action)\n        if not action_obj:\n            raise ValueError(f\"Unknown action: {action}\")\n\n        resource_class = action_obj.resource_class\n        instance = object.__new__(resource_class)\n        Resource.__init__(instance, parent=parent, child=child)\n        return instance\n\n    async def check_visibility(\n        self,\n        actor: dict,\n        action: str,\n        resource: \"Resource\" | None = None,\n    ):\n        \"\"\"\n        Check if actor can see a resource and if it's private.\n\n        Returns (visible, private) tuple:\n        - visible: bool - can the actor see it?\n        - private: bool - if visible, can anonymous users NOT see it?\n        \"\"\"\n        from datasette.permissions import Resource\n\n        # Validate that resource is a Resource object or None\n        if resource is not None and not isinstance(resource, Resource):\n            raise TypeError(\"resource must be a Resource subclass instance or None.\")\n\n        # Check if actor can see it\n        if not await self.allowed(action=action, resource=resource, actor=actor):\n            return False, False\n\n        # Check if anonymous user can see it (for \"private\" flag)\n        if not await self.allowed(action=action, resource=resource, actor=None):\n            # Actor can see it but anonymous cannot - it's private\n            return True, True\n\n        # Both actor and anonymous can see it - it's public\n        return True, False\n\n    async def allowed_resources_sql(\n        self,\n        *,\n        action: str,\n        actor: dict | None = None,\n        parent: str | None = None,\n        include_is_private: bool = False,\n    ) -> ResourcesSQL:\n        \"\"\"\n        Build SQL query to get all resources the actor can access for the given action.\n\n        Args:\n            action: The action name (e.g., \"view-table\")\n            actor: The actor dict (or None for unauthenticated)\n            parent: Optional parent filter (e.g., database name) to limit results\n            include_is_private: If True, include is_private column showing if anonymous cannot access\n\n        Returns a namedtuple of (query: str, params: dict) that can be executed against the internal database.\n        The query returns rows with (parent, child, reason) columns, plus is_private if requested.\n\n        Example:\n            query, params = await datasette.allowed_resources_sql(\n                action=\"view-table\",\n                actor=actor,\n                parent=\"mydb\",\n                include_is_private=True\n            )\n            result = await datasette.get_internal_database().execute(query, params)\n        \"\"\"\n        from datasette.utils.actions_sql import build_allowed_resources_sql\n\n        action_obj = self.actions.get(action)\n        if not action_obj:\n            raise ValueError(f\"Unknown action: {action}\")\n\n        sql, params = await build_allowed_resources_sql(\n            self, actor, action, parent=parent, include_is_private=include_is_private\n        )\n        return ResourcesSQL(sql, params)\n\n    async def allowed_resources(\n        self,\n        action: str,\n        actor: dict | None = None,\n        *,\n        parent: str | None = None,\n        include_is_private: bool = False,\n        include_reasons: bool = False,\n        limit: int = 100,\n        next: str | None = None,\n    ) -> PaginatedResources:\n        \"\"\"\n        Return paginated resources the actor can access for the given action.\n\n        Uses SQL with keyset pagination to efficiently filter resources.\n        Returns PaginatedResources with list of Resource instances and pagination metadata.\n\n        Args:\n            action: The action name (e.g., \"view-table\")\n            actor: The actor dict (or None for unauthenticated)\n            parent: Optional parent filter (e.g., database name) to limit results\n            include_is_private: If True, adds a .private attribute to each Resource\n            include_reasons: If True, adds a .reasons attribute with List[str] of permission reasons\n            limit: Maximum number of results to return (1-1000, default 100)\n            next: Keyset token from previous page for pagination\n\n        Returns:\n            PaginatedResources with:\n                - resources: List of Resource objects for this page\n                - next: Token for next page (None if no more results)\n\n        Example:\n            # Get first page of tables\n            page = await datasette.allowed_resources(\"view-table\", actor, limit=50)\n            for table in page.resources:\n                print(f\"{table.parent}/{table.child}\")\n\n            # Get next page\n            if page.next:\n                next_page = await datasette.allowed_resources(\n                    \"view-table\", actor, limit=50, next=page.next\n                )\n\n            # With reasons for debugging\n            page = await datasette.allowed_resources(\n                \"view-table\", actor, include_reasons=True\n            )\n            for table in page.resources:\n                print(f\"{table.child}: {table.reasons}\")\n\n            # Iterate through all results with async generator\n            page = await datasette.allowed_resources(\"view-table\", actor)\n            async for table in page.all():\n                print(table.child)\n        \"\"\"\n\n        action_obj = self.actions.get(action)\n        if not action_obj:\n            raise ValueError(f\"Unknown action: {action}\")\n\n        # Validate and cap limit\n        limit = min(max(1, limit), 1000)\n\n        # Get base SQL query\n        query, params = await self.allowed_resources_sql(\n            action=action,\n            actor=actor,\n            parent=parent,\n            include_is_private=include_is_private,\n        )\n\n        # Add keyset pagination WHERE clause if next token provided\n        if next:\n            try:\n                components = urlsafe_components(next)\n                if len(components) >= 2:\n                    last_parent, last_child = components[0], components[1]\n                    # Keyset condition: (parent > last) OR (parent = last AND child > last)\n                    keyset_where = \"\"\"\n                        (parent > :keyset_parent OR\n                         (parent = :keyset_parent AND child > :keyset_child))\n                    \"\"\"\n                    # Wrap original query and add keyset filter\n                    query = f\"SELECT * FROM ({query}) WHERE {keyset_where}\"\n                    params[\"keyset_parent\"] = last_parent\n                    params[\"keyset_child\"] = last_child\n            except (ValueError, KeyError):\n                # Invalid token - ignore and start from beginning\n                pass\n\n        # Add LIMIT (fetch limit+1 to detect if there are more results)\n        # Note: query from allowed_resources_sql() already includes ORDER BY parent, child\n        query = f\"{query} LIMIT :limit\"\n        params[\"limit\"] = limit + 1\n\n        # Execute query\n        result = await self.get_internal_database().execute(query, params)\n        rows = list(result.rows)\n\n        # Check if truncated (got more than limit rows)\n        truncated = len(rows) > limit\n        if truncated:\n            rows = rows[:limit]  # Remove the extra row\n\n        # Build Resource objects with optional attributes\n        resources = []\n        for row in rows:\n            # row[0]=parent, row[1]=child, row[2]=reason, row[3]=is_private (if requested)\n            resource = self.resource_for_action(action, parent=row[0], child=row[1])\n\n            # Add reasons if requested\n            if include_reasons:\n                reason_json = row[2]\n                try:\n                    reasons_array = (\n                        json.loads(reason_json) if isinstance(reason_json, str) else []\n                    )\n                    resource.reasons = [r for r in reasons_array if r is not None]\n                except (json.JSONDecodeError, TypeError):\n                    resource.reasons = [reason_json] if reason_json else []\n\n            # Add private flag if requested\n            if include_is_private:\n                resource.private = bool(row[3])\n\n            resources.append(resource)\n\n        # Generate next token if there are more results\n        next_token = None\n        if truncated and resources:\n            last_resource = resources[-1]\n            # Use tilde-encoding like table pagination\n            next_token = \"{},{}\".format(\n                tilde_encode(str(last_resource.parent)),\n                tilde_encode(str(last_resource.child)),\n            )\n\n        return PaginatedResources(\n            resources=resources,\n            next=next_token,\n            _datasette=self,\n            _action=action,\n            _actor=actor,\n            _parent=parent,\n            _include_is_private=include_is_private,\n            _include_reasons=include_reasons,\n            _limit=limit,\n        )\n\n    async def allowed(\n        self,\n        *,\n        action: str,\n        resource: \"Resource\" = None,\n        actor: dict | None = None,\n    ) -> bool:\n        \"\"\"\n        Check if actor can perform action on specific resource.\n\n        Uses SQL to check permission for a single resource without fetching all resources.\n        This is efficient - it does NOT call allowed_resources() and check membership.\n\n        For global actions, resource should be None (or omitted).\n\n        Example:\n            from datasette.resources import TableResource\n            can_view = await datasette.allowed(\n                action=\"view-table\",\n                resource=TableResource(database=\"analytics\", table=\"users\"),\n                actor=actor\n            )\n\n            # For global actions, resource can be omitted:\n            can_debug = await datasette.allowed(action=\"permissions-debug\", actor=actor)\n        \"\"\"\n        from datasette.utils.actions_sql import check_permission_for_resource\n\n        # For global actions, resource remains None\n\n        # Check if this action has also_requires - if so, check that action first\n        action_obj = self.actions.get(action)\n        if action_obj and action_obj.also_requires:\n            # Must have the required action first\n            if not await self.allowed(\n                action=action_obj.also_requires,\n                resource=resource,\n                actor=actor,\n            ):\n                return False\n\n        # For global actions, resource is None\n        parent = resource.parent if resource else None\n        child = resource.child if resource else None\n\n        result = await check_permission_for_resource(\n            datasette=self,\n            actor=actor,\n            action=action,\n            parent=parent,\n            child=child,\n        )\n\n        # Log the permission check for debugging\n        self._permission_checks.append(\n            PermissionCheck(\n                when=datetime.datetime.now(datetime.timezone.utc).isoformat(),\n                actor=actor,\n                action=action,\n                parent=parent,\n                child=child,\n                result=result,\n            )\n        )\n\n        return result\n\n    async def ensure_permission(\n        self,\n        *,\n        action: str,\n        resource: \"Resource\" = None,\n        actor: dict | None = None,\n    ):\n        \"\"\"\n        Check if actor can perform action on resource, raising Forbidden if not.\n\n        This is a convenience wrapper around allowed() that raises Forbidden\n        instead of returning False. Use this when you want to enforce a permission\n        check and halt execution if it fails.\n\n        Example:\n            from datasette.resources import TableResource\n\n            # Will raise Forbidden if actor cannot view the table\n            await datasette.ensure_permission(\n                action=\"view-table\",\n                resource=TableResource(database=\"analytics\", table=\"users\"),\n                actor=request.actor\n            )\n\n            # For instance-level actions, resource can be omitted:\n            await datasette.ensure_permission(\n                action=\"permissions-debug\",\n                actor=request.actor\n            )\n        \"\"\"\n        if not await self.allowed(action=action, resource=resource, actor=actor):\n            raise Forbidden(action)\n\n    async def execute(\n        self,\n        db_name,\n        sql,\n        params=None,\n        truncate=False,\n        custom_time_limit=None,\n        page_size=None,\n        log_sql_errors=True,\n    ):\n        return await self.databases[db_name].execute(\n            sql,\n            params=params,\n            truncate=truncate,\n            custom_time_limit=custom_time_limit,\n            page_size=page_size,\n            log_sql_errors=log_sql_errors,\n        )\n\n    async def expand_foreign_keys(self, actor, database, table, column, values):\n        \"\"\"Returns dict mapping (column, value) -> label\"\"\"\n        labeled_fks = {}\n        db = self.databases[database]\n        foreign_keys = await db.foreign_keys_for_table(table)\n        # Find the foreign_key for this column\n        try:\n            fk = [\n                foreign_key\n                for foreign_key in foreign_keys\n                if foreign_key[\"column\"] == column\n            ][0]\n        except IndexError:\n            return {}\n        # Ensure user has permission to view the referenced table\n        from datasette.resources import TableResource\n\n        other_table = fk[\"other_table\"]\n        other_column = fk[\"other_column\"]\n        visible, _ = await self.check_visibility(\n            actor,\n            action=\"view-table\",\n            resource=TableResource(database=database, table=other_table),\n        )\n        if not visible:\n            return {}\n        label_column = await db.label_column_for_table(other_table)\n        if not label_column:\n            return {(fk[\"column\"], value): str(value) for value in values}\n        labeled_fks = {}\n        sql = \"\"\"\n            select {other_column}, {label_column}\n            from {other_table}\n            where {other_column} in ({placeholders})\n        \"\"\".format(\n            other_column=escape_sqlite(other_column),\n            label_column=escape_sqlite(label_column),\n            other_table=escape_sqlite(other_table),\n            placeholders=\", \".join([\"?\"] * len(set(values))),\n        )\n        try:\n            results = await self.execute(database, sql, list(set(values)))\n        except QueryInterrupted:\n            pass\n        else:\n            for id, value in results:\n                labeled_fks[(fk[\"column\"], id)] = value\n        return labeled_fks\n\n    def absolute_url(self, request, path):\n        url = urllib.parse.urljoin(request.url, path)\n        if url.startswith(\"http://\") and self.setting(\"force_https_urls\"):\n            url = \"https://\" + url[len(\"http://\") :]\n        return url\n\n    def _connected_databases(self):\n        return [\n            {\n                \"name\": d.name,\n                \"route\": d.route,\n                \"path\": d.path,\n                \"size\": d.size,\n                \"is_mutable\": d.is_mutable,\n                \"is_memory\": d.is_memory,\n                \"hash\": d.hash,\n            }\n            for name, d in self.databases.items()\n        ]\n\n    def _versions(self):\n        conn = sqlite3.connect(\":memory:\")\n        self._prepare_connection(conn, \"_memory\")\n        sqlite_version = conn.execute(\"select sqlite_version()\").fetchone()[0]\n        sqlite_extensions = {\"json1\": detect_json1(conn)}\n        for extension, testsql, hasversion in (\n            (\"spatialite\", \"SELECT spatialite_version()\", True),\n        ):\n            try:\n                result = conn.execute(testsql)\n                if hasversion:\n                    sqlite_extensions[extension] = result.fetchone()[0]\n                else:\n                    sqlite_extensions[extension] = None\n            except Exception:\n                pass\n        # More details on SpatiaLite\n        if \"spatialite\" in sqlite_extensions:\n            spatialite_details = {}\n            for fn in SPATIALITE_FUNCTIONS:\n                try:\n                    result = conn.execute(\"select {}()\".format(fn))\n                    spatialite_details[fn] = result.fetchone()[0]\n                except Exception as e:\n                    spatialite_details[fn] = {\"error\": str(e)}\n            sqlite_extensions[\"spatialite\"] = spatialite_details\n\n        # Figure out supported FTS versions\n        fts_versions = []\n        for fts in (\"FTS5\", \"FTS4\", \"FTS3\"):\n            try:\n                conn.execute(\n                    \"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)\".format(fts=fts)\n                )\n                fts_versions.append(fts)\n            except sqlite3.OperationalError:\n                continue\n        datasette_version = {\"version\": __version__}\n        if self.version_note:\n            datasette_version[\"note\"] = self.version_note\n\n        try:\n            # Optional import to avoid breaking Pyodide\n            # https://github.com/simonw/datasette/issues/1733#issuecomment-1115268245\n            import uvicorn\n\n            uvicorn_version = uvicorn.__version__\n        except ImportError:\n            uvicorn_version = None\n        info = {\n            \"python\": {\n                \"version\": \".\".join(map(str, sys.version_info[:3])),\n                \"full\": sys.version,\n            },\n            \"datasette\": datasette_version,\n            \"asgi\": \"3.0\",\n            \"uvicorn\": uvicorn_version,\n            \"sqlite\": {\n                \"version\": sqlite_version,\n                \"fts_versions\": fts_versions,\n                \"extensions\": sqlite_extensions,\n                \"compile_options\": [\n                    r[0] for r in conn.execute(\"pragma compile_options;\").fetchall()\n                ],\n            },\n        }\n        if using_pysqlite3:\n            for package in (\"pysqlite3\", \"pysqlite3-binary\"):\n                try:\n                    info[\"pysqlite3\"] = importlib.metadata.version(package)\n                    break\n                except importlib.metadata.PackageNotFoundError:\n                    pass\n        return info\n\n    def _plugins(self, request=None, all=False):\n        ps = list(get_plugins())\n        should_show_all = False\n        if request is not None:\n            should_show_all = request.args.get(\"all\")\n        else:\n            should_show_all = all\n        if not should_show_all:\n            ps = [p for p in ps if p[\"name\"] not in DEFAULT_PLUGINS]\n        ps.sort(key=lambda p: p[\"name\"])\n        return [\n            {\n                \"name\": p[\"name\"],\n                \"static\": p[\"static_path\"] is not None,\n                \"templates\": p[\"templates_path\"] is not None,\n                \"version\": p.get(\"version\"),\n                \"hooks\": list(sorted(set(p[\"hooks\"]))),\n            }\n            for p in ps\n        ]\n\n    def _threads(self):\n        if self.setting(\"num_sql_threads\") == 0:\n            return {\"num_threads\": 0, \"threads\": []}\n        threads = list(threading.enumerate())\n        d = {\n            \"num_threads\": len(threads),\n            \"threads\": [\n                {\"name\": t.name, \"ident\": t.ident, \"daemon\": t.daemon} for t in threads\n            ],\n        }\n        tasks = asyncio.all_tasks()\n        d.update(\n            {\n                \"num_tasks\": len(tasks),\n                \"tasks\": [_cleaner_task_str(t) for t in tasks],\n            }\n        )\n        return d\n\n    def _actor(self, request):\n        return {\"actor\": request.actor}\n\n    def _actions(self):\n        return [\n            {\n                \"name\": action.name,\n                \"abbr\": action.abbr,\n                \"description\": action.description,\n                \"takes_parent\": action.takes_parent,\n                \"takes_child\": action.takes_child,\n                \"resource_class\": (\n                    action.resource_class.__name__ if action.resource_class else None\n                ),\n                \"also_requires\": action.also_requires,\n            }\n            for action in sorted(self.actions.values(), key=lambda a: a.name)\n        ]\n\n    async def table_config(self, database: str, table: str) -> dict:\n        \"\"\"Return dictionary of configuration for specified table\"\"\"\n        return (\n            (self.config or {})\n            .get(\"databases\", {})\n            .get(database, {})\n            .get(\"tables\", {})\n            .get(table, {})\n        )\n\n    def _register_renderers(self):\n        \"\"\"Register output renderers which output data in custom formats.\"\"\"\n        # Built-in renderers\n        self.renderers[\"json\"] = (json_renderer, lambda: True)\n\n        # Hooks\n        hook_renderers = []\n        # pylint: disable=no-member\n        for hook in pm.hook.register_output_renderer(datasette=self):\n            if type(hook) is list:\n                hook_renderers += hook\n            else:\n                hook_renderers.append(hook)\n\n        for renderer in hook_renderers:\n            self.renderers[renderer[\"extension\"]] = (\n                # It used to be called \"callback\" - remove this in Datasette 1.0\n                renderer.get(\"render\") or renderer[\"callback\"],\n                renderer.get(\"can_render\") or (lambda: True),\n            )\n\n    async def render_template(\n        self,\n        templates: List[str] | str | Template,\n        context: Dict[str, Any] | Context | None = None,\n        request: Request | None = None,\n        view_name: str | None = None,\n    ):\n        if not self._startup_invoked:\n            raise Exception(\"render_template() called before await ds.invoke_startup()\")\n        context = context or {}\n        if isinstance(templates, Template):\n            template = templates\n        else:\n            if isinstance(templates, str):\n                templates = [templates]\n            template = self.get_jinja_environment(request).select_template(templates)\n        if dataclasses.is_dataclass(context):\n            context = dataclasses.asdict(context)\n        body_scripts = []\n        # pylint: disable=no-member\n        for extra_script in pm.hook.extra_body_script(\n            template=template.name,\n            database=context.get(\"database\"),\n            table=context.get(\"table\"),\n            columns=context.get(\"columns\"),\n            view_name=view_name,\n            request=request,\n            datasette=self,\n        ):\n            extra_script = await await_me_maybe(extra_script)\n            if isinstance(extra_script, dict):\n                script = extra_script[\"script\"]\n                module = bool(extra_script.get(\"module\"))\n            else:\n                script = extra_script\n                module = False\n            body_scripts.append({\"script\": Markup(script), \"module\": module})\n\n        extra_template_vars = {}\n        # pylint: disable=no-member\n        for extra_vars in pm.hook.extra_template_vars(\n            template=template.name,\n            database=context.get(\"database\"),\n            table=context.get(\"table\"),\n            columns=context.get(\"columns\"),\n            view_name=view_name,\n            request=request,\n            datasette=self,\n        ):\n            extra_vars = await await_me_maybe(extra_vars)\n            assert isinstance(extra_vars, dict), \"extra_vars is of type {}\".format(\n                type(extra_vars)\n            )\n            extra_template_vars.update(extra_vars)\n\n        async def menu_links():\n            links = []\n            for hook in pm.hook.menu_links(\n                datasette=self,\n                actor=request.actor if request else None,\n                request=request or None,\n            ):\n                extra_links = await await_me_maybe(hook)\n                if extra_links:\n                    links.extend(extra_links)\n            return links\n\n        template_context = {\n            **context,\n            **{\n                \"request\": request,\n                \"crumb_items\": self._crumb_items,\n                \"urls\": self.urls,\n                \"actor\": request.actor if request else None,\n                \"menu_links\": menu_links,\n                \"display_actor\": display_actor,\n                \"show_logout\": request is not None\n                and \"ds_actor\" in request.cookies\n                and request.actor,\n                \"app_css_hash\": self.app_css_hash(),\n                \"zip\": zip,\n                \"body_scripts\": body_scripts,\n                \"format_bytes\": format_bytes,\n                \"show_messages\": lambda: self._show_messages(request),\n                \"extra_css_urls\": await self._asset_urls(\n                    \"extra_css_urls\", template, context, request, view_name\n                ),\n                \"extra_js_urls\": await self._asset_urls(\n                    \"extra_js_urls\", template, context, request, view_name\n                ),\n                \"base_url\": self.setting(\"base_url\"),\n                \"csrftoken\": request.scope[\"csrftoken\"] if request else lambda: \"\",\n                \"datasette_version\": __version__,\n            },\n            **extra_template_vars,\n        }\n        if request and request.args.get(\"_context\") and self.setting(\"template_debug\"):\n            return \"<pre>{}</pre>\".format(\n                escape(json.dumps(template_context, default=repr, indent=4))\n            )\n\n        return await template.render_async(template_context)\n\n    def set_actor_cookie(\n        self, response: Response, actor: dict, expire_after: int | None = None\n    ):\n        data = {\"a\": actor}\n        if expire_after:\n            expires_at = int(time.time()) + (24 * 60 * 60)\n            data[\"e\"] = baseconv.base62.encode(expires_at)\n        response.set_cookie(\"ds_actor\", self.sign(data, \"actor\"))\n\n    def delete_actor_cookie(self, response: Response):\n        response.set_cookie(\"ds_actor\", \"\", expires=0, max_age=0)\n\n    async def _asset_urls(self, key, template, context, request, view_name):\n        # Flatten list-of-lists from plugins:\n        seen_urls = set()\n        collected = []\n        for hook in getattr(pm.hook, key)(\n            template=template.name,\n            database=context.get(\"database\"),\n            table=context.get(\"table\"),\n            columns=context.get(\"columns\"),\n            view_name=view_name,\n            request=request,\n            datasette=self,\n        ):\n            hook = await await_me_maybe(hook)\n            collected.extend(hook)\n        collected.extend((self.config or {}).get(key) or [])\n        output = []\n        for url_or_dict in collected:\n            if isinstance(url_or_dict, dict):\n                url = url_or_dict[\"url\"]\n                sri = url_or_dict.get(\"sri\")\n                module = bool(url_or_dict.get(\"module\"))\n            else:\n                url = url_or_dict\n                sri = None\n                module = False\n            if url in seen_urls:\n                continue\n            seen_urls.add(url)\n            if url.startswith(\"/\"):\n                # Take base_url into account:\n                url = self.urls.path(url)\n            script = {\"url\": url}\n            if sri:\n                script[\"sri\"] = sri\n            if module:\n                script[\"module\"] = True\n            output.append(script)\n        return output\n\n    def _config(self):\n        return redact_keys(\n            self.config, (\"secret\", \"key\", \"password\", \"token\", \"hash\", \"dsn\")\n        )\n\n    def _routes(self):\n        routes = []\n\n        for routes_to_add in pm.hook.register_routes(datasette=self):\n            for regex, view_fn in routes_to_add:\n                routes.append((regex, wrap_view(view_fn, self)))\n\n        def add_route(view, regex):\n            routes.append((regex, view))\n\n        add_route(IndexView.as_view(self), r\"/(\\.(?P<format>jsono?))?$\")\n        add_route(IndexView.as_view(self), r\"/-/(\\.(?P<format>jsono?))?$\")\n        add_route(permanent_redirect(\"/-/\"), r\"/-$\")\n        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires\n        add_route(favicon, \"/favicon.ico\")\n\n        add_route(\n            asgi_static(app_root / \"datasette\" / \"static\"), r\"/-/static/(?P<path>.*)$\"\n        )\n        for path, dirname in self.static_mounts:\n            add_route(asgi_static(dirname), r\"/\" + path + \"/(?P<path>.*)$\")\n\n        # Mount any plugin static/ directories\n        for plugin in get_plugins():\n            if plugin[\"static_path\"]:\n                add_route(\n                    asgi_static(plugin[\"static_path\"]),\n                    f\"/-/static-plugins/{plugin['name']}/(?P<path>.*)$\",\n                )\n                # Support underscores in name in addition to hyphens, see https://github.com/simonw/datasette/issues/611\n                add_route(\n                    asgi_static(plugin[\"static_path\"]),\n                    \"/-/static-plugins/{}/(?P<path>.*)$\".format(\n                        plugin[\"name\"].replace(\"-\", \"_\")\n                    ),\n                )\n        add_route(\n            permanent_redirect(\n                \"/_memory\", forward_query_string=True, forward_rest=True\n            ),\n            r\"/:memory:(?P<rest>.*)$\",\n        )\n        add_route(\n            JsonDataView.as_view(self, \"versions.json\", self._versions),\n            r\"/-/versions(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(\n                self, \"plugins.json\", self._plugins, needs_request=True\n            ),\n            r\"/-/plugins(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(self, \"settings.json\", lambda: self._settings),\n            r\"/-/settings(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(self, \"config.json\", lambda: self._config()),\n            r\"/-/config(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(self, \"threads.json\", self._threads),\n            r\"/-/threads(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(self, \"databases.json\", self._connected_databases),\n            r\"/-/databases(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(\n                self, \"actor.json\", self._actor, needs_request=True, permission=None\n            ),\n            r\"/-/actor(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            JsonDataView.as_view(\n                self,\n                \"actions.json\",\n                self._actions,\n                template=\"debug_actions.html\",\n                permission=\"permissions-debug\",\n            ),\n            r\"/-/actions(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            AuthTokenView.as_view(self),\n            r\"/-/auth-token$\",\n        )\n        add_route(\n            CreateTokenView.as_view(self),\n            r\"/-/create-token$\",\n        )\n        add_route(\n            ApiExplorerView.as_view(self),\n            r\"/-/api$\",\n        )\n        add_route(\n            TablesView.as_view(self),\n            r\"/-/tables(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            InstanceSchemaView.as_view(self),\n            r\"/-/schema(\\.(?P<format>json|md))?$\",\n        )\n        add_route(\n            LogoutView.as_view(self),\n            r\"/-/logout$\",\n        )\n        add_route(\n            PermissionsDebugView.as_view(self),\n            r\"/-/permissions$\",\n        )\n        add_route(\n            AllowedResourcesView.as_view(self),\n            r\"/-/allowed(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            PermissionRulesView.as_view(self),\n            r\"/-/rules(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            PermissionCheckView.as_view(self),\n            r\"/-/check(\\.(?P<format>json))?$\",\n        )\n        add_route(\n            MessagesDebugView.as_view(self),\n            r\"/-/messages$\",\n        )\n        add_route(\n            AllowDebugView.as_view(self),\n            r\"/-/allow-debug$\",\n        )\n        add_route(\n            wrap_view(PatternPortfolioView, self),\n            r\"/-/patterns$\",\n        )\n        add_route(\n            wrap_view(database_download, self),\n            r\"/(?P<database>[^\\/\\.]+)\\.db$\",\n        )\n        add_route(\n            wrap_view(DatabaseView, self),\n            r\"/(?P<database>[^\\/\\.]+)(\\.(?P<format>\\w+))?$\",\n        )\n        add_route(TableCreateView.as_view(self), r\"/(?P<database>[^\\/\\.]+)/-/create$\")\n        add_route(\n            DatabaseSchemaView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/-/schema(\\.(?P<format>json|md))?$\",\n        )\n        add_route(\n            wrap_view(QueryView, self),\n            r\"/(?P<database>[^\\/\\.]+)/-/query(\\.(?P<format>\\w+))?$\",\n        )\n        add_route(\n            wrap_view(table_view, self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)(\\.(?P<format>\\w+))?$\",\n        )\n        add_route(\n            RowView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)(\\.(?P<format>\\w+))?$\",\n        )\n        add_route(\n            TableInsertView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)/-/insert$\",\n        )\n        add_route(\n            TableUpsertView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)/-/upsert$\",\n        )\n        add_route(\n            TableSetColumnTypeView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)/-/set-column-type$\",\n        )\n        add_route(\n            TableDropView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)/-/drop$\",\n        )\n        add_route(\n            TableSchemaView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)/-/schema(\\.(?P<format>json|md))?$\",\n        )\n        add_route(\n            RowDeleteView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/delete$\",\n        )\n        add_route(\n            RowUpdateView.as_view(self),\n            r\"/(?P<database>[^\\/\\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/update$\",\n        )\n        return [\n            # Compile any strings to regular expressions\n            ((re.compile(pattern) if isinstance(pattern, str) else pattern), view)\n            for pattern, view in routes\n        ]\n\n    async def resolve_database(self, request):\n        database_route = tilde_decode(request.url_vars[\"database\"])\n        try:\n            return self.get_database(route=database_route)\n        except KeyError:\n            raise DatabaseNotFound(database_route)\n\n    async def resolve_table(self, request):\n        db = await self.resolve_database(request)\n        table_name = tilde_decode(request.url_vars[\"table\"])\n        # Table must exist\n        is_view = False\n        table_exists = await db.table_exists(table_name)\n        if not table_exists:\n            is_view = await db.view_exists(table_name)\n        if not (table_exists or is_view):\n            raise TableNotFound(db.name, table_name)\n        return ResolvedTable(db, table_name, is_view)\n\n    async def resolve_row(self, request):\n        db, table_name, _ = await self.resolve_table(request)\n        pk_values = urlsafe_components(request.url_vars[\"pks\"])\n        sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)\n        results = await db.execute(sql, params, truncate=True)\n        row = results.first()\n        if row is None:\n            raise RowNotFound(db.name, table_name, pk_values)\n        return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())\n\n    def app(self):\n        \"\"\"Returns an ASGI app function that serves the whole of Datasette\"\"\"\n        routes = self._routes()\n\n        async def setup_db():\n            # First time server starts up, calculate table counts for immutable databases\n            for database in self.databases.values():\n                if not database.is_mutable:\n                    await database.table_counts(limit=60 * 60 * 1000)\n\n        async def custom_csrf_error(scope, send, message_id):\n            await asgi_send(\n                send,\n                content=await self.render_template(\n                    \"csrf_error.html\",\n                    {\"message_id\": message_id, \"message_name\": Errors(message_id).name},\n                ),\n                status=403,\n                content_type=\"text/html; charset=utf-8\",\n            )\n\n        asgi = asgi_csrf.asgi_csrf(\n            DatasetteRouter(self, routes),\n            signing_secret=self._secret,\n            cookie_name=\"ds_csrftoken\",\n            skip_if_scope=lambda scope: any(\n                pm.hook.skip_csrf(datasette=self, scope=scope)\n            ),\n            send_csrf_failed=custom_csrf_error,\n        )\n        if self.setting(\"trace_debug\"):\n            asgi = AsgiTracer(asgi)\n        asgi = AsgiLifespan(asgi)\n        asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])\n        for wrapper in pm.hook.asgi_wrapper(datasette=self):\n            asgi = wrapper(asgi)\n        return asgi\n\n\nclass DatasetteRouter:\n    def __init__(self, datasette, routes):\n        self.ds = datasette\n        self.routes = routes or []\n\n    async def __call__(self, scope, receive, send):\n        # Because we care about \"foo/bar\" v.s. \"foo%2Fbar\" we decode raw_path ourselves\n        path = scope[\"path\"]\n        raw_path = scope.get(\"raw_path\")\n        if raw_path:\n            path = raw_path.decode(\"ascii\")\n        path = path.partition(\"?\")[0]\n        return await self.route_path(scope, receive, send, path)\n\n    async def route_path(self, scope, receive, send, path):\n        # Strip off base_url if present before routing\n        base_url = self.ds.setting(\"base_url\")\n        if base_url != \"/\" and path.startswith(base_url):\n            path = \"/\" + path[len(base_url) :]\n            scope = dict(scope, route_path=path)\n        request = Request(scope, receive)\n        # Populate request_messages if ds_messages cookie is present\n        try:\n            request._messages = self.ds.unsign(\n                request.cookies.get(\"ds_messages\", \"\"), \"messages\"\n            )\n        except BadSignature:\n            pass\n\n        scope_modifications = {}\n        # Apply force_https_urls, if set\n        if (\n            self.ds.setting(\"force_https_urls\")\n            and scope[\"type\"] == \"http\"\n            and scope.get(\"scheme\") != \"https\"\n        ):\n            scope_modifications[\"scheme\"] = \"https\"\n        # Handle authentication\n        default_actor = scope.get(\"actor\") or None\n        actor = None\n        results = pm.hook.actor_from_request(datasette=self.ds, request=request)\n        for result in results:\n            result = await await_me_maybe(result)\n            if result and actor is None:\n                actor = result\n                # Don't break — we must await all coroutines to avoid\n                # \"coroutine was never awaited\" warnings\n        scope_modifications[\"actor\"] = actor or default_actor\n        scope = dict(scope, **scope_modifications)\n\n        match, view = resolve_routes(self.routes, path)\n\n        if match is None:\n            return await self.handle_404(request, send)\n\n        new_scope = dict(scope, url_route={\"kwargs\": match.groupdict()})\n        request.scope = new_scope\n        try:\n            response = await view(request, send)\n            if response:\n                self.ds._write_messages_to_response(request, response)\n                await response.asgi_send(send)\n            return\n        except NotFound as exception:\n            return await self.handle_404(request, send, exception)\n        except Forbidden as exception:\n            # Try the forbidden() plugin hook\n            for custom_response in pm.hook.forbidden(\n                datasette=self.ds, request=request, message=exception.args[0]\n            ):\n                custom_response = await await_me_maybe(custom_response)\n                assert (\n                    custom_response\n                ), \"Default forbidden() hook should have been called\"\n                return await custom_response.asgi_send(send)\n        except Exception as exception:\n            return await self.handle_exception(request, send, exception)\n\n    async def handle_404(self, request, send, exception=None):\n        # If path contains % encoding, redirect to tilde encoding\n        if \"%\" in request.path:\n            # Try the same path but with \"%\" replaced by \"~\"\n            # and \"~\" replaced with \"~7E\"\n            # and \".\" replaced with \"~2E\"\n            new_path = (\n                request.path.replace(\"~\", \"~7E\").replace(\"%\", \"~\").replace(\".\", \"~2E\")\n            )\n            if request.query_string:\n                new_path += \"?{}\".format(request.query_string)\n            await asgi_send_redirect(send, new_path)\n            return\n        # If URL has a trailing slash, redirect to URL without it\n        path = request.scope.get(\n            \"raw_path\", request.scope[\"path\"].encode(\"utf8\")\n        ).partition(b\"?\")[0]\n        context = {}\n        if path.endswith(b\"/\"):\n            path = path.rstrip(b\"/\")\n            if request.scope[\"query_string\"]:\n                path += b\"?\" + request.scope[\"query_string\"]\n            await asgi_send_redirect(send, path.decode(\"latin1\"))\n        else:\n            # Is there a pages/* template matching this path?\n            route_path = request.scope.get(\"route_path\", request.scope[\"path\"])\n            # Jinja requires template names to use \"/\" even on Windows\n            template_name = \"pages\" + route_path + \".html\"\n            # Build a list of pages/blah/{name}.html matching expressions\n            environment = self.ds.get_jinja_environment(request)\n            pattern_templates = [\n                filepath\n                for filepath in environment.list_templates()\n                if \"{\" in filepath and filepath.startswith(\"pages/\")\n            ]\n            page_routes = [\n                (route_pattern_from_filepath(filepath[len(\"pages/\") :]), filepath)\n                for filepath in pattern_templates\n            ]\n            try:\n                template = environment.select_template([template_name])\n            except TemplateNotFound:\n                template = None\n            if template is None:\n                # Try for a pages/blah/{name}.html template match\n                for regex, wildcard_template in page_routes:\n                    match = regex.match(route_path)\n                    if match is not None:\n                        context.update(match.groupdict())\n                        template = wildcard_template\n                        break\n\n            if template:\n                headers = {}\n                status = [200]\n\n                def custom_header(name, value):\n                    headers[name] = value\n                    return \"\"\n\n                def custom_status(code):\n                    status[0] = code\n                    return \"\"\n\n                def custom_redirect(location, code=302):\n                    status[0] = code\n                    headers[\"Location\"] = location\n                    return \"\"\n\n                def raise_404(message=\"\"):\n                    raise NotFoundExplicit(message)\n\n                context.update(\n                    {\n                        \"custom_header\": custom_header,\n                        \"custom_status\": custom_status,\n                        \"custom_redirect\": custom_redirect,\n                        \"raise_404\": raise_404,\n                    }\n                )\n                try:\n                    body = await self.ds.render_template(\n                        template,\n                        context,\n                        request=request,\n                        view_name=\"page\",\n                    )\n                except NotFoundExplicit as e:\n                    await self.handle_exception(request, send, e)\n                    return\n                # Pull content-type out into separate parameter\n                content_type = \"text/html; charset=utf-8\"\n                matches = [k for k in headers if k.lower() == \"content-type\"]\n                if matches:\n                    content_type = headers[matches[0]]\n                await asgi_send(\n                    send,\n                    body,\n                    status=status[0],\n                    headers=headers,\n                    content_type=content_type,\n                )\n            else:\n                await self.handle_exception(request, send, exception or NotFound(\"404\"))\n\n    async def handle_exception(self, request, send, exception):\n        responses = []\n        for hook in pm.hook.handle_exception(\n            datasette=self.ds,\n            request=request,\n            exception=exception,\n        ):\n            response = await await_me_maybe(hook)\n            if response is not None:\n                responses.append(response)\n\n        assert responses, \"Default exception handler should have returned something\"\n        # Even if there are multiple responses use just the first one\n        response = responses[0]\n        await response.asgi_send(send)\n\n\n_cleaner_task_str_re = re.compile(r\"\\S*site-packages/\")\n\n\ndef _cleaner_task_str(task):\n    s = str(task)\n    # This has something like the following in it:\n    # running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361>\n    # Clean up everything up to and including site-packages\n    return _cleaner_task_str_re.sub(\"\", s)\n\n\ndef wrap_view(view_fn_or_class, datasette):\n    is_function = isinstance(view_fn_or_class, types.FunctionType)\n    if is_function:\n        return wrap_view_function(view_fn_or_class, datasette)\n    else:\n        if not isinstance(view_fn_or_class, type):\n            raise ValueError(\"view_fn_or_class must be a function or a class\")\n        return wrap_view_class(view_fn_or_class, datasette)\n\n\ndef wrap_view_class(view_class, datasette):\n    async def async_view_for_class(request, send):\n        instance = view_class()\n        if inspect.iscoroutinefunction(instance.__call__):\n            return await async_call_with_supported_arguments(\n                instance.__call__,\n                scope=request.scope,\n                receive=request.receive,\n                send=send,\n                request=request,\n                datasette=datasette,\n            )\n        else:\n            return call_with_supported_arguments(\n                instance.__call__,\n                scope=request.scope,\n                receive=request.receive,\n                send=send,\n                request=request,\n                datasette=datasette,\n            )\n\n    async_view_for_class.view_class = view_class\n    return async_view_for_class\n\n\ndef wrap_view_function(view_fn, datasette):\n    @functools.wraps(view_fn)\n    async def async_view_fn(request, send):\n        if inspect.iscoroutinefunction(view_fn):\n            response = await async_call_with_supported_arguments(\n                view_fn,\n                scope=request.scope,\n                receive=request.receive,\n                send=send,\n                request=request,\n                datasette=datasette,\n            )\n        else:\n            response = call_with_supported_arguments(\n                view_fn,\n                scope=request.scope,\n                receive=request.receive,\n                send=send,\n                request=request,\n                datasette=datasette,\n            )\n        if response is not None:\n            return response\n\n    return async_view_fn\n\n\ndef permanent_redirect(path, forward_query_string=False, forward_rest=False):\n    return wrap_view(\n        lambda request, send: Response.redirect(\n            path\n            + (request.url_vars[\"rest\"] if forward_rest else \"\")\n            + (\n                (\"?\" + request.query_string)\n                if forward_query_string and request.query_string\n                else \"\"\n            ),\n            status=301,\n        ),\n        datasette=None,\n    )\n\n\n_curly_re = re.compile(r\"({.*?})\")\n\n\ndef route_pattern_from_filepath(filepath):\n    # Drop the \".html\" suffix\n    if filepath.endswith(\".html\"):\n        filepath = filepath[: -len(\".html\")]\n    re_bits = [\"/\"]\n    for bit in _curly_re.split(filepath):\n        if _curly_re.match(bit):\n            re_bits.append(f\"(?P<{bit[1:-1]}>[^/]*)\")\n        else:\n            re_bits.append(re.escape(bit))\n    return re.compile(\"^\" + \"\".join(re_bits) + \"$\")\n\n\nclass NotFoundExplicit(NotFound):\n    pass\n\n\nclass DatasetteClient:\n    \"\"\"Internal HTTP client for making requests to a Datasette instance.\n\n    Used for testing and for internal operations that need to make HTTP requests\n    to the Datasette app without going through an actual HTTP server.\n    \"\"\"\n\n    def __init__(self, ds):\n        self.ds = ds\n\n    @property\n    def app(self):\n        return self.ds.app()\n\n    def actor_cookie(self, actor):\n        # Utility method, mainly for tests\n        return self.ds.sign({\"a\": actor}, \"actor\")\n\n    def _fix(self, path, avoid_path_rewrites=False):\n        if not isinstance(path, PrefixedUrlString) and not avoid_path_rewrites:\n            path = self.ds.urls.path(path)\n        if path.startswith(\"/\"):\n            path = f\"http://localhost{path}\"\n        return path\n\n    async def _request(self, method, path, skip_permission_checks=False, **kwargs):\n        from datasette.permissions import SkipPermissions\n\n        with _DatasetteClientContext():\n            if skip_permission_checks:\n                with SkipPermissions():\n                    async with httpx.AsyncClient(\n                        transport=httpx.ASGITransport(app=self.app),\n                        cookies=kwargs.pop(\"cookies\", None),\n                    ) as client:\n                        return await getattr(client, method)(self._fix(path), **kwargs)\n            else:\n                async with httpx.AsyncClient(\n                    transport=httpx.ASGITransport(app=self.app),\n                    cookies=kwargs.pop(\"cookies\", None),\n                ) as client:\n                    return await getattr(client, method)(self._fix(path), **kwargs)\n\n    async def get(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"get\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def options(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"options\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def head(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"head\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def post(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"post\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def put(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"put\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def patch(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"patch\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def delete(self, path, skip_permission_checks=False, **kwargs):\n        return await self._request(\n            \"delete\", path, skip_permission_checks=skip_permission_checks, **kwargs\n        )\n\n    async def request(self, method, path, skip_permission_checks=False, **kwargs):\n        \"\"\"Make an HTTP request with the specified method.\n\n        Args:\n            method: HTTP method (e.g., \"GET\", \"POST\", \"PUT\")\n            path: The path to request\n            skip_permission_checks: If True, bypass all permission checks for this request\n            **kwargs: Additional arguments to pass to httpx\n\n        Returns:\n            httpx.Response: The response from the request\n        \"\"\"\n        from datasette.permissions import SkipPermissions\n\n        avoid_path_rewrites = kwargs.pop(\"avoid_path_rewrites\", None)\n        with _DatasetteClientContext():\n            if skip_permission_checks:\n                with SkipPermissions():\n                    async with httpx.AsyncClient(\n                        transport=httpx.ASGITransport(app=self.app),\n                        cookies=kwargs.pop(\"cookies\", None),\n                    ) as client:\n                        return await client.request(\n                            method, self._fix(path, avoid_path_rewrites), **kwargs\n                        )\n            else:\n                async with httpx.AsyncClient(\n                    transport=httpx.ASGITransport(app=self.app),\n                    cookies=kwargs.pop(\"cookies\", None),\n                ) as client:\n                    return await client.request(\n                        method, self._fix(path, avoid_path_rewrites), **kwargs\n                    )\n"
  },
  {
    "path": "datasette/blob_renderer.py",
    "content": "from datasette import hookimpl\nfrom datasette.utils.asgi import Response, BadRequest\nfrom datasette.utils import to_css_class\nimport hashlib\n\n_BLOB_COLUMN = \"_blob_column\"\n_BLOB_HASH = \"_blob_hash\"\n\n\nasync def render_blob(datasette, database, rows, columns, request, table, view_name):\n    if _BLOB_COLUMN not in request.args:\n        raise BadRequest(f\"?{_BLOB_COLUMN}= is required\")\n    blob_column = request.args[_BLOB_COLUMN]\n    if blob_column not in columns:\n        raise BadRequest(f\"{blob_column} is not a valid column\")\n\n    # If ?_blob_hash= provided, use that to select the row - otherwise use first row\n    blob_hash = None\n    if _BLOB_HASH in request.args:\n        blob_hash = request.args[_BLOB_HASH]\n        for row in rows:\n            value = row[blob_column]\n            if hashlib.sha256(value).hexdigest() == blob_hash:\n                break\n        else:\n            # Loop did not break\n            raise BadRequest(\n                \"Link has expired - the requested binary content has changed or could not be found.\"\n            )\n    else:\n        row = rows[0]\n\n    value = row[blob_column]\n    filename_bits = []\n    if table:\n        filename_bits.append(to_css_class(table))\n    if \"pks\" in request.url_vars:\n        filename_bits.append(request.url_vars[\"pks\"])\n    filename_bits.append(to_css_class(blob_column))\n    if blob_hash:\n        filename_bits.append(blob_hash[:6])\n    filename = \"-\".join(filename_bits) + \".blob\"\n    headers = {\n        \"X-Content-Type-Options\": \"nosniff\",\n        \"Content-Disposition\": f'attachment; filename=\"{filename}\"',\n    }\n    return Response(\n        body=value or b\"\",\n        status=200,\n        headers=headers,\n        content_type=\"application/binary\",\n    )\n\n\n@hookimpl\ndef register_output_renderer():\n    return {\n        \"extension\": \"blob\",\n        \"render\": render_blob,\n        \"can_render\": lambda: False,\n    }\n"
  },
  {
    "path": "datasette/cli.py",
    "content": "import asyncio\nimport uvicorn\nimport click\nfrom click import formatting\nfrom click.types import CompositeParamType\nfrom click_default_group import DefaultGroup\nimport functools\nimport json\nimport os\nimport pathlib\nfrom runpy import run_module\nimport shutil\nfrom subprocess import call\nimport sys\nimport textwrap\nimport webbrowser\nfrom .app import (\n    Datasette,\n    DEFAULT_SETTINGS,\n    SETTINGS,\n    SQLITE_LIMIT_ATTACHED,\n    pm,\n)\nfrom .utils import (\n    LoadExtension,\n    StartupError,\n    check_connection,\n    deep_dict_update,\n    find_spatialite,\n    parse_metadata,\n    ConnectionProblem,\n    SpatialiteConnectionProblem,\n    initial_path_for_datasette,\n    pairs_to_nested_config,\n    temporary_docker_directory,\n    value_as_boolean,\n    SpatialiteNotFound,\n    StaticMount,\n    ValueAsBooleanError,\n)\nfrom .utils.sqlite import sqlite3\nfrom .utils.testing import TestClient\nfrom .version import __version__\n\n\ndef run_sync(coro_func):\n    \"\"\"Run an async callable to completion on a fresh event loop.\"\"\"\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        return loop.run_until_complete(coro_func())\n    finally:\n        asyncio.set_event_loop(None)\n        loop.close()\n\n\n# Use Rich for tracebacks if it is installed\ntry:\n    from rich.traceback import install\n\n    install(show_locals=True)\nexcept ImportError:\n    pass\n\n\nclass Setting(CompositeParamType):\n    name = \"setting\"\n    arity = 2\n\n    def convert(self, config, param, ctx):\n        name, value = config\n        if name in DEFAULT_SETTINGS:\n            # For backwards compatibility with how this worked prior to\n            # Datasette 1.0, we turn bare setting names into setting.name\n            # Type checking for those older settings\n            default = DEFAULT_SETTINGS[name]\n            name = \"settings.{}\".format(name)\n            if isinstance(default, bool):\n                try:\n                    return name, \"true\" if value_as_boolean(value) else \"false\"\n                except ValueAsBooleanError:\n                    self.fail(f'\"{name}\" should be on/off/true/false/1/0', param, ctx)\n            elif isinstance(default, int):\n                if not value.isdigit():\n                    self.fail(f'\"{name}\" should be an integer', param, ctx)\n                return name, value\n            elif isinstance(default, str):\n                return name, value\n            else:\n                # Should never happen:\n                self.fail(\"Invalid option\")\n        return name, value\n\n\ndef sqlite_extensions(fn):\n    fn = click.option(\n        \"sqlite_extensions\",\n        \"--load-extension\",\n        type=LoadExtension(),\n        envvar=\"DATASETTE_LOAD_EXTENSION\",\n        multiple=True,\n        help=\"Path to a SQLite extension to load, and optional entrypoint\",\n    )(fn)\n\n    # Wrap it in a custom error handler\n    @functools.wraps(fn)\n    def wrapped(*args, **kwargs):\n        try:\n            return fn(*args, **kwargs)\n        except AttributeError as e:\n            if \"enable_load_extension\" in str(e):\n                raise click.ClickException(textwrap.dedent(\"\"\"\n                    Your Python installation does not have the ability to load SQLite extensions.\n\n                    More information: https://datasette.io/help/extensions\n                    \"\"\").strip())\n            raise\n\n    return wrapped\n\n\n@click.group(cls=DefaultGroup, default=\"serve\", default_if_no_args=True)\n@click.version_option(version=__version__)\ndef cli():\n    \"\"\"\n    Datasette is an open source multi-tool for exploring and publishing data\n\n    \\b\n    About Datasette: https://datasette.io/\n    Full documentation: https://docs.datasette.io/\n    \"\"\"\n\n\n@cli.command()\n@click.argument(\"files\", type=click.Path(exists=True), nargs=-1)\n@click.option(\"--inspect-file\", default=\"-\")\n@sqlite_extensions\ndef inspect(files, inspect_file, sqlite_extensions):\n    \"\"\"\n    Generate JSON summary of provided database files\n\n    This can then be passed to \"datasette --inspect-file\" to speed up count\n    operations against immutable database files.\n    \"\"\"\n    inspect_data = run_sync(lambda: inspect_(files, sqlite_extensions))\n    if inspect_file == \"-\":\n        sys.stdout.write(json.dumps(inspect_data, indent=2))\n    else:\n        with open(inspect_file, \"w\") as fp:\n            fp.write(json.dumps(inspect_data, indent=2))\n\n\nasync def inspect_(files, sqlite_extensions):\n    app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)\n    data = {}\n    for name, database in app.databases.items():\n        counts = await database.table_counts(limit=3600 * 1000)\n        data[name] = {\n            \"hash\": database.hash,\n            \"size\": database.size,\n            \"file\": database.path,\n            \"tables\": {\n                table_name: {\"count\": table_count}\n                for table_name, table_count in counts.items()\n            },\n        }\n    return data\n\n\n@cli.group()\ndef publish():\n    \"\"\"Publish specified SQLite database files to the internet along with a Datasette-powered interface and API\"\"\"\n    pass\n\n\n# Register publish plugins\npm.hook.publish_subcommand(publish=publish)\n\n\n@cli.command()\n@click.option(\"--all\", help=\"Include built-in default plugins\", is_flag=True)\n@click.option(\n    \"--requirements\", help=\"Output requirements.txt of installed plugins\", is_flag=True\n)\n@click.option(\n    \"--plugins-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom plugins\",\n)\ndef plugins(all, requirements, plugins_dir):\n    \"\"\"List currently installed plugins\"\"\"\n    app = Datasette([], plugins_dir=plugins_dir)\n    if requirements:\n        for plugin in app._plugins():\n            if plugin[\"version\"]:\n                click.echo(\"{}=={}\".format(plugin[\"name\"], plugin[\"version\"]))\n    else:\n        click.echo(json.dumps(app._plugins(all=all), indent=4))\n\n\n@cli.command()\n@click.argument(\"files\", type=click.Path(exists=True), nargs=-1, required=True)\n@click.option(\n    \"-t\",\n    \"--tag\",\n    help=\"Name for the resulting Docker container, can optionally use name:tag format\",\n)\n@click.option(\n    \"-m\",\n    \"--metadata\",\n    type=click.File(mode=\"r\"),\n    help=\"Path to JSON/YAML file containing metadata to publish\",\n)\n@click.option(\"--extra-options\", help=\"Extra options to pass to datasette serve\")\n@click.option(\"--branch\", help=\"Install datasette from a GitHub branch e.g. main\")\n@click.option(\n    \"--template-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom templates\",\n)\n@click.option(\n    \"--plugins-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom plugins\",\n)\n@click.option(\n    \"--static\",\n    type=StaticMount(),\n    help=\"Serve static files from this directory at /MOUNT/...\",\n    multiple=True,\n)\n@click.option(\n    \"--install\", help=\"Additional packages (e.g. plugins) to install\", multiple=True\n)\n@click.option(\"--spatialite\", is_flag=True, help=\"Enable SpatialLite extension\")\n@click.option(\"--version-note\", help=\"Additional note to show on /-/versions\")\n@click.option(\n    \"--secret\",\n    help=\"Secret used for signing secure values, such as signed cookies\",\n    envvar=\"DATASETTE_PUBLISH_SECRET\",\n    default=lambda: os.urandom(32).hex(),\n)\n@click.option(\n    \"-p\",\n    \"--port\",\n    default=8001,\n    type=click.IntRange(1, 65535),\n    help=\"Port to run the server on, defaults to 8001\",\n)\n@click.option(\"--title\", help=\"Title for metadata\")\n@click.option(\"--license\", help=\"License label for metadata\")\n@click.option(\"--license_url\", help=\"License URL for metadata\")\n@click.option(\"--source\", help=\"Source label for metadata\")\n@click.option(\"--source_url\", help=\"Source URL for metadata\")\n@click.option(\"--about\", help=\"About label for metadata\")\n@click.option(\"--about_url\", help=\"About URL for metadata\")\ndef package(\n    files,\n    tag,\n    metadata,\n    extra_options,\n    branch,\n    template_dir,\n    plugins_dir,\n    static,\n    install,\n    spatialite,\n    version_note,\n    secret,\n    port,\n    **extra_metadata,\n):\n    \"\"\"Package SQLite files into a Datasette Docker container\"\"\"\n    if not shutil.which(\"docker\"):\n        click.secho(\n            ' The package command requires \"docker\" to be installed and configured ',\n            bg=\"red\",\n            fg=\"white\",\n            bold=True,\n            err=True,\n        )\n        sys.exit(1)\n    with temporary_docker_directory(\n        files,\n        \"datasette\",\n        metadata=metadata,\n        extra_options=extra_options,\n        branch=branch,\n        template_dir=template_dir,\n        plugins_dir=plugins_dir,\n        static=static,\n        install=install,\n        spatialite=spatialite,\n        version_note=version_note,\n        secret=secret,\n        extra_metadata=extra_metadata,\n        port=port,\n    ):\n        args = [\"docker\", \"build\"]\n        if tag:\n            args.append(\"-t\")\n            args.append(tag)\n        args.append(\".\")\n        call(args)\n\n\n@cli.command()\n@click.argument(\"packages\", nargs=-1)\n@click.option(\n    \"-U\", \"--upgrade\", is_flag=True, help=\"Upgrade packages to latest version\"\n)\n@click.option(\n    \"-r\",\n    \"--requirement\",\n    type=click.Path(exists=True),\n    help=\"Install from requirements file\",\n)\n@click.option(\n    \"-e\",\n    \"--editable\",\n    help=\"Install a project in editable mode from this path\",\n)\ndef install(packages, upgrade, requirement, editable):\n    \"\"\"Install plugins and packages from PyPI into the same environment as Datasette\"\"\"\n    if not packages and not requirement and not editable:\n        raise click.UsageError(\"Please specify at least one package to install\")\n    args = [\"pip\", \"install\"]\n    if upgrade:\n        args += [\"--upgrade\"]\n    if editable:\n        args += [\"--editable\", editable]\n    if requirement:\n        args += [\"-r\", requirement]\n    args += list(packages)\n    sys.argv = args\n    run_module(\"pip\", run_name=\"__main__\")\n\n\n@cli.command()\n@click.argument(\"packages\", nargs=-1, required=True)\n@click.option(\"-y\", \"--yes\", is_flag=True, help=\"Don't ask for confirmation\")\ndef uninstall(packages, yes):\n    \"\"\"Uninstall plugins and Python packages from the Datasette environment\"\"\"\n    sys.argv = [\"pip\", \"uninstall\"] + list(packages) + ([\"-y\"] if yes else [])\n    run_module(\"pip\", run_name=\"__main__\")\n\n\n@cli.command()\n@click.argument(\"files\", type=click.Path(), nargs=-1)\n@click.option(\n    \"-i\",\n    \"--immutable\",\n    type=click.Path(exists=True),\n    help=\"Database files to open in immutable mode\",\n    multiple=True,\n)\n@click.option(\n    \"-h\",\n    \"--host\",\n    default=\"127.0.0.1\",\n    help=(\n        \"Host for server. Defaults to 127.0.0.1 which means only connections \"\n        \"from the local machine will be allowed. Use 0.0.0.0 to listen to \"\n        \"all IPs and allow access from other machines.\"\n    ),\n)\n@click.option(\n    \"-p\",\n    \"--port\",\n    default=8001,\n    type=click.IntRange(0, 65535),\n    help=\"Port for server, defaults to 8001. Use -p 0 to automatically assign an available port.\",\n)\n@click.option(\n    \"--uds\",\n    help=\"Bind to a Unix domain socket\",\n)\n@click.option(\n    \"--reload\",\n    is_flag=True,\n    help=\"Automatically reload if code or metadata change detected - useful for development\",\n)\n@click.option(\n    \"--cors\", is_flag=True, help=\"Enable CORS by serving Access-Control-Allow-Origin: *\"\n)\n@sqlite_extensions\n@click.option(\n    \"--inspect-file\", help='Path to JSON file created using \"datasette inspect\"'\n)\n@click.option(\n    \"-m\",\n    \"--metadata\",\n    type=click.File(mode=\"r\"),\n    help=\"Path to JSON/YAML file containing license/source metadata\",\n)\n@click.option(\n    \"--template-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom templates\",\n)\n@click.option(\n    \"--plugins-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom plugins\",\n)\n@click.option(\n    \"--static\",\n    type=StaticMount(),\n    help=\"Serve static files from this directory at /MOUNT/...\",\n    multiple=True,\n)\n@click.option(\"--memory\", is_flag=True, help=\"Make /_memory database available\")\n@click.option(\n    \"-c\",\n    \"--config\",\n    type=click.File(mode=\"r\"),\n    help=\"Path to JSON/YAML Datasette configuration file\",\n)\n@click.option(\n    \"-s\",\n    \"--setting\",\n    \"settings\",\n    type=Setting(),\n    help=\"nested.key, value setting to use in Datasette configuration\",\n    multiple=True,\n)\n@click.option(\n    \"--secret\",\n    help=\"Secret used for signing secure values, such as signed cookies\",\n    envvar=\"DATASETTE_SECRET\",\n)\n@click.option(\n    \"--root\",\n    help=\"Output URL that sets a cookie authenticating the root user\",\n    is_flag=True,\n)\n@click.option(\n    \"--default-deny\",\n    help=\"Deny all permissions by default\",\n    is_flag=True,\n)\n@click.option(\n    \"--get\",\n    help=\"Run an HTTP GET request against this path, print results and exit\",\n)\n@click.option(\n    \"--headers\",\n    is_flag=True,\n    help=\"Include HTTP headers in --get output\",\n)\n@click.option(\n    \"--token\",\n    help=\"API token to send with --get requests\",\n)\n@click.option(\n    \"--actor\",\n    help=\"Actor to use for --get requests (JSON string)\",\n)\n@click.option(\"--version-note\", help=\"Additional note to show on /-/versions\")\n@click.option(\"--help-settings\", is_flag=True, help=\"Show available settings\")\n@click.option(\"--pdb\", is_flag=True, help=\"Launch debugger on any errors\")\n@click.option(\n    \"-o\",\n    \"--open\",\n    \"open_browser\",\n    is_flag=True,\n    help=\"Open Datasette in your web browser\",\n)\n@click.option(\n    \"--create\",\n    is_flag=True,\n    help=\"Create database files if they do not exist\",\n)\n@click.option(\n    \"--crossdb\",\n    is_flag=True,\n    help=\"Enable cross-database joins using the /_memory database\",\n)\n@click.option(\n    \"--nolock\",\n    is_flag=True,\n    help=\"Ignore locking, open locked files in read-only mode\",\n)\n@click.option(\n    \"--ssl-keyfile\",\n    help=\"SSL key file\",\n    envvar=\"DATASETTE_SSL_KEYFILE\",\n)\n@click.option(\n    \"--ssl-certfile\",\n    help=\"SSL certificate file\",\n    envvar=\"DATASETTE_SSL_CERTFILE\",\n)\n@click.option(\n    \"--internal\",\n    type=click.Path(),\n    help=\"Path to a persistent Datasette internal SQLite database\",\n)\ndef serve(\n    files,\n    immutable,\n    host,\n    port,\n    uds,\n    reload,\n    cors,\n    sqlite_extensions,\n    inspect_file,\n    metadata,\n    template_dir,\n    plugins_dir,\n    static,\n    memory,\n    config,\n    settings,\n    secret,\n    root,\n    default_deny,\n    get,\n    headers,\n    token,\n    actor,\n    version_note,\n    help_settings,\n    pdb,\n    open_browser,\n    create,\n    crossdb,\n    nolock,\n    ssl_keyfile,\n    ssl_certfile,\n    internal,\n    return_instance=False,\n):\n    \"\"\"Serve up specified SQLite database files with a web UI\"\"\"\n    if help_settings:\n        formatter = formatting.HelpFormatter()\n        with formatter.section(\"Settings\"):\n            formatter.write_dl(\n                [\n                    (option.name, f\"{option.help} (default={option.default})\")\n                    for option in SETTINGS\n                ]\n            )\n        click.echo(formatter.getvalue())\n        sys.exit(0)\n    if reload:\n        import hupper\n\n        reloader = hupper.start_reloader(\"datasette.cli.cli\")\n        if immutable:\n            reloader.watch_files(immutable)\n        if config:\n            reloader.watch_files([config.name])\n        if metadata:\n            reloader.watch_files([metadata.name])\n\n    inspect_data = None\n    if inspect_file:\n        with open(inspect_file) as fp:\n            inspect_data = json.load(fp)\n\n    metadata_data = None\n    if metadata:\n        metadata_data = parse_metadata(metadata.read())\n\n    config_data = None\n    if config:\n        config_data = parse_metadata(config.read())\n\n    config_data = config_data or {}\n\n    # Merge in settings from -s/--setting\n    if settings:\n        settings_updates = pairs_to_nested_config(settings)\n        # Merge recursively, to avoid over-writing nested values\n        # https://github.com/simonw/datasette/issues/2389\n        deep_dict_update(config_data, settings_updates)\n\n    kwargs = dict(\n        immutables=immutable,\n        cache_headers=not reload,\n        cors=cors,\n        inspect_data=inspect_data,\n        config=config_data,\n        metadata=metadata_data,\n        sqlite_extensions=sqlite_extensions,\n        template_dir=template_dir,\n        plugins_dir=plugins_dir,\n        static_mounts=static,\n        settings=None,  # These are passed in config= now\n        memory=memory,\n        secret=secret,\n        version_note=version_note,\n        pdb=pdb,\n        crossdb=crossdb,\n        nolock=nolock,\n        internal=internal,\n        default_deny=default_deny,\n    )\n\n    # Separate directories from files\n    directories = [f for f in files if os.path.isdir(f)]\n    file_paths = [f for f in files if not os.path.isdir(f)]\n\n    # Handle config_dir - only one directory allowed\n    if len(directories) > 1:\n        raise click.ClickException(\n            \"Cannot pass multiple directories. Pass a single directory as config_dir.\"\n        )\n    elif len(directories) == 1:\n        kwargs[\"config_dir\"] = pathlib.Path(directories[0])\n\n    # Verify list of files, create if needed (and --create)\n    for file in file_paths:\n        if not pathlib.Path(file).exists():\n            if create:\n                sqlite3.connect(file).execute(\"vacuum\")\n            else:\n                raise click.ClickException(\n                    \"Invalid value for '[FILES]...': Path '{}' does not exist.\".format(\n                        file\n                    )\n                )\n\n    # Check for duplicate files by resolving all paths to their absolute forms\n    # Collect all database files that will be loaded (explicit files + config_dir files)\n    all_db_files = []\n\n    # Add explicit files\n    for file in file_paths:\n        all_db_files.append((file, pathlib.Path(file).resolve()))\n\n    # Add config_dir databases if config_dir is set\n    if \"config_dir\" in kwargs:\n        config_dir = kwargs[\"config_dir\"]\n        for ext in (\"db\", \"sqlite\", \"sqlite3\"):\n            for db_file in config_dir.glob(f\"*.{ext}\"):\n                all_db_files.append((str(db_file), db_file.resolve()))\n\n    # Check for duplicates\n    seen = {}\n    for original_path, resolved_path in all_db_files:\n        if resolved_path in seen:\n            raise click.ClickException(\n                f\"Duplicate database file: '{original_path}' and '{seen[resolved_path]}' \"\n                f\"both refer to {resolved_path}\"\n            )\n        seen[resolved_path] = original_path\n\n    files = file_paths\n\n    try:\n        ds = Datasette(files, **kwargs)\n    except SpatialiteNotFound:\n        raise click.ClickException(\"Could not find SpatiaLite extension\")\n    except StartupError as e:\n        raise click.ClickException(e.args[0])\n\n    if return_instance:\n        # Private utility mechanism for writing unit tests\n        return ds\n\n    # Run async soundness checks before startup hooks, since invoke_startup\n    # now populates internal tables which requires querying each database\n    run_sync(lambda: check_databases(ds))\n\n    # Run the \"startup\" plugin hooks\n    try:\n        run_sync(ds.invoke_startup)\n    except StartupError as e:\n        raise click.ClickException(e.args[0])\n\n    if headers and not get:\n        raise click.ClickException(\"--headers can only be used with --get\")\n\n    if token and not get:\n        raise click.ClickException(\"--token can only be used with --get\")\n\n    if get:\n        client = TestClient(ds)\n        request_headers = {}\n        if token:\n            request_headers[\"Authorization\"] = \"Bearer {}\".format(token)\n        cookies = {}\n        if actor:\n            cookies[\"ds_actor\"] = client.actor_cookie(json.loads(actor))\n        response = client.get(get, headers=request_headers, cookies=cookies)\n\n        if headers:\n            # Output HTTP status code, headers, two newlines, then the response body\n            click.echo(f\"HTTP/1.1 {response.status}\")\n            for key, value in response.headers.items():\n                click.echo(f\"{key}: {value}\")\n            if response.text:\n                click.echo()\n                click.echo(response.text)\n        else:\n            click.echo(response.text)\n\n        exit_code = 0 if response.status == 200 else 1\n        sys.exit(exit_code)\n        return\n\n    # Start the server\n    url = None\n    if root:\n        ds.root_enabled = True\n        url = \"http://{}:{}{}?token={}\".format(\n            host, port, ds.urls.path(\"-/auth-token\"), ds._root_token\n        )\n        click.echo(url)\n    if open_browser:\n        if url is None:\n            # Figure out most convenient URL - to table, database or homepage\n            path = run_sync(lambda: initial_path_for_datasette(ds))\n            url = f\"http://{host}:{port}{path}\"\n        webbrowser.open(url)\n    uvicorn_kwargs = dict(\n        host=host, port=port, log_level=\"info\", lifespan=\"on\", workers=1\n    )\n    if uds:\n        uvicorn_kwargs[\"uds\"] = uds\n    if ssl_keyfile:\n        uvicorn_kwargs[\"ssl_keyfile\"] = ssl_keyfile\n    if ssl_certfile:\n        uvicorn_kwargs[\"ssl_certfile\"] = ssl_certfile\n    uvicorn.run(ds.app(), **uvicorn_kwargs)\n\n\n@cli.command()\n@click.argument(\"id\")\n@click.option(\n    \"--secret\",\n    help=\"Secret used for signing the API tokens\",\n    envvar=\"DATASETTE_SECRET\",\n    required=True,\n)\n@click.option(\n    \"-e\",\n    \"--expires-after\",\n    help=\"Token should expire after this many seconds\",\n    type=int,\n)\n@click.option(\n    \"alls\",\n    \"-a\",\n    \"--all\",\n    type=str,\n    metavar=\"ACTION\",\n    multiple=True,\n    help=\"Restrict token to this action\",\n)\n@click.option(\n    \"databases\",\n    \"-d\",\n    \"--database\",\n    type=(str, str),\n    metavar=\"DB ACTION\",\n    multiple=True,\n    help=\"Restrict token to this action on this database\",\n)\n@click.option(\n    \"resources\",\n    \"-r\",\n    \"--resource\",\n    type=(str, str, str),\n    metavar=\"DB RESOURCE ACTION\",\n    multiple=True,\n    help=\"Restrict token to this action on this database resource (a table, SQL view or named query)\",\n)\n@click.option(\n    \"--debug\",\n    help=\"Show decoded token\",\n    is_flag=True,\n)\n@click.option(\n    \"--plugins-dir\",\n    type=click.Path(exists=True, file_okay=False, dir_okay=True),\n    help=\"Path to directory containing custom plugins\",\n)\ndef create_token(\n    id, secret, expires_after, alls, databases, resources, debug, plugins_dir\n):\n    \"\"\"\n    Create a signed API token for the specified actor ID\n\n    Example:\n\n        datasette create-token root --secret mysecret\n\n    To allow only \"view-database-download\" for all databases:\n\n    \\b\n        datasette create-token root --secret mysecret \\\\\n            --all view-database-download\n\n    To allow \"create-table\" against a specific database:\n\n    \\b\n        datasette create-token root --secret mysecret \\\\\n            --database mydb create-table\n\n    To allow \"insert-row\" against a specific table:\n\n    \\b\n        datasette create-token root --secret myscret \\\\\n            --resource mydb mytable insert-row\n\n    Restricted actions can be specified multiple times using\n    multiple --all, --database, and --resource options.\n\n    Add --debug to see a decoded version of the token.\n    \"\"\"\n    ds = Datasette(secret=secret, plugins_dir=plugins_dir)\n\n    # Run ds.invoke_startup() in an event loop\n    try:\n        run_sync(ds.invoke_startup)\n    except StartupError as e:\n        raise click.ClickException(e.args[0])\n\n    # Warn about any unknown actions\n    actions = []\n    actions.extend(alls)\n    actions.extend([p[1] for p in databases])\n    actions.extend([p[2] for p in resources])\n    for action in actions:\n        if not ds.actions.get(action):\n            click.secho(\n                f\"  Unknown permission: {action} \",\n                fg=\"red\",\n                err=True,\n            )\n\n    from datasette.tokens import TokenRestrictions\n\n    restrictions = TokenRestrictions()\n    for action in alls:\n        restrictions.allow_all(action)\n    for database, action in databases:\n        restrictions.allow_database(database, action)\n    for database, resource, action in resources:\n        restrictions.allow_resource(database, resource, action)\n\n    token = run_sync(\n        lambda: ds.create_token(\n            id,\n            expires_after=expires_after,\n            restrictions=restrictions,\n            handler=\"signed\",\n        )\n    )\n    click.echo(token)\n    if debug:\n        encoded = token[len(\"dstok_\") :]\n        click.echo(\"\\nDecoded:\\n\")\n        click.echo(json.dumps(ds.unsign(encoded, namespace=\"token\"), indent=2))\n\n\npm.hook.register_commands(cli=cli)\n\n\nasync def check_databases(ds):\n    # Run check_connection against every connected database\n    # to confirm they are all usable\n    for database in list(ds.databases.values()):\n        try:\n            await database.execute_fn(check_connection)\n        except SpatialiteConnectionProblem:\n            suggestion = \"\"\n            try:\n                find_spatialite()\n                suggestion = \"\\n\\nTry adding the --load-extension=spatialite option.\"\n            except SpatialiteNotFound:\n                pass\n            raise click.UsageError(\n                \"It looks like you're trying to load a SpatiaLite\"\n                + \" database without first loading the SpatiaLite module.\"\n                + suggestion\n                + \"\\n\\nRead more: https://docs.datasette.io/en/stable/spatialite.html\"\n            )\n        except ConnectionProblem as e:\n            raise click.UsageError(\n                f\"Connection to {database.path} failed check: {str(e.args[0])}\"\n            )\n    # If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning\n    if (\n        ds.crossdb\n        and len([db for db in ds.databases.values() if not db.is_memory])\n        > SQLITE_LIMIT_ATTACHED\n    ):\n        msg = (\n            \"Warning: --crossdb only works with the first {} attached databases\".format(\n                SQLITE_LIMIT_ATTACHED\n            )\n        )\n        click.echo(click.style(msg, bold=True, fg=\"yellow\"), err=True)\n"
  },
  {
    "path": "datasette/column_types.py",
    "content": "from enum import Enum\n\n\nclass SQLiteType(Enum):\n    TEXT = \"TEXT\"\n    INTEGER = \"INTEGER\"\n    REAL = \"REAL\"\n    BLOB = \"BLOB\"\n    NULL = \"NULL\"\n\n    @classmethod\n    def from_declared_type(cls, declared_type: str | None) -> \"SQLiteType | None\":\n        if declared_type is None:\n            return cls.NULL\n\n        normalized = declared_type.strip().upper()\n        if not normalized:\n            return cls.NULL\n\n        if normalized == cls.NULL.value:\n            return cls.NULL\n        if \"INT\" in normalized:\n            return cls.INTEGER\n        if any(token in normalized for token in (\"CHAR\", \"CLOB\", \"TEXT\")):\n            return cls.TEXT\n        if \"BLOB\" in normalized:\n            return cls.BLOB\n        if any(\n            token in normalized\n            for token in (\"REAL\", \"FLOA\", \"DOUB\")  # codespell:ignore doub\n        ):\n            return cls.REAL\n\n        return None\n\n\nclass ColumnType:\n    \"\"\"\n    Base class for column types.\n\n    Subclasses must define ``name`` and ``description`` as class attributes:\n\n    - ``name``: Unique identifier string. Lowercase, no spaces.\n      Examples: \"markdown\", \"file\", \"email\", \"url\", \"point\", \"image\".\n    - ``description``: Human-readable label for admin UI dropdowns.\n      Examples: \"Markdown text\", \"File reference\", \"Email address\".\n    - ``sqlite_types``: Optional tuple of SQLiteType values restricting\n      which SQLite column types this ColumnType can be assigned to.\n\n    Instantiate with an optional ``config`` dict to bind per-column\n    configuration::\n\n        ct = MyColumnType(config={\"key\": \"value\"})\n        ct.config  # {\"key\": \"value\"}\n    \"\"\"\n\n    name: str\n    description: str\n    sqlite_types: tuple[SQLiteType, ...] | None = None\n\n    def __init__(self, config=None):\n        self.config = config\n\n    async def render_cell(self, value, column, table, database, datasette, request):\n        \"\"\"\n        Return an HTML string to render this cell value, or None to\n        fall through to the default render_cell plugin hook chain.\n        \"\"\"\n        return None\n\n    async def validate(self, value, datasette):\n        \"\"\"\n        Validate a value before it is written. Return None if valid,\n        or a string error message if invalid.\n        \"\"\"\n        return None\n\n    async def transform_value(self, value, datasette):\n        \"\"\"\n        Transform a value before it appears in JSON API output.\n        Return the transformed value. Default: return unchanged.\n        \"\"\"\n        return value\n"
  },
  {
    "path": "datasette/database.py",
    "content": "import asyncio\nfrom collections import namedtuple\nfrom pathlib import Path\nimport janus\nimport queue\nimport sqlite_utils\nimport sys\nimport threading\nimport uuid\n\nfrom .tracer import trace\nfrom .utils import (\n    detect_fts,\n    detect_primary_keys,\n    detect_spatialite,\n    get_all_foreign_keys,\n    get_outbound_foreign_keys,\n    md5_not_usedforsecurity,\n    sqlite_timelimit,\n    sqlite3,\n    table_columns,\n    table_column_details,\n)\nfrom .utils.sqlite import sqlite_version\nfrom .inspect import inspect_hash\n\nconnections = threading.local()\n\nAttachedDatabase = namedtuple(\"AttachedDatabase\", (\"seq\", \"name\", \"file\"))\n\n\nclass Database:\n    # For table counts stop at this many rows:\n    count_limit = 10000\n    _thread_local_id_counter = 1\n\n    def __init__(\n        self,\n        ds,\n        path=None,\n        is_mutable=True,\n        is_memory=False,\n        memory_name=None,\n        mode=None,\n    ):\n        self.name = None\n        self._thread_local_id = f\"x{self._thread_local_id_counter}\"\n        Database._thread_local_id_counter += 1\n        self.route = None\n        self.ds = ds\n        self.path = path\n        self.is_mutable = is_mutable\n        self.is_memory = is_memory\n        self.memory_name = memory_name\n        if memory_name is not None:\n            self.is_memory = True\n        self.cached_hash = None\n        self.cached_size = None\n        self._cached_table_counts = None\n        self._write_thread = None\n        self._write_queue = None\n        # These are used when in non-threaded mode:\n        self._read_connection = None\n        self._write_connection = None\n        # This is used to track all file connections so they can be closed\n        self._all_file_connections = []\n        self.mode = mode\n\n    @property\n    def cached_table_counts(self):\n        if self._cached_table_counts is not None:\n            return self._cached_table_counts\n        # Maybe use self.ds.inspect_data to populate cached_table_counts\n        if self.ds.inspect_data and self.ds.inspect_data.get(self.name):\n            self._cached_table_counts = {\n                key: value[\"count\"]\n                for key, value in self.ds.inspect_data[self.name][\"tables\"].items()\n            }\n        return self._cached_table_counts\n\n    @property\n    def color(self):\n        if self.hash:\n            return self.hash[:6]\n        return md5_not_usedforsecurity(self.name)[:6]\n\n    def suggest_name(self):\n        if self.path:\n            return Path(self.path).stem\n        elif self.memory_name:\n            return self.memory_name\n        else:\n            return \"db\"\n\n    def connect(self, write=False):\n        extra_kwargs = {}\n        if write:\n            extra_kwargs[\"isolation_level\"] = \"IMMEDIATE\"\n        if self.memory_name:\n            uri = \"file:{}?mode=memory&cache=shared\".format(self.memory_name)\n            conn = sqlite3.connect(\n                uri, uri=True, check_same_thread=False, **extra_kwargs\n            )\n            if not write:\n                conn.execute(\"PRAGMA query_only=1\")\n            return conn\n        if self.is_memory:\n            return sqlite3.connect(\":memory:\", uri=True)\n\n        # mode=ro or immutable=1?\n        if self.is_mutable:\n            qs = \"?mode=ro\"\n            if self.ds.nolock:\n                qs += \"&nolock=1\"\n        else:\n            qs = \"?immutable=1\"\n        assert not (write and not self.is_mutable)\n        if write:\n            qs = \"\"\n        if self.mode is not None:\n            qs = f\"?mode={self.mode}\"\n        conn = sqlite3.connect(\n            f\"file:{self.path}{qs}\", uri=True, check_same_thread=False, **extra_kwargs\n        )\n        self._all_file_connections.append(conn)\n        return conn\n\n    def close(self):\n        # Close all connections - useful to avoid running out of file handles in tests\n        for connection in self._all_file_connections:\n            connection.close()\n\n    async def execute_write(self, sql, params=None, block=True, request=None):\n        def _inner(conn):\n            return conn.execute(sql, params or [])\n\n        with trace(\"sql\", database=self.name, sql=sql.strip(), params=params):\n            results = await self.execute_write_fn(_inner, block=block, request=request)\n        return results\n\n    async def execute_write_script(self, sql, block=True, request=None):\n        def _inner(conn):\n            return conn.executescript(sql)\n\n        with trace(\"sql\", database=self.name, sql=sql.strip(), executescript=True):\n            results = await self.execute_write_fn(\n                _inner, block=block, transaction=False, request=request\n            )\n        return results\n\n    async def execute_write_many(self, sql, params_seq, block=True, request=None):\n        def _inner(conn):\n            count = 0\n\n            def count_params(params):\n                nonlocal count\n                for param in params:\n                    count += 1\n                    yield param\n\n            return conn.executemany(sql, count_params(params_seq)), count\n\n        with trace(\n            \"sql\", database=self.name, sql=sql.strip(), executemany=True\n        ) as kwargs:\n            results, count = await self.execute_write_fn(\n                _inner, block=block, request=request\n            )\n            kwargs[\"count\"] = count\n        return results\n\n    async def execute_isolated_fn(self, fn):\n        # Open a new connection just for the duration of this function\n        # blocking the write queue to avoid any writes occurring during it\n        if self.ds.executor is None:\n            # non-threaded mode\n            isolated_connection = self.connect(write=True)\n            try:\n                result = fn(isolated_connection)\n            finally:\n                isolated_connection.close()\n                try:\n                    self._all_file_connections.remove(isolated_connection)\n                except ValueError:\n                    # Was probably a memory connection\n                    pass\n            return result\n        else:\n            # Threaded mode - send to write thread\n            return await self._send_to_write_thread(fn, isolated_connection=True)\n\n    async def execute_write_fn(self, fn, block=True, transaction=True, request=None):\n        fn = self._wrap_fn_with_hooks(fn, request, transaction)\n        if self.ds.executor is None:\n            # non-threaded mode\n            if self._write_connection is None:\n                self._write_connection = self.connect(write=True)\n                self.ds._prepare_connection(self._write_connection, self.name)\n            if transaction:\n                with self._write_connection:\n                    return fn(self._write_connection)\n            else:\n                return fn(self._write_connection)\n        else:\n            return await self._send_to_write_thread(\n                fn, block=block, transaction=transaction\n            )\n\n    def _wrap_fn_with_hooks(self, fn, request, transaction):\n        from .plugins import pm\n\n        wrappers = pm.hook.write_wrapper(\n            datasette=self.ds,\n            database=self.name,\n            request=request,\n            transaction=transaction,\n        )\n        wrappers = [w for w in wrappers if w is not None]\n        if not wrappers:\n            return fn\n        # Build the wrapped fn by nesting context manager generators.\n        # The first wrapper returned by pluggy is outermost.\n        original_fn = fn\n        for wrapper_factory in reversed(wrappers):\n            original_fn = _apply_write_wrapper(original_fn, wrapper_factory)\n        return original_fn\n\n    async def _send_to_write_thread(\n        self, fn, block=True, isolated_connection=False, transaction=True\n    ):\n        if self._write_queue is None:\n            self._write_queue = queue.Queue()\n        if self._write_thread is None:\n            self._write_thread = threading.Thread(\n                target=self._execute_writes, daemon=True\n            )\n            self._write_thread.name = \"_execute_writes for database {}\".format(\n                self.name\n            )\n            self._write_thread.start()\n        task_id = uuid.uuid5(uuid.NAMESPACE_DNS, \"datasette.io\")\n        reply_queue = janus.Queue()\n        self._write_queue.put(\n            WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)\n        )\n        if block:\n            result = await reply_queue.async_q.get()\n            if isinstance(result, Exception):\n                raise result\n            else:\n                return result\n        else:\n            return task_id\n\n    def _execute_writes(self):\n        # Infinite looping thread that protects the single write connection\n        # to this database\n        conn_exception = None\n        conn = None\n        try:\n            conn = self.connect(write=True)\n            self.ds._prepare_connection(conn, self.name)\n        except Exception as e:\n            conn_exception = e\n        while True:\n            task = self._write_queue.get()\n            if conn_exception is not None:\n                result = conn_exception\n            else:\n                if task.isolated_connection:\n                    isolated_connection = self.connect(write=True)\n                    try:\n                        result = task.fn(isolated_connection)\n                    except Exception as e:\n                        sys.stderr.write(\"{}\\n\".format(e))\n                        sys.stderr.flush()\n                        result = e\n                    finally:\n                        isolated_connection.close()\n                        try:\n                            self._all_file_connections.remove(isolated_connection)\n                        except ValueError:\n                            # Was probably a memory connection\n                            pass\n                else:\n                    try:\n                        if task.transaction:\n                            with conn:\n                                result = task.fn(conn)\n                        else:\n                            result = task.fn(conn)\n                    except Exception as e:\n                        sys.stderr.write(\"{}\\n\".format(e))\n                        sys.stderr.flush()\n                        result = e\n            task.reply_queue.sync_q.put(result)\n\n    async def execute_fn(self, fn):\n        if self.ds.executor is None:\n            # non-threaded mode\n            if self._read_connection is None:\n                self._read_connection = self.connect()\n                self.ds._prepare_connection(self._read_connection, self.name)\n            return fn(self._read_connection)\n\n        # threaded mode\n        def in_thread():\n            conn = getattr(connections, self._thread_local_id, None)\n            if not conn:\n                conn = self.connect()\n                self.ds._prepare_connection(conn, self.name)\n                setattr(connections, self._thread_local_id, conn)\n            return fn(conn)\n\n        return await asyncio.get_event_loop().run_in_executor(\n            self.ds.executor, in_thread\n        )\n\n    async def execute(\n        self,\n        sql,\n        params=None,\n        truncate=False,\n        custom_time_limit=None,\n        page_size=None,\n        log_sql_errors=True,\n    ):\n        \"\"\"Executes sql against db_name in a thread\"\"\"\n        page_size = page_size or self.ds.page_size\n\n        def sql_operation_in_thread(conn):\n            time_limit_ms = self.ds.sql_time_limit_ms\n            if custom_time_limit and custom_time_limit < time_limit_ms:\n                time_limit_ms = custom_time_limit\n\n            with sqlite_timelimit(conn, time_limit_ms):\n                try:\n                    cursor = conn.cursor()\n                    cursor.execute(sql, params if params is not None else {})\n                    max_returned_rows = self.ds.max_returned_rows\n                    if max_returned_rows == page_size:\n                        max_returned_rows += 1\n                    if max_returned_rows and truncate:\n                        rows = cursor.fetchmany(max_returned_rows + 1)\n                        truncated = len(rows) > max_returned_rows\n                        rows = rows[:max_returned_rows]\n                    else:\n                        rows = cursor.fetchall()\n                        truncated = False\n                except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:\n                    if e.args == (\"interrupted\",):\n                        raise QueryInterrupted(e, sql, params)\n                    if log_sql_errors:\n                        sys.stderr.write(\n                            \"ERROR: conn={}, sql = {}, params = {}: {}\\n\".format(\n                                conn, repr(sql), params, e\n                            )\n                        )\n                        sys.stderr.flush()\n                    raise\n\n            if truncate:\n                return Results(rows, truncated, cursor.description)\n\n            else:\n                return Results(rows, False, cursor.description)\n\n        with trace(\"sql\", database=self.name, sql=sql.strip(), params=params):\n            results = await self.execute_fn(sql_operation_in_thread)\n        return results\n\n    @property\n    def hash(self):\n        if self.cached_hash is not None:\n            return self.cached_hash\n        elif self.is_mutable or self.is_memory:\n            return None\n        elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):\n            self.cached_hash = self.ds.inspect_data[self.name][\"hash\"]\n            return self.cached_hash\n        else:\n            p = Path(self.path)\n            self.cached_hash = inspect_hash(p)\n            return self.cached_hash\n\n    @property\n    def size(self):\n        if self.cached_size is not None:\n            return self.cached_size\n        elif self.is_memory:\n            return 0\n        elif self.is_mutable:\n            return Path(self.path).stat().st_size\n        elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):\n            self.cached_size = self.ds.inspect_data[self.name][\"size\"]\n            return self.cached_size\n        else:\n            self.cached_size = Path(self.path).stat().st_size\n            return self.cached_size\n\n    async def table_counts(self, limit=10):\n        if not self.is_mutable and self.cached_table_counts is not None:\n            return self.cached_table_counts\n        # Try to get counts for each table, $limit timeout for each count\n        counts = {}\n        for table in await self.table_names():\n            try:\n                table_count = (\n                    await self.execute(\n                        f\"select count(*) from (select * from [{table}] limit {self.count_limit + 1})\",\n                        custom_time_limit=limit,\n                    )\n                ).rows[0][0]\n                counts[table] = table_count\n            # In some cases I saw \"SQL Logic Error\" here in addition to\n            # QueryInterrupted - so we catch that too:\n            except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError):\n                counts[table] = None\n        if not self.is_mutable:\n            self._cached_table_counts = counts\n        return counts\n\n    @property\n    def mtime_ns(self):\n        if self.is_memory:\n            return None\n        return Path(self.path).stat().st_mtime_ns\n\n    async def attached_databases(self):\n        # This used to be:\n        #   select seq, name, file from pragma_database_list() where seq > 0\n        # But SQLite prior to 3.16.0 doesn't support pragma functions\n        results = await self.execute(\"PRAGMA database_list;\")\n        # {'seq': 0, 'name': 'main', 'file': ''}\n        return [\n            AttachedDatabase(*row)\n            for row in results.rows\n            # Filter out the SQLite internal \"temp\" database, refs #2557\n            if row[\"seq\"] > 0 and row[\"name\"] != \"temp\"\n        ]\n\n    async def table_exists(self, table):\n        results = await self.execute(\n            \"select 1 from sqlite_master where type='table' and name=?\", params=(table,)\n        )\n        return bool(results.rows)\n\n    async def view_exists(self, table):\n        results = await self.execute(\n            \"select 1 from sqlite_master where type='view' and name=?\", params=(table,)\n        )\n        return bool(results.rows)\n\n    async def table_names(self):\n        results = await self.execute(\n            \"select name from sqlite_master where type='table' order by name\"\n        )\n        return [r[0] for r in results.rows]\n\n    async def table_columns(self, table):\n        return await self.execute_fn(lambda conn: table_columns(conn, table))\n\n    async def table_column_details(self, table):\n        return await self.execute_fn(lambda conn: table_column_details(conn, table))\n\n    async def primary_keys(self, table):\n        return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))\n\n    async def fts_table(self, table):\n        return await self.execute_fn(lambda conn: detect_fts(conn, table))\n\n    async def label_column_for_table(self, table):\n        explicit_label_column = (await self.ds.table_config(self.name, table)).get(\n            \"label_column\"\n        )\n        if explicit_label_column:\n            return explicit_label_column\n\n        def column_details(conn):\n            # Returns {column_name: (type, is_unique)}\n            db = sqlite_utils.Database(conn)\n            columns = db[table].columns_dict\n            indexes = db[table].indexes\n            details = {}\n            for name in columns:\n                is_unique = any(\n                    index\n                    for index in indexes\n                    if index.columns == [name] and index.unique\n                )\n                details[name] = (columns[name], is_unique)\n            return details\n\n        column_details = await self.execute_fn(column_details)\n        # Is there just one unique column that's text?\n        unique_text_columns = [\n            name\n            for name, (type_, is_unique) in column_details.items()\n            if is_unique and type_ is str\n        ]\n        if len(unique_text_columns) == 1:\n            return unique_text_columns[0]\n\n        column_names = list(column_details.keys())\n        # Is there a name or title column?\n        name_or_title = [c for c in column_names if c.lower() in (\"name\", \"title\")]\n        if name_or_title:\n            return name_or_title[0]\n        # If a table has two columns, one of which is ID, then label_column is the other one\n        if (\n            column_names\n            and len(column_names) == 2\n            and (\"id\" in column_names or \"pk\" in column_names)\n            and not set(column_names) == {\"id\", \"pk\"}\n        ):\n            return [c for c in column_names if c not in (\"id\", \"pk\")][0]\n        # Couldn't find a label:\n        return None\n\n    async def foreign_keys_for_table(self, table):\n        return await self.execute_fn(\n            lambda conn: get_outbound_foreign_keys(conn, table)\n        )\n\n    async def hidden_table_names(self):\n        hidden_tables = []\n        # Add any tables marked as hidden in config\n        db_config = self.ds.config.get(\"databases\", {}).get(self.name, {})\n        if \"tables\" in db_config:\n            hidden_tables += [\n                t for t in db_config[\"tables\"] if db_config[\"tables\"][t].get(\"hidden\")\n            ]\n\n        if sqlite_version()[1] >= 37:\n            hidden_tables += [x[0] for x in await self.execute(\"\"\"\n                      with shadow_tables as (\n                        select name\n                        from pragma_table_list\n                        where [type] = 'shadow'\n                        order by name\n                      ),\n                      core_tables as (\n                        select name\n                        from sqlite_master\n                        WHERE  name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')\n                          OR substr(name, 1, 1) == '_'\n                      ),\n                      combined as (\n                        select name from shadow_tables\n                        union all\n                        select name from core_tables\n                      )\n                      select name from combined order by 1\n                    \"\"\")]\n        else:\n            hidden_tables += [x[0] for x in await self.execute(\"\"\"\n                      WITH base AS (\n                        SELECT name\n                        FROM sqlite_master\n                        WHERE  name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')\n                          OR substr(name, 1, 1) == '_'\n                      ),\n                      fts_suffixes AS (\n                        SELECT column1 AS suffix\n                        FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))\n                      ),\n                      fts5_names AS (\n                        SELECT name\n                        FROM sqlite_master\n                        WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'\n                      ),\n                      fts5_shadow_tables AS (\n                        SELECT\n                          printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name\n                        FROM fts5_names\n                        JOIN fts_suffixes\n                      ),\n                      fts3_suffixes AS (\n                        SELECT column1 AS suffix\n                        FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))\n                      ),\n                      fts3_names AS (\n                        SELECT name\n                        FROM sqlite_master\n                        WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'\n                          OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'\n                      ),\n                      fts3_shadow_tables AS (\n                        SELECT\n                          printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name\n                        FROM fts3_names\n                        JOIN fts3_suffixes\n                      ),\n                      final AS (\n                        SELECT name FROM base\n                        UNION ALL\n                        SELECT name FROM fts5_shadow_tables\n                        UNION ALL\n                        SELECT name FROM fts3_shadow_tables\n                      )\n                      SELECT name FROM final ORDER BY 1\n                    \"\"\")]\n        # Also hide any FTS tables that have a content= argument\n        hidden_tables += [x[0] for x in await self.execute(\"\"\"\n                  SELECT name\n                  FROM sqlite_master\n                  WHERE sql LIKE '%VIRTUAL TABLE%'\n                    AND sql LIKE '%USING FTS%'\n                    AND sql LIKE '%content=%'\n                \"\"\")]\n\n        has_spatialite = await self.execute_fn(detect_spatialite)\n        if has_spatialite:\n            # Also hide Spatialite internal tables\n            hidden_tables += [\n                \"ElementaryGeometries\",\n                \"SpatialIndex\",\n                \"geometry_columns\",\n                \"spatial_ref_sys\",\n                \"spatialite_history\",\n                \"sql_statements_log\",\n                \"sqlite_sequence\",\n                \"views_geometry_columns\",\n                \"virts_geometry_columns\",\n                \"data_licenses\",\n                \"KNN\",\n                \"KNN2\",\n            ] + [\n                r[0] for r in (await self.execute(\"\"\"\n                        select name from sqlite_master\n                        where name like \"idx_%\"\n                        and type = \"table\"\n                    \"\"\")).rows\n            ]\n\n        return hidden_tables\n\n    async def view_names(self):\n        results = await self.execute(\"select name from sqlite_master where type='view'\")\n        return [r[0] for r in results.rows]\n\n    async def get_all_foreign_keys(self):\n        return await self.execute_fn(get_all_foreign_keys)\n\n    async def get_table_definition(self, table, type_=\"table\"):\n        table_definition_rows = list(\n            await self.execute(\n                \"select sql from sqlite_master where name = :n and type=:t\",\n                {\"n\": table, \"t\": type_},\n            )\n        )\n        if not table_definition_rows:\n            return None\n        bits = [table_definition_rows[0][0] + \";\"]\n        # Add on any indexes\n        index_rows = list(\n            await self.execute(\n                \"select sql from sqlite_master where tbl_name = :n and type='index' and sql is not null\",\n                {\"n\": table},\n            )\n        )\n        for index_row in index_rows:\n            bits.append(index_row[0] + \";\")\n        return \"\\n\".join(bits)\n\n    async def get_view_definition(self, view):\n        return await self.get_table_definition(view, \"view\")\n\n    def __repr__(self):\n        tags = []\n        if self.is_mutable:\n            tags.append(\"mutable\")\n        if self.is_memory:\n            tags.append(\"memory\")\n        if self.hash:\n            tags.append(f\"hash={self.hash}\")\n        if self.size is not None:\n            tags.append(f\"size={self.size}\")\n        tags_str = \"\"\n        if tags:\n            tags_str = f\" ({', '.join(tags)})\"\n        return f\"<Database: {self.name}{tags_str}>\"\n\n\ndef _apply_write_wrapper(fn, wrapper_factory):\n    \"\"\"Apply a single write_wrapper context manager around fn.\n\n    ``wrapper_factory`` is a callable that takes ``(conn)`` and returns a\n    generator that yields exactly once.  Code before the yield runs before\n    ``fn(conn)``, code after the yield runs after.  The result of\n    ``fn(conn)`` is sent into the generator via ``.send()``, and any\n    exception raised by ``fn(conn)`` is thrown via ``.throw()``.\n    \"\"\"\n\n    def wrapped(conn):\n        gen = wrapper_factory(conn)\n        # Advance to the yield point (run \"before\" code)\n        try:\n            next(gen)\n        except StopIteration:\n            # Generator didn't yield — just run fn unchanged\n            return fn(conn)\n\n        # Execute the actual write\n        try:\n            result = fn(conn)\n        except Exception:\n            # Throw exception into generator so it can handle it\n            try:\n                gen.throw(*sys.exc_info())\n            except StopIteration:\n                pass\n            # Re-raise the original exception\n            raise\n        else:\n            # Send the result back through the yield\n            try:\n                gen.send(result)\n            except StopIteration:\n                pass\n            return result\n\n    return wrapped\n\n\nclass WriteTask:\n    __slots__ = (\"fn\", \"task_id\", \"reply_queue\", \"isolated_connection\", \"transaction\")\n\n    def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):\n        self.fn = fn\n        self.task_id = task_id\n        self.reply_queue = reply_queue\n        self.isolated_connection = isolated_connection\n        self.transaction = transaction\n\n\nclass QueryInterrupted(Exception):\n    def __init__(self, e, sql, params):\n        self.e = e\n        self.sql = sql\n        self.params = params\n\n    def __str__(self):\n        return \"QueryInterrupted: {}\".format(self.e)\n\n\nclass MultipleValues(Exception):\n    pass\n\n\nclass Results:\n    def __init__(self, rows, truncated, description):\n        self.rows = rows\n        self.truncated = truncated\n        self.description = description\n\n    @property\n    def columns(self):\n        return [d[0] for d in self.description]\n\n    def first(self):\n        if self.rows:\n            return self.rows[0]\n        else:\n            return None\n\n    def single_value(self):\n        if self.rows and 1 == len(self.rows) and 1 == len(self.rows[0]):\n            return self.rows[0][0]\n        else:\n            raise MultipleValues\n\n    def dicts(self):\n        return [dict(row) for row in self.rows]\n\n    def __iter__(self):\n        return iter(self.rows)\n\n    def __len__(self):\n        return len(self.rows)\n"
  },
  {
    "path": "datasette/default_actions.py",
    "content": "from datasette import hookimpl\nfrom datasette.permissions import Action\nfrom datasette.resources import (\n    DatabaseResource,\n    TableResource,\n    QueryResource,\n)\n\n\n@hookimpl\ndef register_actions():\n    \"\"\"Register the core Datasette actions.\"\"\"\n    return (\n        # Global actions (no resource_class)\n        Action(\n            name=\"view-instance\",\n            abbr=\"vi\",\n            description=\"View Datasette instance\",\n        ),\n        Action(\n            name=\"permissions-debug\",\n            abbr=\"pd\",\n            description=\"Access permission debug tool\",\n        ),\n        Action(\n            name=\"debug-menu\",\n            abbr=\"dm\",\n            description=\"View debug menu items\",\n        ),\n        # Database-level actions (parent-level)\n        Action(\n            name=\"view-database\",\n            abbr=\"vd\",\n            description=\"View database\",\n            resource_class=DatabaseResource,\n        ),\n        Action(\n            name=\"view-database-download\",\n            abbr=\"vdd\",\n            description=\"Download database file\",\n            resource_class=DatabaseResource,\n            also_requires=\"view-database\",\n        ),\n        Action(\n            name=\"execute-sql\",\n            abbr=\"es\",\n            description=\"Execute read-only SQL queries\",\n            resource_class=DatabaseResource,\n            also_requires=\"view-database\",\n        ),\n        Action(\n            name=\"create-table\",\n            abbr=\"ct\",\n            description=\"Create tables\",\n            resource_class=DatabaseResource,\n        ),\n        # Table-level actions (child-level)\n        Action(\n            name=\"view-table\",\n            abbr=\"vt\",\n            description=\"View table\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"insert-row\",\n            abbr=\"ir\",\n            description=\"Insert rows\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"delete-row\",\n            abbr=\"dr\",\n            description=\"Delete rows\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"update-row\",\n            abbr=\"ur\",\n            description=\"Update rows\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"alter-table\",\n            abbr=\"at\",\n            description=\"Alter tables\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"set-column-type\",\n            abbr=\"sct\",\n            description=\"Set column type\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"drop-table\",\n            abbr=\"dt\",\n            description=\"Drop tables\",\n            resource_class=TableResource,\n        ),\n        # Query-level actions (child-level)\n        Action(\n            name=\"view-query\",\n            abbr=\"vq\",\n            description=\"View named query results\",\n            resource_class=QueryResource,\n        ),\n    )\n"
  },
  {
    "path": "datasette/default_column_types.py",
    "content": "import json\nimport re\n\nimport markupsafe\n\nfrom datasette import hookimpl\nfrom datasette.column_types import ColumnType, SQLiteType\n\n\nclass UrlColumnType(ColumnType):\n    name = \"url\"\n    description = \"URL\"\n    sqlite_types = (SQLiteType.TEXT,)\n\n    async def render_cell(self, value, column, table, database, datasette, request):\n        if not value or not isinstance(value, str):\n            return None\n        escaped = markupsafe.escape(value.strip())\n        return markupsafe.Markup(f'<a href=\"{escaped}\">{escaped}</a>')\n\n    async def validate(self, value, datasette):\n        if value is None or value == \"\":\n            return None\n        if not isinstance(value, str):\n            return \"URL must be a string\"\n        if not re.match(r\"^https?://\\S+$\", value.strip()):\n            return \"Invalid URL\"\n        return None\n\n\nclass EmailColumnType(ColumnType):\n    name = \"email\"\n    description = \"Email address\"\n    sqlite_types = (SQLiteType.TEXT,)\n\n    async def render_cell(self, value, column, table, database, datasette, request):\n        if not value or not isinstance(value, str):\n            return None\n        escaped = markupsafe.escape(value.strip())\n        return markupsafe.Markup(f'<a href=\"mailto:{escaped}\">{escaped}</a>')\n\n    async def validate(self, value, datasette):\n        if value is None or value == \"\":\n            return None\n        if not isinstance(value, str):\n            return \"Email must be a string\"\n        if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\", value.strip()):\n            return \"Invalid email address\"\n        return None\n\n\nclass JsonColumnType(ColumnType):\n    name = \"json\"\n    description = \"JSON data\"\n    sqlite_types = (SQLiteType.TEXT,)\n\n    async def render_cell(self, value, column, table, database, datasette, request):\n        if value is None:\n            return None\n        try:\n            parsed = json.loads(value) if isinstance(value, str) else value\n            formatted = json.dumps(parsed, indent=2)\n            escaped = markupsafe.escape(formatted)\n            return markupsafe.Markup(f\"<pre>{escaped}</pre>\")\n        except (json.JSONDecodeError, TypeError):\n            return None\n\n    async def validate(self, value, datasette):\n        if value is None or value == \"\":\n            return None\n        if isinstance(value, str):\n            try:\n                json.loads(value)\n            except json.JSONDecodeError:\n                return \"Invalid JSON\"\n        return None\n\n\n@hookimpl\ndef register_column_types(datasette):\n    return [UrlColumnType, EmailColumnType, JsonColumnType]\n"
  },
  {
    "path": "datasette/default_magic_parameters.py",
    "content": "from datasette import hookimpl\nimport datetime\nimport os\nimport time\n\n\ndef header(key, request):\n    key = key.replace(\"_\", \"-\").encode(\"utf-8\")\n    headers_dict = dict(request.scope[\"headers\"])\n    return headers_dict.get(key, b\"\").decode(\"utf-8\")\n\n\ndef actor(key, request):\n    if request.actor is None:\n        raise KeyError\n    return request.actor[key]\n\n\ndef cookie(key, request):\n    return request.cookies[key]\n\n\ndef now(key, request):\n    if key == \"epoch\":\n        return int(time.time())\n    elif key == \"date_utc\":\n        return datetime.datetime.now(datetime.timezone.utc).date().isoformat()\n    elif key == \"datetime_utc\":\n        return (\n            datetime.datetime.now(datetime.timezone.utc).strftime(r\"%Y-%m-%dT%H:%M:%S\")\n            + \"Z\"\n        )\n    else:\n        raise KeyError\n\n\ndef random(key, request):\n    if key.startswith(\"chars_\") and key.split(\"chars_\")[-1].isdigit():\n        num_chars = int(key.split(\"chars_\")[-1])\n        if num_chars % 2 == 1:\n            urandom_len = (num_chars + 1) / 2\n        else:\n            urandom_len = num_chars / 2\n        return os.urandom(int(urandom_len)).hex()[:num_chars]\n    else:\n        raise KeyError\n\n\n@hookimpl\ndef register_magic_parameters():\n    return [\n        (\"header\", header),\n        (\"actor\", actor),\n        (\"cookie\", cookie),\n        (\"now\", now),\n        (\"random\", random),\n    ]\n"
  },
  {
    "path": "datasette/default_menu_links.py",
    "content": "from datasette import hookimpl\n\n\n@hookimpl\ndef menu_links(datasette, actor):\n    async def inner():\n        if not await datasette.allowed(action=\"debug-menu\", actor=actor):\n            return []\n\n        return [\n            {\"href\": datasette.urls.path(\"/-/databases\"), \"label\": \"Databases\"},\n            {\n                \"href\": datasette.urls.path(\"/-/plugins\"),\n                \"label\": \"Installed plugins\",\n            },\n            {\n                \"href\": datasette.urls.path(\"/-/versions\"),\n                \"label\": \"Version info\",\n            },\n            {\n                \"href\": datasette.urls.path(\"/-/settings\"),\n                \"label\": \"Settings\",\n            },\n            {\n                \"href\": datasette.urls.path(\"/-/permissions\"),\n                \"label\": \"Debug permissions\",\n            },\n            {\n                \"href\": datasette.urls.path(\"/-/messages\"),\n                \"label\": \"Debug messages\",\n            },\n            {\n                \"href\": datasette.urls.path(\"/-/allow-debug\"),\n                \"label\": \"Debug allow rules\",\n            },\n            {\"href\": datasette.urls.path(\"/-/threads\"), \"label\": \"Debug threads\"},\n            {\"href\": datasette.urls.path(\"/-/actor\"), \"label\": \"Debug actor\"},\n            {\"href\": datasette.urls.path(\"/-/patterns\"), \"label\": \"Pattern portfolio\"},\n        ]\n\n    return inner\n"
  },
  {
    "path": "datasette/default_permissions/__init__.py",
    "content": "\"\"\"\nDefault permission implementations for Datasette.\n\nThis module provides the built-in permission checking logic through implementations\nof the permission_resources_sql hook. The hooks are organized by their purpose:\n\n1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens\n2. Root User - Grants full access when --root flag is used\n3. Config Rules - Applies permissions from datasette.yaml\n4. Default Settings - Enforces default_allow_sql and default view permissions\n\nIMPORTANT: These hooks return PermissionSQL objects that are combined using SQL\nUNION/INTERSECT operations. The order of evaluation is:\n  - restriction_sql fields are INTERSECTed (all must match)\n  - Regular sql fields are UNIONed and evaluated with cascading priority\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Optional\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\n\n# Re-export all hooks and public utilities\nfrom .restrictions import (\n    actor_restrictions_sql as actor_restrictions_sql,\n    restrictions_allow_action as restrictions_allow_action,\n    ActorRestrictions as ActorRestrictions,\n)\nfrom .root import root_user_permissions_sql as root_user_permissions_sql\nfrom .config import config_permissions_sql as config_permissions_sql\nfrom .defaults import (\n    default_allow_sql_check as default_allow_sql_check,\n    default_action_permissions_sql as default_action_permissions_sql,\n    DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,\n)\n\n\n@hookimpl\ndef skip_csrf(scope) -> Optional[bool]:\n    \"\"\"Skip CSRF check for JSON content-type requests.\"\"\"\n    if scope[\"type\"] == \"http\":\n        headers = scope.get(\"headers\") or {}\n        if dict(headers).get(b\"content-type\") == b\"application/json\":\n            return True\n    return None\n\n\n@hookimpl\ndef canned_queries(datasette: \"Datasette\", database: str, actor) -> dict:\n    \"\"\"Return canned queries defined in datasette.yaml configuration.\"\"\"\n    queries = (\n        ((datasette.config or {}).get(\"databases\") or {}).get(database) or {}\n    ).get(\"queries\") or {}\n    return queries\n"
  },
  {
    "path": "datasette/default_permissions/config.py",
    "content": "\"\"\"\nConfig-based permission handling for Datasette.\n\nApplies permission rules from datasette.yaml configuration.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\nfrom datasette.permissions import PermissionSQL\nfrom datasette.utils import actor_matches_allow\n\nfrom .helpers import PermissionRowCollector, get_action_name_variants\n\n\nclass ConfigPermissionProcessor:\n    \"\"\"\n    Processes permission rules from datasette.yaml configuration.\n\n    Configuration structure:\n\n    permissions:                    # Root-level permissions block\n      view-instance:\n        id: admin\n\n    databases:\n      mydb:\n        permissions:                # Database-level permissions\n          view-database:\n            id: admin\n        allow:                      # Database-level allow block (for view-*)\n          id: viewer\n        allow_sql:                  # execute-sql allow block\n          id: analyst\n        tables:\n          users:\n            permissions:            # Table-level permissions\n              view-table:\n                id: admin\n            allow:                  # Table-level allow block\n              id: viewer\n        queries:\n          my_query:\n            permissions:            # Query-level permissions\n              view-query:\n                id: admin\n            allow:                  # Query-level allow block\n              id: viewer\n    \"\"\"\n\n    def __init__(\n        self,\n        datasette: \"Datasette\",\n        actor: Optional[dict],\n        action: str,\n    ):\n        self.datasette = datasette\n        self.actor = actor\n        self.action = action\n        self.config = datasette.config or {}\n        self.collector = PermissionRowCollector(prefix=\"cfg\")\n\n        # Pre-compute action variants\n        self.action_checks = get_action_name_variants(datasette, action)\n        self.action_obj = datasette.actions.get(action)\n\n        # Parse restrictions if present\n        self.has_restrictions = actor and \"_r\" in actor if actor else False\n        self.restrictions = actor.get(\"_r\", {}) if actor else {}\n\n        # Pre-compute restriction info for efficiency\n        self.restricted_databases: Set[str] = set()\n        self.restricted_tables: Set[Tuple[str, str]] = set()\n\n        if self.has_restrictions:\n            self.restricted_databases = {\n                db_name\n                for db_name, db_actions in (self.restrictions.get(\"d\") or {}).items()\n                if self.action_checks.intersection(db_actions)\n            }\n            self.restricted_tables = {\n                (db_name, table_name)\n                for db_name, tables in (self.restrictions.get(\"r\") or {}).items()\n                for table_name, table_actions in tables.items()\n                if self.action_checks.intersection(table_actions)\n            }\n            # Tables implicitly reference their parent databases\n            self.restricted_databases.update(db for db, _ in self.restricted_tables)\n\n    def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:\n        \"\"\"Evaluate an allow block against the current actor.\"\"\"\n        if allow_block is None:\n            return None\n        return actor_matches_allow(self.actor, allow_block)\n\n    def is_in_restriction_allowlist(\n        self,\n        parent: Optional[str],\n        child: Optional[str],\n    ) -> bool:\n        \"\"\"Check if resource is allowed by actor restrictions.\"\"\"\n        if not self.has_restrictions:\n            return True  # No restrictions, all resources allowed\n\n        # Check global allowlist\n        if self.action_checks.intersection(self.restrictions.get(\"a\", [])):\n            return True\n\n        # Check database-level allowlist\n        if parent and self.action_checks.intersection(\n            self.restrictions.get(\"d\", {}).get(parent, [])\n        ):\n            return True\n\n        # Check table-level allowlist\n        if parent:\n            table_restrictions = (self.restrictions.get(\"r\", {}) or {}).get(parent, {})\n            if child:\n                table_actions = table_restrictions.get(child, [])\n                if self.action_checks.intersection(table_actions):\n                    return True\n            else:\n                # Parent query should proceed if any child in this database is allowlisted\n                for table_actions in table_restrictions.values():\n                    if self.action_checks.intersection(table_actions):\n                        return True\n\n        # Parent/child both None: include if any restrictions exist for this action\n        if parent is None and child is None:\n            if self.action_checks.intersection(self.restrictions.get(\"a\", [])):\n                return True\n            if self.restricted_databases:\n                return True\n            if self.restricted_tables:\n                return True\n\n        return False\n\n    def add_permissions_rule(\n        self,\n        parent: Optional[str],\n        child: Optional[str],\n        permissions_block: Optional[dict],\n        scope_desc: str,\n    ) -> None:\n        \"\"\"Add a rule from a permissions:{action} block.\"\"\"\n        if permissions_block is None:\n            return\n\n        action_allow_block = permissions_block.get(self.action)\n        result = self.evaluate_allow_block(action_allow_block)\n\n        self.collector.add(\n            parent=parent,\n            child=child,\n            allow=result,\n            reason=f\"config {'allow' if result else 'deny'} {scope_desc}\",\n            if_not_none=True,\n        )\n\n    def add_allow_block_rule(\n        self,\n        parent: Optional[str],\n        child: Optional[str],\n        allow_block: Any,\n        scope_desc: str,\n    ) -> None:\n        \"\"\"\n        Add rules from an allow:{} block.\n\n        For allow blocks, if the block exists but doesn't match the actor,\n        this is treated as a deny. We also handle the restriction-gate logic.\n        \"\"\"\n        if allow_block is None:\n            return\n\n        # Skip if resource is not in restriction allowlist\n        if not self.is_in_restriction_allowlist(parent, child):\n            return\n\n        result = self.evaluate_allow_block(allow_block)\n        bool_result = bool(result)\n\n        self.collector.add(\n            parent,\n            child,\n            bool_result,\n            f\"config {'allow' if result else 'deny'} {scope_desc}\",\n        )\n\n        # Handle restriction-gate: add explicit denies for restricted resources\n        self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)\n\n    def _add_restriction_gate_denies(\n        self,\n        parent: Optional[str],\n        child: Optional[str],\n        is_allowed: bool,\n        scope_desc: str,\n    ) -> None:\n        \"\"\"\n        When a config rule denies at a higher level, add explicit denies\n        for restricted resources to prevent child-level allows from\n        incorrectly granting access.\n        \"\"\"\n        if is_allowed or child is not None or not self.has_restrictions:\n            return\n\n        if not self.action_obj:\n            return\n\n        reason = f\"config deny {scope_desc} (restriction gate)\"\n\n        if parent is None:\n            # Root-level deny: add denies for all restricted resources\n            if self.action_obj.takes_parent:\n                for db_name in self.restricted_databases:\n                    self.collector.add(db_name, None, False, reason)\n            if self.action_obj.takes_child:\n                for db_name, table_name in self.restricted_tables:\n                    self.collector.add(db_name, table_name, False, reason)\n        else:\n            # Database-level deny: add denies for tables in that database\n            if self.action_obj.takes_child:\n                for db_name, table_name in self.restricted_tables:\n                    if db_name == parent:\n                        self.collector.add(db_name, table_name, False, reason)\n\n    def process(self) -> Optional[PermissionSQL]:\n        \"\"\"Process all config rules and return combined PermissionSQL.\"\"\"\n        self._process_root_permissions()\n        self._process_databases()\n        self._process_root_allow_blocks()\n\n        return self.collector.to_permission_sql()\n\n    def _process_root_permissions(self) -> None:\n        \"\"\"Process root-level permissions block.\"\"\"\n        root_perms = self.config.get(\"permissions\") or {}\n        self.add_permissions_rule(\n            None,\n            None,\n            root_perms,\n            f\"permissions for {self.action}\",\n        )\n\n    def _process_databases(self) -> None:\n        \"\"\"Process database-level and nested configurations.\"\"\"\n        databases = self.config.get(\"databases\") or {}\n\n        for db_name, db_config in databases.items():\n            self._process_database(db_name, db_config or {})\n\n    def _process_database(self, db_name: str, db_config: dict) -> None:\n        \"\"\"Process a single database's configuration.\"\"\"\n        # Database-level permissions block\n        db_perms = db_config.get(\"permissions\") or {}\n        self.add_permissions_rule(\n            db_name,\n            None,\n            db_perms,\n            f\"permissions for {self.action} on {db_name}\",\n        )\n\n        # Process tables\n        for table_name, table_config in (db_config.get(\"tables\") or {}).items():\n            self._process_table(db_name, table_name, table_config or {})\n\n        # Process queries\n        for query_name, query_config in (db_config.get(\"queries\") or {}).items():\n            self._process_query(db_name, query_name, query_config)\n\n        # Database-level allow blocks\n        self._process_database_allow_blocks(db_name, db_config)\n\n    def _process_table(\n        self,\n        db_name: str,\n        table_name: str,\n        table_config: dict,\n    ) -> None:\n        \"\"\"Process a single table's configuration.\"\"\"\n        # Table-level permissions block\n        table_perms = table_config.get(\"permissions\") or {}\n        self.add_permissions_rule(\n            db_name,\n            table_name,\n            table_perms,\n            f\"permissions for {self.action} on {db_name}/{table_name}\",\n        )\n\n        # Table-level allow block (for view-table)\n        if self.action == \"view-table\":\n            self.add_allow_block_rule(\n                db_name,\n                table_name,\n                table_config.get(\"allow\"),\n                f\"allow for {self.action} on {db_name}/{table_name}\",\n            )\n\n    def _process_query(\n        self,\n        db_name: str,\n        query_name: str,\n        query_config: Any,\n    ) -> None:\n        \"\"\"Process a single query's configuration.\"\"\"\n        # Query config can be a string (just SQL) or dict\n        if not isinstance(query_config, dict):\n            return\n\n        # Query-level permissions block\n        query_perms = query_config.get(\"permissions\") or {}\n        self.add_permissions_rule(\n            db_name,\n            query_name,\n            query_perms,\n            f\"permissions for {self.action} on {db_name}/{query_name}\",\n        )\n\n        # Query-level allow block (for view-query)\n        if self.action == \"view-query\":\n            self.add_allow_block_rule(\n                db_name,\n                query_name,\n                query_config.get(\"allow\"),\n                f\"allow for {self.action} on {db_name}/{query_name}\",\n            )\n\n    def _process_database_allow_blocks(\n        self,\n        db_name: str,\n        db_config: dict,\n    ) -> None:\n        \"\"\"Process database-level allow/allow_sql blocks.\"\"\"\n        # view-database allow block\n        if self.action == \"view-database\":\n            self.add_allow_block_rule(\n                db_name,\n                None,\n                db_config.get(\"allow\"),\n                f\"allow for {self.action} on {db_name}\",\n            )\n\n        # execute-sql allow_sql block\n        if self.action == \"execute-sql\":\n            self.add_allow_block_rule(\n                db_name,\n                None,\n                db_config.get(\"allow_sql\"),\n                f\"allow_sql for {db_name}\",\n            )\n\n        # view-table uses database-level allow for inheritance\n        if self.action == \"view-table\":\n            self.add_allow_block_rule(\n                db_name,\n                None,\n                db_config.get(\"allow\"),\n                f\"allow for {self.action} on {db_name}\",\n            )\n\n        # view-query uses database-level allow for inheritance\n        if self.action == \"view-query\":\n            self.add_allow_block_rule(\n                db_name,\n                None,\n                db_config.get(\"allow\"),\n                f\"allow for {self.action} on {db_name}\",\n            )\n\n    def _process_root_allow_blocks(self) -> None:\n        \"\"\"Process root-level allow/allow_sql blocks.\"\"\"\n        root_allow = self.config.get(\"allow\")\n\n        if self.action == \"view-instance\":\n            self.add_allow_block_rule(\n                None,\n                None,\n                root_allow,\n                \"allow for view-instance\",\n            )\n\n        if self.action == \"view-database\":\n            self.add_allow_block_rule(\n                None,\n                None,\n                root_allow,\n                \"allow for view-database\",\n            )\n\n        if self.action == \"view-table\":\n            self.add_allow_block_rule(\n                None,\n                None,\n                root_allow,\n                \"allow for view-table\",\n            )\n\n        if self.action == \"view-query\":\n            self.add_allow_block_rule(\n                None,\n                None,\n                root_allow,\n                \"allow for view-query\",\n            )\n\n        if self.action == \"execute-sql\":\n            self.add_allow_block_rule(\n                None,\n                None,\n                self.config.get(\"allow_sql\"),\n                \"allow_sql\",\n            )\n\n\n@hookimpl(specname=\"permission_resources_sql\")\nasync def config_permissions_sql(\n    datasette: \"Datasette\",\n    actor: Optional[dict],\n    action: str,\n) -> Optional[List[PermissionSQL]]:\n    \"\"\"\n    Apply permission rules from datasette.yaml configuration.\n\n    This processes:\n    - permissions: blocks at root, database, table, and query levels\n    - allow: blocks for view-* actions\n    - allow_sql: blocks for execute-sql action\n    \"\"\"\n    processor = ConfigPermissionProcessor(datasette, actor, action)\n    result = processor.process()\n\n    if result is None:\n        return []\n\n    return [result]\n"
  },
  {
    "path": "datasette/default_permissions/defaults.py",
    "content": "\"\"\"\nDefault permission settings for Datasette.\n\nProvides default allow rules for standard view/execute actions.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Optional\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\nfrom datasette.permissions import PermissionSQL\n\n# Actions that are allowed by default (unless --default-deny is used)\nDEFAULT_ALLOW_ACTIONS = frozenset(\n    {\n        \"view-instance\",\n        \"view-database\",\n        \"view-database-download\",\n        \"view-table\",\n        \"view-query\",\n        \"execute-sql\",\n    }\n)\n\n\n@hookimpl(specname=\"permission_resources_sql\")\nasync def default_allow_sql_check(\n    datasette: \"Datasette\",\n    actor: Optional[dict],\n    action: str,\n) -> Optional[PermissionSQL]:\n    \"\"\"\n    Enforce the default_allow_sql setting.\n\n    When default_allow_sql is false (the default), execute-sql is denied\n    unless explicitly allowed by config or other rules.\n    \"\"\"\n    if action == \"execute-sql\":\n        if not datasette.setting(\"default_allow_sql\"):\n            return PermissionSQL.deny(reason=\"default_allow_sql is false\")\n\n    return None\n\n\n@hookimpl(specname=\"permission_resources_sql\")\nasync def default_action_permissions_sql(\n    datasette: \"Datasette\",\n    actor: Optional[dict],\n    action: str,\n) -> Optional[PermissionSQL]:\n    \"\"\"\n    Provide default allow rules for standard view/execute actions.\n\n    These defaults are skipped when datasette is started with --default-deny.\n    The restriction_sql mechanism (from actor_restrictions_sql) will still\n    filter these results if the actor has restrictions.\n    \"\"\"\n    if datasette.default_deny:\n        return None\n\n    if action in DEFAULT_ALLOW_ACTIONS:\n        reason = f\"default allow for {action}\".replace(\"'\", \"''\")\n        return PermissionSQL.allow(reason=reason)\n\n    return None\n"
  },
  {
    "path": "datasette/default_permissions/helpers.py",
    "content": "\"\"\"\nShared helper utilities for default permission implementations.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, List, Optional, Set\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette.permissions import PermissionSQL\n\n\ndef get_action_name_variants(datasette: \"Datasette\", action: str) -> Set[str]:\n    \"\"\"\n    Get all name variants for an action (full name and abbreviation).\n\n    Example:\n        get_action_name_variants(ds, \"view-table\") -> {\"view-table\", \"vt\"}\n    \"\"\"\n    variants = {action}\n    action_obj = datasette.actions.get(action)\n    if action_obj and action_obj.abbr:\n        variants.add(action_obj.abbr)\n    return variants\n\n\ndef action_in_list(datasette: \"Datasette\", action: str, action_list: list) -> bool:\n    \"\"\"Check if an action (or its abbreviation) is in a list.\"\"\"\n    return bool(get_action_name_variants(datasette, action).intersection(action_list))\n\n\n@dataclass\nclass PermissionRow:\n    \"\"\"A single permission rule row.\"\"\"\n\n    parent: Optional[str]\n    child: Optional[str]\n    allow: bool\n    reason: str\n\n\nclass PermissionRowCollector:\n    \"\"\"Collects permission rows and converts them to PermissionSQL.\"\"\"\n\n    def __init__(self, prefix: str = \"row\"):\n        self.rows: List[PermissionRow] = []\n        self.prefix = prefix\n\n    def add(\n        self,\n        parent: Optional[str],\n        child: Optional[str],\n        allow: Optional[bool],\n        reason: str,\n        if_not_none: bool = False,\n    ) -> None:\n        \"\"\"Add a permission row. If if_not_none=True, only add if allow is not None.\"\"\"\n        if if_not_none and allow is None:\n            return\n        self.rows.append(PermissionRow(parent, child, allow, reason))\n\n    def to_permission_sql(self) -> Optional[PermissionSQL]:\n        \"\"\"Convert collected rows to a PermissionSQL object.\"\"\"\n        if not self.rows:\n            return None\n\n        parts = []\n        params = {}\n\n        for idx, row in enumerate(self.rows):\n            key = f\"{self.prefix}_{idx}\"\n            parts.append(\n                f\"SELECT :{key}_parent AS parent, :{key}_child AS child, \"\n                f\":{key}_allow AS allow, :{key}_reason AS reason\"\n            )\n            params[f\"{key}_parent\"] = row.parent\n            params[f\"{key}_child\"] = row.child\n            params[f\"{key}_allow\"] = 1 if row.allow else 0\n            params[f\"{key}_reason\"] = row.reason\n\n        sql = \"\\nUNION ALL\\n\".join(parts)\n        return PermissionSQL(sql=sql, params=params)\n"
  },
  {
    "path": "datasette/default_permissions/restrictions.py",
    "content": "\"\"\"\nActor restriction handling for Datasette permissions.\n\nThis module handles the _r (restrictions) key in actor dictionaries, which\ncontains allowlists of resources the actor can access.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, List, Optional, Set, Tuple\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\nfrom datasette.permissions import PermissionSQL\n\nfrom .helpers import action_in_list, get_action_name_variants\n\n\n@dataclass\nclass ActorRestrictions:\n    \"\"\"Parsed actor restrictions from the _r key.\"\"\"\n\n    global_actions: List[str]  # _r.a - globally allowed actions\n    database_actions: dict  # _r.d - {db_name: [actions]}\n    table_actions: dict  # _r.r - {db_name: {table: [actions]}}\n\n    @classmethod\n    def from_actor(cls, actor: Optional[dict]) -> Optional[\"ActorRestrictions\"]:\n        \"\"\"Parse restrictions from actor dict. Returns None if no restrictions.\"\"\"\n        if not actor:\n            return None\n        assert isinstance(actor, dict), \"actor must be a dictionary\"\n\n        restrictions = actor.get(\"_r\")\n        if restrictions is None:\n            return None\n\n        return cls(\n            global_actions=restrictions.get(\"a\", []),\n            database_actions=restrictions.get(\"d\", {}),\n            table_actions=restrictions.get(\"r\", {}),\n        )\n\n    def is_action_globally_allowed(self, datasette: \"Datasette\", action: str) -> bool:\n        \"\"\"Check if action is in the global allowlist.\"\"\"\n        return action_in_list(datasette, action, self.global_actions)\n\n    def get_allowed_databases(self, datasette: \"Datasette\", action: str) -> Set[str]:\n        \"\"\"Get database names where this action is allowed.\"\"\"\n        allowed = set()\n        for db_name, db_actions in self.database_actions.items():\n            if action_in_list(datasette, action, db_actions):\n                allowed.add(db_name)\n        return allowed\n\n    def get_allowed_tables(\n        self, datasette: \"Datasette\", action: str\n    ) -> Set[Tuple[str, str]]:\n        \"\"\"Get (database, table) pairs where this action is allowed.\"\"\"\n        allowed = set()\n        for db_name, tables in self.table_actions.items():\n            for table_name, table_actions in tables.items():\n                if action_in_list(datasette, action, table_actions):\n                    allowed.add((db_name, table_name))\n        return allowed\n\n\n@hookimpl(specname=\"permission_resources_sql\")\nasync def actor_restrictions_sql(\n    datasette: \"Datasette\",\n    actor: Optional[dict],\n    action: str,\n) -> Optional[List[PermissionSQL]]:\n    \"\"\"\n    Handle actor restriction-based permission rules.\n\n    When an actor has an \"_r\" key, it contains an allowlist of resources they\n    can access. This function returns restriction_sql that filters the final\n    results to only include resources in that allowlist.\n\n    The _r structure:\n    {\n        \"a\": [\"vi\", \"pd\"],           # Global actions allowed\n        \"d\": {\"mydb\": [\"vt\", \"es\"]}, # Database-level actions\n        \"r\": {\"mydb\": {\"users\": [\"vt\"]}}  # Table-level actions\n    }\n    \"\"\"\n    if not actor:\n        return None\n\n    restrictions = ActorRestrictions.from_actor(actor)\n\n    if restrictions is None:\n        # No restrictions - all resources allowed\n        return []\n\n    # If globally allowed, no filtering needed\n    if restrictions.is_action_globally_allowed(datasette, action):\n        return []\n\n    # Build restriction SQL\n    allowed_dbs = restrictions.get_allowed_databases(datasette, action)\n    allowed_tables = restrictions.get_allowed_tables(datasette, action)\n\n    # If nothing is allowed for this action, return empty-set restriction\n    if not allowed_dbs and not allowed_tables:\n        return [\n            PermissionSQL(\n                params={\"deny\": f\"actor restrictions: {action} not in allowlist\"},\n                restriction_sql=\"SELECT NULL AS parent, NULL AS child WHERE 0\",\n            )\n        ]\n\n    # Build UNION of allowed resources\n    selects = []\n    params = {}\n    counter = 0\n\n    # Database-level entries (parent, NULL) - allows all children\n    for db_name in allowed_dbs:\n        key = f\"restr_{counter}\"\n        counter += 1\n        selects.append(f\"SELECT :{key}_parent AS parent, NULL AS child\")\n        params[f\"{key}_parent\"] = db_name\n\n    # Table-level entries (parent, child)\n    for db_name, table_name in allowed_tables:\n        key = f\"restr_{counter}\"\n        counter += 1\n        selects.append(f\"SELECT :{key}_parent AS parent, :{key}_child AS child\")\n        params[f\"{key}_parent\"] = db_name\n        params[f\"{key}_child\"] = table_name\n\n    restriction_sql = \"\\nUNION ALL\\n\".join(selects)\n\n    return [PermissionSQL(params=params, restriction_sql=restriction_sql)]\n\n\ndef restrictions_allow_action(\n    datasette: \"Datasette\",\n    restrictions: dict,\n    action: str,\n    resource: Optional[str | Tuple[str, str]],\n) -> bool:\n    \"\"\"\n    Check if restrictions allow the requested action on the requested resource.\n\n    This is a synchronous utility function for use by other code that needs\n    to quickly check restriction allowlists.\n\n    Args:\n        datasette: The Datasette instance\n        restrictions: The _r dict from an actor\n        action: The action name to check\n        resource: None for global, str for database, (db, table) tuple for table\n\n    Returns:\n        True if allowed, False if denied\n    \"\"\"\n    # Does this action have an abbreviation?\n    to_check = get_action_name_variants(datasette, action)\n\n    # Check global level (any resource)\n    all_allowed = restrictions.get(\"a\")\n    if all_allowed is not None:\n        assert isinstance(all_allowed, list)\n        if to_check.intersection(all_allowed):\n            return True\n\n    # Check database level\n    if resource:\n        if isinstance(resource, str):\n            database_name = resource\n        else:\n            database_name = resource[0]\n        database_allowed = restrictions.get(\"d\", {}).get(database_name)\n        if database_allowed is not None:\n            assert isinstance(database_allowed, list)\n            if to_check.intersection(database_allowed):\n                return True\n\n    # Check table/resource level\n    if resource is not None and not isinstance(resource, str) and len(resource) == 2:\n        database, table = resource\n        table_allowed = restrictions.get(\"r\", {}).get(database, {}).get(table)\n        if table_allowed is not None:\n            assert isinstance(table_allowed, list)\n            if to_check.intersection(table_allowed):\n                return True\n\n    # This action is not explicitly allowed, so reject it\n    return False\n"
  },
  {
    "path": "datasette/default_permissions/root.py",
    "content": "\"\"\"\nRoot user permission handling for Datasette.\n\nGrants full permissions to the root user when --root flag is used.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Optional\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\nfrom datasette.permissions import PermissionSQL\n\n\n@hookimpl(specname=\"permission_resources_sql\")\nasync def root_user_permissions_sql(\n    datasette: \"Datasette\",\n    actor: Optional[dict],\n) -> Optional[PermissionSQL]:\n    \"\"\"\n    Grant root user full permissions when --root flag is used.\n    \"\"\"\n    if not datasette.root_enabled:\n        return None\n    if actor is not None and actor.get(\"id\") == \"root\":\n        return PermissionSQL.allow(reason=\"root user\")\n"
  },
  {
    "path": "datasette/default_permissions/tokens.py",
    "content": "\"\"\"\nToken authentication for Datasette.\n\nRegisters the default SignedTokenHandler and delegates token verification\nto datasette.verify_token() so all registered handlers are tried.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Optional\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\nfrom datasette import hookimpl\nfrom datasette.tokens import SignedTokenHandler\n\n\n@hookimpl\ndef register_token_handler(datasette: \"Datasette\"):\n    \"\"\"Register the default signed token handler.\"\"\"\n    return SignedTokenHandler()\n\n\n@hookimpl(specname=\"actor_from_request\")\nasync def actor_from_signed_api_token(\n    datasette: \"Datasette\", request\n) -> Optional[dict]:\n    \"\"\"\n    Authenticate requests using API tokens by delegating to all registered\n    token handlers via datasette.verify_token().\n    \"\"\"\n    authorization = request.headers.get(\"authorization\")\n    if not authorization:\n        return None\n    if not authorization.startswith(\"Bearer \"):\n        return None\n\n    token = authorization[len(\"Bearer \") :]\n    return await datasette.verify_token(token)\n"
  },
  {
    "path": "datasette/events.py",
    "content": "from abc import ABC, abstractproperty\nfrom dataclasses import asdict, dataclass, field\nfrom datasette.hookspecs import hookimpl\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass Event(ABC):\n    @abstractproperty\n    def name(self):\n        pass\n\n    created: datetime = field(\n        init=False, default_factory=lambda: datetime.now(timezone.utc)\n    )\n    actor: dict | None\n\n    def properties(self):\n        properties = asdict(self)\n        properties.pop(\"actor\", None)\n        properties.pop(\"created\", None)\n        return properties\n\n\n@dataclass\nclass LoginEvent(Event):\n    \"\"\"\n    Event name: ``login``\n\n    A user (represented by ``event.actor``) has logged in.\n    \"\"\"\n\n    name = \"login\"\n\n\n@dataclass\nclass LogoutEvent(Event):\n    \"\"\"\n    Event name: ``logout``\n\n    A user (represented by ``event.actor``) has logged out.\n    \"\"\"\n\n    name = \"logout\"\n\n\n@dataclass\nclass CreateTokenEvent(Event):\n    \"\"\"\n    Event name: ``create-token``\n\n    A user created an API token.\n\n    :ivar expires_after: Number of seconds after which this token will expire.\n    :type expires_after: int or None\n    :ivar restrict_all: Restricted permissions for this token.\n    :type restrict_all: list\n    :ivar restrict_database: Restricted database permissions for this token.\n    :type restrict_database: dict\n    :ivar restrict_resource: Restricted resource permissions for this token.\n    :type restrict_resource: dict\n    \"\"\"\n\n    name = \"create-token\"\n    expires_after: int | None\n    restrict_all: list\n    restrict_database: dict\n    restrict_resource: dict\n\n\n@dataclass\nclass CreateTableEvent(Event):\n    \"\"\"\n    Event name: ``create-table``\n\n    A new table has been created in the database.\n\n    :ivar database: The name of the database where the table was created.\n    :type database: str\n    :ivar table: The name of the table that was created\n    :type table: str\n    :ivar schema: The SQL schema definition for the new table.\n    :type schema: str\n    \"\"\"\n\n    name = \"create-table\"\n    database: str\n    table: str\n    schema: str\n\n\n@dataclass\nclass DropTableEvent(Event):\n    \"\"\"\n    Event name: ``drop-table``\n\n    A table has been dropped from the database.\n\n    :ivar database: The name of the database where the table was dropped.\n    :type database: str\n    :ivar table: The name of the table that was dropped\n    :type table: str\n    \"\"\"\n\n    name = \"drop-table\"\n    database: str\n    table: str\n\n\n@dataclass\nclass AlterTableEvent(Event):\n    \"\"\"\n    Event name: ``alter-table``\n\n    A table has been altered.\n\n    :ivar database: The name of the database where the table was altered\n    :type database: str\n    :ivar table: The name of the table that was altered\n    :type table: str\n    :ivar before_schema: The table's SQL schema before the alteration\n    :type before_schema: str\n    :ivar after_schema: The table's SQL schema after the alteration\n    :type after_schema: str\n    \"\"\"\n\n    name = \"alter-table\"\n    database: str\n    table: str\n    before_schema: str\n    after_schema: str\n\n\n@dataclass\nclass InsertRowsEvent(Event):\n    \"\"\"\n    Event name: ``insert-rows``\n\n    Rows were inserted into a table.\n\n    :ivar database: The name of the database where the rows were inserted.\n    :type database: str\n    :ivar table: The name of the table where the rows were inserted.\n    :type table: str\n    :ivar num_rows: The number of rows that were requested to be inserted.\n    :type num_rows: int\n    :ivar ignore: Was ignore set?\n    :type ignore: bool\n    :ivar replace: Was replace set?\n    :type replace: bool\n    \"\"\"\n\n    name = \"insert-rows\"\n    database: str\n    table: str\n    num_rows: int\n    ignore: bool\n    replace: bool\n\n\n@dataclass\nclass UpsertRowsEvent(Event):\n    \"\"\"\n    Event name: ``upsert-rows``\n\n    Rows were upserted into a table.\n\n    :ivar database: The name of the database where the rows were inserted.\n    :type database: str\n    :ivar table: The name of the table where the rows were inserted.\n    :type table: str\n    :ivar num_rows: The number of rows that were requested to be inserted.\n    :type num_rows: int\n    \"\"\"\n\n    name = \"upsert-rows\"\n    database: str\n    table: str\n    num_rows: int\n\n\n@dataclass\nclass UpdateRowEvent(Event):\n    \"\"\"\n    Event name: ``update-row``\n\n    A row was updated in a table.\n\n    :ivar database: The name of the database where the row was updated.\n    :type database: str\n    :ivar table: The name of the table where the row was updated.\n    :type table: str\n    :ivar pks: The primary key values of the updated row.\n    \"\"\"\n\n    name = \"update-row\"\n    database: str\n    table: str\n    pks: list\n\n\n@dataclass\nclass DeleteRowEvent(Event):\n    \"\"\"\n    Event name: ``delete-row``\n\n    A row was deleted from a table.\n\n    :ivar database: The name of the database where the row was deleted.\n    :type database: str\n    :ivar table: The name of the table where the row was deleted.\n    :type table: str\n    :ivar pks: The primary key values of the deleted row.\n    \"\"\"\n\n    name = \"delete-row\"\n    database: str\n    table: str\n    pks: list\n\n\n@hookimpl\ndef register_events():\n    return [\n        LoginEvent,\n        LogoutEvent,\n        CreateTableEvent,\n        CreateTokenEvent,\n        AlterTableEvent,\n        DropTableEvent,\n        InsertRowsEvent,\n        UpsertRowsEvent,\n        UpdateRowEvent,\n        DeleteRowEvent,\n    ]\n"
  },
  {
    "path": "datasette/facets.py",
    "content": "import json\nimport urllib\nfrom datasette import hookimpl\nfrom datasette.database import QueryInterrupted\nfrom datasette.utils import (\n    escape_sqlite,\n    path_with_added_args,\n    path_with_removed_args,\n    detect_json1,\n    sqlite3,\n)\n\n\ndef load_facet_configs(request, table_config):\n    # Given a request and the configuration for a table, return\n    # a dictionary of selected facets, their lists of configs and for each\n    # config whether it came from the request or the metadata.\n    #\n    #   return {type: [\n    #       {\"source\": \"metadata\", \"config\": config1},\n    #       {\"source\": \"request\", \"config\": config2}]}\n    facet_configs = {}\n    table_config = table_config or {}\n    table_facet_configs = table_config.get(\"facets\", [])\n    for facet_config in table_facet_configs:\n        if isinstance(facet_config, str):\n            type = \"column\"\n            facet_config = {\"simple\": facet_config}\n        else:\n            assert (\n                len(facet_config.values()) == 1\n            ), \"Metadata config dicts should be {type: config}\"\n            type, facet_config = list(facet_config.items())[0]\n            if isinstance(facet_config, str):\n                facet_config = {\"simple\": facet_config}\n        facet_configs.setdefault(type, []).append(\n            {\"source\": \"metadata\", \"config\": facet_config}\n        )\n    qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)\n    for key, values in qs_pairs.items():\n        if key.startswith(\"_facet\"):\n            # Figure out the facet type\n            if key == \"_facet\":\n                type = \"column\"\n            elif key.startswith(\"_facet_\"):\n                type = key[len(\"_facet_\") :]\n            for value in values:\n                # The value is the facet_config - either JSON or not\n                facet_config = (\n                    json.loads(value) if value.startswith(\"{\") else {\"simple\": value}\n                )\n                facet_configs.setdefault(type, []).append(\n                    {\"source\": \"request\", \"config\": facet_config}\n                )\n    return facet_configs\n\n\n@hookimpl\ndef register_facet_classes():\n    classes = [ColumnFacet, DateFacet]\n    if detect_json1():\n        classes.append(ArrayFacet)\n    return classes\n\n\nclass Facet:\n    type = None\n    # How many rows to consider when suggesting facets:\n    suggest_consider = 1000\n\n    def __init__(\n        self,\n        ds,\n        request,\n        database,\n        sql=None,\n        table=None,\n        params=None,\n        table_config=None,\n        row_count=None,\n    ):\n        assert table or sql, \"Must provide either table= or sql=\"\n        self.ds = ds\n        self.request = request\n        self.database = database\n        # For foreign key expansion. Can be None for e.g. canned SQL queries:\n        self.table = table\n        self.sql = sql or f\"select * from [{table}]\"\n        self.params = params or []\n        self.table_config = table_config\n        # row_count can be None, in which case we calculate it ourselves:\n        self.row_count = row_count\n\n    def get_configs(self):\n        configs = load_facet_configs(self.request, self.table_config)\n        return configs.get(self.type) or []\n\n    def get_querystring_pairs(self):\n        # ?_foo=bar&_foo=2&empty= becomes:\n        # [('_foo', 'bar'), ('_foo', '2'), ('empty', '')]\n        return urllib.parse.parse_qsl(self.request.query_string, keep_blank_values=True)\n\n    def get_facet_size(self):\n        facet_size = self.ds.setting(\"default_facet_size\")\n        max_returned_rows = self.ds.setting(\"max_returned_rows\")\n        table_facet_size = None\n        if self.table:\n            config_facet_size = (\n                self.ds.config.get(\"databases\", {})\n                .get(self.database, {})\n                .get(\"tables\", {})\n                .get(self.table, {})\n                .get(\"facet_size\")\n            )\n            if config_facet_size:\n                table_facet_size = config_facet_size\n        custom_facet_size = self.request.args.get(\"_facet_size\")\n        if custom_facet_size:\n            if custom_facet_size == \"max\":\n                facet_size = max_returned_rows\n            elif custom_facet_size.isdigit():\n                facet_size = int(custom_facet_size)\n            else:\n                # Invalid value, ignore it\n                custom_facet_size = None\n        if table_facet_size and not custom_facet_size:\n            if table_facet_size == \"max\":\n                facet_size = max_returned_rows\n            else:\n                facet_size = table_facet_size\n        return min(facet_size, max_returned_rows)\n\n    async def suggest(self):\n        return []\n\n    async def facet_results(self):\n        # returns ([results], [timed_out])\n        # TODO: Include \"hideable\" with each one somehow, which indicates if it was\n        # defined in metadata (in which case you cannot turn it off)\n        raise NotImplementedError\n\n    async def get_columns(self, sql, params=None):\n        # Detect column names using the \"limit 0\" trick\n        return (\n            await self.ds.execute(\n                self.database, f\"select * from ({sql}) limit 0\", params or []\n            )\n        ).columns\n\n\nclass ColumnFacet(Facet):\n    type = \"column\"\n\n    async def suggest(self):\n        row_count = await self.get_row_count()\n        columns = await self.get_columns(self.sql, self.params)\n        facet_size = self.get_facet_size()\n        suggested_facets = []\n        already_enabled = [c[\"config\"][\"simple\"] for c in self.get_configs()]\n        for column in columns:\n            if column in already_enabled:\n                continue\n            suggested_facet_sql = \"\"\"\n                with limited as (select * from ({sql}) limit {suggest_consider})\n                select {column} as value, count(*) as n from limited\n                where value is not null\n                group by value\n                limit {limit}\n            \"\"\".format(\n                column=escape_sqlite(column),\n                sql=self.sql,\n                limit=facet_size + 1,\n                suggest_consider=self.suggest_consider,\n            )\n            distinct_values = None\n            try:\n                distinct_values = await self.ds.execute(\n                    self.database,\n                    suggested_facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_suggest_time_limit_ms\"),\n                )\n                num_distinct_values = len(distinct_values)\n                if (\n                    1 < num_distinct_values < row_count\n                    and num_distinct_values <= facet_size\n                    # And at least one has n > 1\n                    and any(r[\"n\"] > 1 for r in distinct_values)\n                ):\n                    suggested_facets.append(\n                        {\n                            \"name\": column,\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request,\n                                self.ds.urls.path(\n                                    path_with_added_args(\n                                        self.request, {\"_facet\": column}\n                                    )\n                                ),\n                            ),\n                        }\n                    )\n            except QueryInterrupted:\n                continue\n        return suggested_facets\n\n    async def get_row_count(self):\n        if self.row_count is None:\n            self.row_count = (\n                await self.ds.execute(\n                    self.database,\n                    f\"select count(*) from (select * from ({self.sql}) limit {self.suggest_consider})\",\n                    self.params,\n                )\n            ).rows[0][0]\n        return self.row_count\n\n    async def facet_results(self):\n        facet_results = []\n        facets_timed_out = []\n\n        qs_pairs = self.get_querystring_pairs()\n\n        facet_size = self.get_facet_size()\n        for source_and_config in self.get_configs():\n            config = source_and_config[\"config\"]\n            source = source_and_config[\"source\"]\n            column = config.get(\"column\") or config[\"simple\"]\n            facet_sql = \"\"\"\n                select {col} as value, count(*) as count from (\n                    {sql}\n                )\n                where {col} is not null\n                group by {col} order by count desc, value limit {limit}\n            \"\"\".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)\n            try:\n                facet_rows_results = await self.ds.execute(\n                    self.database,\n                    facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_time_limit_ms\"),\n                )\n                facet_results_values = []\n                facet_results.append(\n                    {\n                        \"name\": column,\n                        \"type\": self.type,\n                        \"hideable\": source != \"metadata\",\n                        \"toggle_url\": self.ds.urls.path(\n                            path_with_removed_args(self.request, {\"_facet\": column})\n                        ),\n                        \"results\": facet_results_values,\n                        \"truncated\": len(facet_rows_results) > facet_size,\n                    }\n                )\n                facet_rows = facet_rows_results.rows[:facet_size]\n                if self.table:\n                    # Attempt to expand foreign keys into labels\n                    values = [row[\"value\"] for row in facet_rows]\n                    expanded = await self.ds.expand_foreign_keys(\n                        self.request.actor, self.database, self.table, column, values\n                    )\n                else:\n                    expanded = {}\n                for row in facet_rows:\n                    column_qs = column\n                    if column.startswith(\"_\"):\n                        column_qs = \"{}__exact\".format(column)\n                    selected = (column_qs, str(row[\"value\"])) in qs_pairs\n                    if selected:\n                        toggle_path = path_with_removed_args(\n                            self.request, {column_qs: str(row[\"value\"])}\n                        )\n                    else:\n                        toggle_path = path_with_added_args(\n                            self.request, {column_qs: row[\"value\"]}\n                        )\n                    facet_results_values.append(\n                        {\n                            \"value\": row[\"value\"],\n                            \"label\": expanded.get((column, row[\"value\"]), row[\"value\"]),\n                            \"count\": row[\"count\"],\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request, self.ds.urls.path(toggle_path)\n                            ),\n                            \"selected\": selected,\n                        }\n                    )\n            except QueryInterrupted:\n                facets_timed_out.append(column)\n\n        return facet_results, facets_timed_out\n\n\nclass ArrayFacet(Facet):\n    type = \"array\"\n\n    def _is_json_array_of_strings(self, json_string):\n        try:\n            array = json.loads(json_string)\n        except ValueError:\n            return False\n        for item in array:\n            if not isinstance(item, str):\n                return False\n        return True\n\n    async def suggest(self):\n        columns = await self.get_columns(self.sql, self.params)\n        suggested_facets = []\n        already_enabled = [c[\"config\"][\"simple\"] for c in self.get_configs()]\n        for column in columns:\n            if column in already_enabled:\n                continue\n            # Is every value in this column either null or a JSON array?\n            suggested_facet_sql = \"\"\"\n                with limited as (select * from ({sql}) limit {suggest_consider})\n                select distinct json_type({column})\n                from limited\n                where {column} is not null and {column} != ''\n            \"\"\".format(\n                column=escape_sqlite(column),\n                sql=self.sql,\n                suggest_consider=self.suggest_consider,\n            )\n            try:\n                results = await self.ds.execute(\n                    self.database,\n                    suggested_facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_suggest_time_limit_ms\"),\n                    log_sql_errors=False,\n                )\n                types = tuple(r[0] for r in results.rows)\n                if types in ((\"array\",), (\"array\", None)):\n                    # Now check that first 100 arrays contain only strings\n                    first_100 = [\n                        v[0]\n                        for v in await self.ds.execute(\n                            self.database,\n                            (\n                                \"select {column} from ({sql}) \"\n                                \"where {column} is not null \"\n                                \"and {column} != '' \"\n                                \"and json_array_length({column}) > 0 \"\n                                \"limit 100\"\n                            ).format(column=escape_sqlite(column), sql=self.sql),\n                            self.params,\n                            truncate=False,\n                            custom_time_limit=self.ds.setting(\n                                \"facet_suggest_time_limit_ms\"\n                            ),\n                            log_sql_errors=False,\n                        )\n                    ]\n                    if first_100 and all(\n                        self._is_json_array_of_strings(r) for r in first_100\n                    ):\n                        suggested_facets.append(\n                            {\n                                \"name\": column,\n                                \"type\": \"array\",\n                                \"toggle_url\": self.ds.absolute_url(\n                                    self.request,\n                                    self.ds.urls.path(\n                                        path_with_added_args(\n                                            self.request, {\"_facet_array\": column}\n                                        )\n                                    ),\n                                ),\n                            }\n                        )\n            except (QueryInterrupted, sqlite3.OperationalError):\n                continue\n        return suggested_facets\n\n    async def facet_results(self):\n        # self.configs should be a plain list of columns\n        facet_results = []\n        facets_timed_out = []\n\n        facet_size = self.get_facet_size()\n        for source_and_config in self.get_configs():\n            config = source_and_config[\"config\"]\n            source = source_and_config[\"source\"]\n            column = config.get(\"column\") or config[\"simple\"]\n            # https://github.com/simonw/datasette/issues/448\n            facet_sql = \"\"\"\n                with inner as ({sql}),\n                deduped_array_items as (\n                    select\n                        distinct j.value,\n                        inner.*\n                    from\n                        json_each([inner].{col}) j\n                        join inner\n                )\n                select\n                    value as value,\n                    count(*) as count\n                from\n                    deduped_array_items\n                group by\n                    value\n                order by\n                    count(*) desc, value limit {limit}\n            \"\"\".format(\n                col=escape_sqlite(column),\n                sql=self.sql,\n                limit=facet_size + 1,\n            )\n            try:\n                facet_rows_results = await self.ds.execute(\n                    self.database,\n                    facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_time_limit_ms\"),\n                )\n                facet_results_values = []\n                facet_results.append(\n                    {\n                        \"name\": column,\n                        \"type\": self.type,\n                        \"results\": facet_results_values,\n                        \"hideable\": source != \"metadata\",\n                        \"toggle_url\": self.ds.urls.path(\n                            path_with_removed_args(\n                                self.request, {\"_facet_array\": column}\n                            )\n                        ),\n                        \"truncated\": len(facet_rows_results) > facet_size,\n                    }\n                )\n                facet_rows = facet_rows_results.rows[:facet_size]\n                pairs = self.get_querystring_pairs()\n                for row in facet_rows:\n                    value = str(row[\"value\"])\n                    selected = (f\"{column}__arraycontains\", value) in pairs\n                    if selected:\n                        toggle_path = path_with_removed_args(\n                            self.request, {f\"{column}__arraycontains\": value}\n                        )\n                    else:\n                        toggle_path = path_with_added_args(\n                            self.request, {f\"{column}__arraycontains\": value}\n                        )\n                    facet_results_values.append(\n                        {\n                            \"value\": value,\n                            \"label\": value,\n                            \"count\": row[\"count\"],\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request, toggle_path\n                            ),\n                            \"selected\": selected,\n                        }\n                    )\n            except QueryInterrupted:\n                facets_timed_out.append(column)\n\n        return facet_results, facets_timed_out\n\n\nclass DateFacet(Facet):\n    type = \"date\"\n\n    async def suggest(self):\n        columns = await self.get_columns(self.sql, self.params)\n        already_enabled = [c[\"config\"][\"simple\"] for c in self.get_configs()]\n        suggested_facets = []\n        for column in columns:\n            if column in already_enabled:\n                continue\n            # Does this column contain any dates in the first 100 rows?\n            suggested_facet_sql = \"\"\"\n                select date({column}) from (\n                    select * from ({sql}) limit 100\n                ) where {column} glob \"????-??-*\"\n            \"\"\".format(column=escape_sqlite(column), sql=self.sql)\n            try:\n                results = await self.ds.execute(\n                    self.database,\n                    suggested_facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_suggest_time_limit_ms\"),\n                    log_sql_errors=False,\n                )\n                values = tuple(r[0] for r in results.rows)\n                if any(values):\n                    suggested_facets.append(\n                        {\n                            \"name\": column,\n                            \"type\": \"date\",\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request,\n                                self.ds.urls.path(\n                                    path_with_added_args(\n                                        self.request, {\"_facet_date\": column}\n                                    )\n                                ),\n                            ),\n                        }\n                    )\n            except (QueryInterrupted, sqlite3.OperationalError):\n                continue\n        return suggested_facets\n\n    async def facet_results(self):\n        facet_results = []\n        facets_timed_out = []\n        args = dict(self.get_querystring_pairs())\n        facet_size = self.get_facet_size()\n        for source_and_config in self.get_configs():\n            config = source_and_config[\"config\"]\n            source = source_and_config[\"source\"]\n            column = config.get(\"column\") or config[\"simple\"]\n            # TODO: does this query break if inner sql produces value or count columns?\n            facet_sql = \"\"\"\n                select date({col}) as value, count(*) as count from (\n                    {sql}\n                )\n                where date({col}) is not null\n                group by date({col}) order by count desc, value limit {limit}\n            \"\"\".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)\n            try:\n                facet_rows_results = await self.ds.execute(\n                    self.database,\n                    facet_sql,\n                    self.params,\n                    truncate=False,\n                    custom_time_limit=self.ds.setting(\"facet_time_limit_ms\"),\n                )\n                facet_results_values = []\n                facet_results.append(\n                    {\n                        \"name\": column,\n                        \"type\": self.type,\n                        \"results\": facet_results_values,\n                        \"hideable\": source != \"metadata\",\n                        \"toggle_url\": path_with_removed_args(\n                            self.request, {\"_facet_date\": column}\n                        ),\n                        \"truncated\": len(facet_rows_results) > facet_size,\n                    }\n                )\n                facet_rows = facet_rows_results.rows[:facet_size]\n                for row in facet_rows:\n                    selected = str(args.get(f\"{column}__date\")) == str(row[\"value\"])\n                    if selected:\n                        toggle_path = path_with_removed_args(\n                            self.request, {f\"{column}__date\": str(row[\"value\"])}\n                        )\n                    else:\n                        toggle_path = path_with_added_args(\n                            self.request, {f\"{column}__date\": row[\"value\"]}\n                        )\n                    facet_results_values.append(\n                        {\n                            \"value\": row[\"value\"],\n                            \"label\": row[\"value\"],\n                            \"count\": row[\"count\"],\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request, toggle_path\n                            ),\n                            \"selected\": selected,\n                        }\n                    )\n            except QueryInterrupted:\n                facets_timed_out.append(column)\n\n        return facet_results, facets_timed_out\n"
  },
  {
    "path": "datasette/filters.py",
    "content": "from datasette import hookimpl\nfrom datasette.resources import DatabaseResource\nfrom datasette.views.base import DatasetteError\nfrom datasette.utils.asgi import BadRequest\nimport json\nfrom .utils import detect_json1, escape_sqlite, path_with_removed_args\n\n\n@hookimpl(specname=\"filters_from_request\")\ndef where_filters(request, database, datasette):\n    # This one deals with ?_where=\n    async def inner():\n        where_clauses = []\n        extra_wheres_for_ui = []\n        if \"_where\" in request.args:\n            if not await datasette.allowed(\n                action=\"execute-sql\",\n                resource=DatabaseResource(database=database),\n                actor=request.actor,\n            ):\n                raise DatasetteError(\"_where= is not allowed\", status=403)\n            else:\n                where_clauses.extend(request.args.getlist(\"_where\"))\n                extra_wheres_for_ui = [\n                    {\n                        \"text\": text,\n                        \"remove_url\": path_with_removed_args(request, {\"_where\": text}),\n                    }\n                    for text in request.args.getlist(\"_where\")\n                ]\n\n        return FilterArguments(\n            where_clauses,\n            extra_context={\n                \"extra_wheres_for_ui\": extra_wheres_for_ui,\n            },\n        )\n\n    return inner\n\n\n@hookimpl(specname=\"filters_from_request\")\ndef search_filters(request, database, table, datasette):\n    # ?_search= and _search_colname=\n    async def inner():\n        where_clauses = []\n        params = {}\n        human_descriptions = []\n        extra_context = {}\n\n        # Figure out which fts_table to use\n        table_metadata = await datasette.table_config(database, table)\n        db = datasette.get_database(database)\n        fts_table = request.args.get(\"_fts_table\")\n        fts_table = fts_table or table_metadata.get(\"fts_table\")\n        fts_table = fts_table or await db.fts_table(table)\n        fts_pk = request.args.get(\"_fts_pk\", table_metadata.get(\"fts_pk\", \"rowid\"))\n        search_args = {\n            key: request.args[key]\n            for key in request.args\n            if key.startswith(\"_search\") and key != \"_searchmode\"\n        }\n        search = \"\"\n        search_mode_raw = table_metadata.get(\"searchmode\") == \"raw\"\n        # Or set search mode from the querystring\n        qs_searchmode = request.args.get(\"_searchmode\")\n        if qs_searchmode == \"escaped\":\n            search_mode_raw = False\n        if qs_searchmode == \"raw\":\n            search_mode_raw = True\n\n        extra_context[\"supports_search\"] = bool(fts_table)\n\n        if fts_table and search_args:\n            if \"_search\" in search_args:\n                # Simple ?_search=xxx\n                search = search_args[\"_search\"]\n                where_clauses.append(\n                    \"{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})\".format(\n                        fts_table=escape_sqlite(fts_table),\n                        fts_pk=escape_sqlite(fts_pk),\n                        match_clause=(\n                            \":search\" if search_mode_raw else \"escape_fts(:search)\"\n                        ),\n                    )\n                )\n                human_descriptions.append(f'search matches \"{search}\"')\n                params[\"search\"] = search\n                extra_context[\"search\"] = search\n            else:\n                # More complex: search against specific columns\n                for i, (key, search_text) in enumerate(search_args.items()):\n                    search_col = key.split(\"_search_\", 1)[1]\n                    if search_col not in await db.table_columns(fts_table):\n                        raise BadRequest(\"Cannot search by that column\")\n\n                    where_clauses.append(\n                        \"rowid in (select rowid from {fts_table} where {search_col} match {match_clause})\".format(\n                            fts_table=escape_sqlite(fts_table),\n                            search_col=escape_sqlite(search_col),\n                            match_clause=(\n                                \":search_{}\".format(i)\n                                if search_mode_raw\n                                else \"escape_fts(:search_{})\".format(i)\n                            ),\n                        )\n                    )\n                    human_descriptions.append(\n                        f'search column \"{search_col}\" matches \"{search_text}\"'\n                    )\n                    params[f\"search_{i}\"] = search_text\n                    extra_context[\"search\"] = search_text\n\n        return FilterArguments(where_clauses, params, human_descriptions, extra_context)\n\n    return inner\n\n\n@hookimpl(specname=\"filters_from_request\")\ndef through_filters(request, database, table, datasette):\n    # ?_search= and _search_colname=\n    async def inner():\n        where_clauses = []\n        params = {}\n        human_descriptions = []\n        extra_context = {}\n\n        # Support for ?_through={table, column, value}\n        if \"_through\" in request.args:\n            for through in request.args.getlist(\"_through\"):\n                through_data = json.loads(through)\n                through_table = through_data[\"table\"]\n                other_column = through_data[\"column\"]\n                value = through_data[\"value\"]\n                db = datasette.get_database(database)\n                outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)\n                try:\n                    fk_to_us = [\n                        fk for fk in outgoing_foreign_keys if fk[\"other_table\"] == table\n                    ][0]\n                except IndexError:\n                    raise DatasetteError(\n                        \"Invalid _through - could not find corresponding foreign key\"\n                    )\n                param = f\"p{len(params)}\"\n                where_clauses.append(\n                    \"{our_pk} in (select {our_column} from {through_table} where {other_column} = :{param})\".format(\n                        through_table=escape_sqlite(through_table),\n                        our_pk=escape_sqlite(fk_to_us[\"other_column\"]),\n                        our_column=escape_sqlite(fk_to_us[\"column\"]),\n                        other_column=escape_sqlite(other_column),\n                        param=param,\n                    )\n                )\n                params[param] = value\n                human_descriptions.append(f'{through_table}.{other_column} = \"{value}\"')\n\n        return FilterArguments(where_clauses, params, human_descriptions, extra_context)\n\n    return inner\n\n\nclass FilterArguments:\n    def __init__(\n        self, where_clauses, params=None, human_descriptions=None, extra_context=None\n    ):\n        self.where_clauses = where_clauses\n        self.params = params or {}\n        self.human_descriptions = human_descriptions or []\n        self.extra_context = extra_context or {}\n\n\nclass Filter:\n    key = None\n    display = None\n    no_argument = False\n\n    def where_clause(self, table, column, value, param_counter):\n        raise NotImplementedError\n\n    def human_clause(self, column, value):\n        raise NotImplementedError\n\n\nclass TemplatedFilter(Filter):\n    def __init__(\n        self,\n        key,\n        display,\n        sql_template,\n        human_template,\n        format=\"{}\",\n        numeric=False,\n        no_argument=False,\n    ):\n        self.key = key\n        self.display = display\n        self.sql_template = sql_template\n        self.human_template = human_template\n        self.format = format\n        self.numeric = numeric\n        self.no_argument = no_argument\n\n    def where_clause(self, table, column, value, param_counter):\n        converted = self.format.format(value)\n        if self.numeric and converted.isdigit():\n            converted = int(converted)\n        if self.no_argument:\n            kwargs = {\"c\": column}\n            converted = None\n        else:\n            kwargs = {\"c\": column, \"p\": f\"p{param_counter}\", \"t\": table}\n        return self.sql_template.format(**kwargs), converted\n\n    def human_clause(self, column, value):\n        if callable(self.human_template):\n            template = self.human_template(column, value)\n        else:\n            template = self.human_template\n        if self.no_argument:\n            return template.format(c=column)\n        else:\n            return template.format(c=column, v=value)\n\n\nclass InFilter(Filter):\n    key = \"in\"\n    display = \"in\"\n\n    def split_value(self, value):\n        if value.startswith(\"[\"):\n            return json.loads(value)\n        else:\n            return [v.strip() for v in value.split(\",\")]\n\n    def where_clause(self, table, column, value, param_counter):\n        values = self.split_value(value)\n        params = [f\":p{param_counter + i}\" for i in range(len(values))]\n        sql = f\"{escape_sqlite(column)} in ({', '.join(params)})\"\n        return sql, values\n\n    def human_clause(self, column, value):\n        return f\"{column} in {json.dumps(self.split_value(value))}\"\n\n\nclass NotInFilter(InFilter):\n    key = \"notin\"\n    display = \"not in\"\n\n    def where_clause(self, table, column, value, param_counter):\n        values = self.split_value(value)\n        params = [f\":p{param_counter + i}\" for i in range(len(values))]\n        sql = f\"{escape_sqlite(column)} not in ({', '.join(params)})\"\n        return sql, values\n\n    def human_clause(self, column, value):\n        return f\"{column} not in {json.dumps(self.split_value(value))}\"\n\n\nclass Filters:\n    _filters = (\n        [\n            # key, display, sql_template, human_template, format=, numeric=, no_argument=\n            TemplatedFilter(\n                \"exact\",\n                \"=\",\n                '\"{c}\" = :{p}',\n                lambda c, v: \"{c} = {v}\" if v.isdigit() else '{c} = \"{v}\"',\n            ),\n            TemplatedFilter(\n                \"not\",\n                \"!=\",\n                '\"{c}\" != :{p}',\n                lambda c, v: \"{c} != {v}\" if v.isdigit() else '{c} != \"{v}\"',\n            ),\n            TemplatedFilter(\n                \"contains\",\n                \"contains\",\n                '\"{c}\" like :{p}',\n                '{c} contains \"{v}\"',\n                format=\"%{}%\",\n            ),\n            TemplatedFilter(\n                \"notcontains\",\n                \"does not contain\",\n                '\"{c}\" not like :{p}',\n                '{c} does not contain \"{v}\"',\n                format=\"%{}%\",\n            ),\n            TemplatedFilter(\n                \"endswith\",\n                \"ends with\",\n                '\"{c}\" like :{p}',\n                '{c} ends with \"{v}\"',\n                format=\"%{}\",\n            ),\n            TemplatedFilter(\n                \"startswith\",\n                \"starts with\",\n                '\"{c}\" like :{p}',\n                '{c} starts with \"{v}\"',\n                format=\"{}%\",\n            ),\n            TemplatedFilter(\"gt\", \">\", '\"{c}\" > :{p}', \"{c} > {v}\", numeric=True),\n            TemplatedFilter(\n                \"gte\", \"\\u2265\", '\"{c}\" >= :{p}', \"{c} \\u2265 {v}\", numeric=True\n            ),\n            TemplatedFilter(\"lt\", \"<\", '\"{c}\" < :{p}', \"{c} < {v}\", numeric=True),\n            TemplatedFilter(\n                \"lte\", \"\\u2264\", '\"{c}\" <= :{p}', \"{c} \\u2264 {v}\", numeric=True\n            ),\n            TemplatedFilter(\"like\", \"like\", '\"{c}\" like :{p}', '{c} like \"{v}\"'),\n            TemplatedFilter(\n                \"notlike\", \"not like\", '\"{c}\" not like :{p}', '{c} not like \"{v}\"'\n            ),\n            TemplatedFilter(\"glob\", \"glob\", '\"{c}\" glob :{p}', '{c} glob \"{v}\"'),\n            InFilter(),\n            NotInFilter(),\n        ]\n        + (\n            [\n                TemplatedFilter(\n                    \"arraycontains\",\n                    \"array contains\",\n                    \"\"\":{p} in (select value from json_each([{t}].[{c}]))\"\"\",\n                    '{c} contains \"{v}\"',\n                ),\n                TemplatedFilter(\n                    \"arraynotcontains\",\n                    \"array does not contain\",\n                    \"\"\":{p} not in (select value from json_each([{t}].[{c}]))\"\"\",\n                    '{c} does not contain \"{v}\"',\n                ),\n            ]\n            if detect_json1()\n            else []\n        )\n        + [\n            TemplatedFilter(\n                \"date\", \"date\", 'date(\"{c}\") = :{p}', '\"{c}\" is on date {v}'\n            ),\n            TemplatedFilter(\n                \"isnull\", \"is null\", '\"{c}\" is null', \"{c} is null\", no_argument=True\n            ),\n            TemplatedFilter(\n                \"notnull\",\n                \"is not null\",\n                '\"{c}\" is not null',\n                \"{c} is not null\",\n                no_argument=True,\n            ),\n            TemplatedFilter(\n                \"isblank\",\n                \"is blank\",\n                '(\"{c}\" is null or \"{c}\" = \"\")',\n                \"{c} is blank\",\n                no_argument=True,\n            ),\n            TemplatedFilter(\n                \"notblank\",\n                \"is not blank\",\n                '(\"{c}\" is not null and \"{c}\" != \"\")',\n                \"{c} is not blank\",\n                no_argument=True,\n            ),\n        ]\n    )\n    _filters_by_key = {f.key: f for f in _filters}\n\n    def __init__(self, pairs):\n        self.pairs = pairs\n\n    def lookups(self):\n        \"\"\"Yields (lookup, display, no_argument) pairs\"\"\"\n        for filter in self._filters:\n            yield filter.key, filter.display, filter.no_argument\n\n    def human_description_en(self, extra=None):\n        bits = []\n        if extra:\n            bits.extend(extra)\n        for column, lookup, value in self.selections():\n            filter = self._filters_by_key.get(lookup, None)\n            if filter:\n                bits.append(filter.human_clause(column, value))\n        # Comma separated, with an ' and ' at the end\n        and_bits = []\n        commas, tail = bits[:-1], bits[-1:]\n        if commas:\n            and_bits.append(\", \".join(commas))\n        if tail:\n            and_bits.append(tail[0])\n        s = \" and \".join(and_bits)\n        if not s:\n            return \"\"\n        return f\"where {s}\"\n\n    def selections(self):\n        \"\"\"Yields (column, lookup, value) tuples\"\"\"\n        for key, value in self.pairs:\n            if \"__\" in key:\n                column, lookup = key.rsplit(\"__\", 1)\n            else:\n                column = key\n                lookup = \"exact\"\n            yield column, lookup, value\n\n    def has_selections(self):\n        return bool(self.pairs)\n\n    def build_where_clauses(self, table):\n        sql_bits = []\n        params = {}\n        i = 0\n        for column, lookup, value in self.selections():\n            filter = self._filters_by_key.get(lookup, None)\n            if filter:\n                sql_bit, param = filter.where_clause(table, column, value, i)\n                sql_bits.append(sql_bit)\n                if param is not None:\n                    if not isinstance(param, list):\n                        param = [param]\n                    for individual_param in param:\n                        param_id = f\"p{i}\"\n                        params[param_id] = individual_param\n                        i += 1\n        return sql_bits, params\n"
  },
  {
    "path": "datasette/forbidden.py",
    "content": "from datasette import hookimpl, Response\n\n\n@hookimpl(trylast=True)\ndef forbidden(datasette, request, message):\n    async def inner():\n        return Response.html(\n            await datasette.render_template(\n                \"error.html\",\n                {\n                    \"title\": \"Forbidden\",\n                    \"error\": message,\n                },\n                request=request,\n            ),\n            status=403,\n        )\n\n    return inner\n"
  },
  {
    "path": "datasette/handle_exception.py",
    "content": "from datasette import hookimpl, Response\nfrom .utils import add_cors_headers\nfrom .utils.asgi import (\n    Base400,\n)\nfrom .views.base import DatasetteError\nfrom markupsafe import Markup\nimport traceback\n\ntry:\n    import ipdb as pdb\nexcept ImportError:\n    import pdb\n\ntry:\n    import rich\nexcept ImportError:\n    rich = None\n\n\n@hookimpl(trylast=True)\ndef handle_exception(datasette, request, exception):\n    async def inner():\n        if datasette.pdb:\n            pdb.post_mortem(exception.__traceback__)\n\n        if rich is not None:\n            rich.get_console().print_exception(show_locals=True)\n\n        title = None\n        if isinstance(exception, Base400):\n            status = exception.status\n            info = {}\n            message = exception.args[0]\n        elif isinstance(exception, DatasetteError):\n            status = exception.status\n            info = exception.error_dict\n            message = exception.message\n            if exception.message_is_html:\n                message = Markup(message)\n            title = exception.title\n        else:\n            status = 500\n            info = {}\n            message = str(exception)\n            traceback.print_exc()\n        templates = [f\"{status}.html\", \"error.html\"]\n        info.update(\n            {\n                \"ok\": False,\n                \"error\": message,\n                \"status\": status,\n                \"title\": title,\n            }\n        )\n        headers = {}\n        if datasette.cors:\n            add_cors_headers(headers)\n        if request.path.split(\"?\")[0].endswith(\".json\"):\n            return Response.json(info, status=status, headers=headers)\n        else:\n            environment = datasette.get_jinja_environment(request)\n            template = environment.select_template(templates)\n            return Response.html(\n                await template.render_async(\n                    dict(\n                        info,\n                        urls=datasette.urls,\n                        app_css_hash=datasette.app_css_hash(),\n                        menu_links=lambda: [],\n                    )\n                ),\n                status=status,\n                headers=headers,\n            )\n\n    return inner\n"
  },
  {
    "path": "datasette/hookspecs.py",
    "content": "from pluggy import HookimplMarker\nfrom pluggy import HookspecMarker\n\nhookspec = HookspecMarker(\"datasette\")\nhookimpl = HookimplMarker(\"datasette\")\n\n\n@hookspec\ndef startup(datasette):\n    \"\"\"Fires directly after Datasette first starts running\"\"\"\n\n\n@hookspec\ndef asgi_wrapper(datasette):\n    \"\"\"Returns an ASGI middleware callable to wrap our ASGI application with\"\"\"\n\n\n@hookspec\ndef prepare_connection(conn, database, datasette):\n    \"\"\"Modify SQLite connection in some way e.g. register custom SQL functions\"\"\"\n\n\n@hookspec\ndef prepare_jinja2_environment(env, datasette):\n    \"\"\"Modify Jinja2 template environment e.g. register custom template tags\"\"\"\n\n\n@hookspec\ndef extra_css_urls(template, database, table, columns, view_name, request, datasette):\n    \"\"\"Extra CSS URLs added by this plugin\"\"\"\n\n\n@hookspec\ndef extra_js_urls(template, database, table, columns, view_name, request, datasette):\n    \"\"\"Extra JavaScript URLs added by this plugin\"\"\"\n\n\n@hookspec\ndef extra_body_script(\n    template, database, table, columns, view_name, request, datasette\n):\n    \"\"\"Extra JavaScript code to be included in <script> at bottom of body\"\"\"\n\n\n@hookspec\ndef extra_template_vars(\n    template, database, table, columns, view_name, request, datasette\n):\n    \"\"\"Extra template variables to be made available to the template - can return dict or callable or awaitable\"\"\"\n\n\n@hookspec\ndef publish_subcommand(publish):\n    \"\"\"Subcommands for 'datasette publish'\"\"\"\n\n\n@hookspec\ndef render_cell(\n    row,\n    value,\n    column,\n    table,\n    pks,\n    database,\n    datasette,\n    request,\n    column_type,\n):\n    \"\"\"Customize rendering of HTML table cell values\"\"\"\n\n\n@hookspec\ndef register_output_renderer(datasette):\n    \"\"\"Register a renderer to output data in a different format\"\"\"\n\n\n@hookspec\ndef register_facet_classes():\n    \"\"\"Register Facet subclasses\"\"\"\n\n\n@hookspec\ndef register_actions(datasette):\n    \"\"\"Register actions: returns a list of datasette.permission.Action objects\"\"\"\n\n\n@hookspec\ndef register_column_types(datasette):\n    \"\"\"Return a list of ColumnType subclasses\"\"\"\n\n\n@hookspec\ndef register_routes(datasette):\n    \"\"\"Register URL routes: return a list of (regex, view_function) pairs\"\"\"\n\n\n@hookspec\ndef register_commands(cli):\n    \"\"\"Register additional CLI commands, e.g. 'datasette mycommand ...'\"\"\"\n\n\n@hookspec\ndef actor_from_request(datasette, request):\n    \"\"\"Return an actor dictionary based on the incoming request\"\"\"\n\n\n@hookspec(firstresult=True)\ndef actors_from_ids(datasette, actor_ids):\n    \"\"\"Returns a dictionary mapping those IDs to actor dictionaries\"\"\"\n\n\n@hookspec\ndef jinja2_environment_from_request(datasette, request, env):\n    \"\"\"Return a Jinja2 environment based on the incoming request\"\"\"\n\n\n@hookspec\ndef filters_from_request(request, database, table, datasette):\n    \"\"\"\n    Return datasette.filters.FilterArguments(\n        where_clauses=[str, str, str],\n        params={},\n        human_descriptions=[str, str, str],\n        extra_context={}\n    ) based on the request\"\"\"\n\n\n@hookspec\ndef permission_resources_sql(datasette, actor, action):\n    \"\"\"Return SQL query fragments for permission checks on resources.\n\n    Returns None, a PermissionSQL object, or a list of PermissionSQL objects.\n    Each PermissionSQL contains SQL that should return rows with columns:\n    parent (str|None), child (str|None), allow (int), reason (str).\n\n    Used to efficiently check permissions across multiple resources at once.\n    \"\"\"\n\n\n@hookspec\ndef canned_queries(datasette, database, actor):\n    \"\"\"Return a dictionary of canned query definitions or an awaitable function that returns them\"\"\"\n\n\n@hookspec\ndef register_magic_parameters(datasette):\n    \"\"\"Return a list of (name, function) magic parameter functions\"\"\"\n\n\n@hookspec\ndef forbidden(datasette, request, message):\n    \"\"\"Custom response for a 403 forbidden error\"\"\"\n\n\n@hookspec\ndef menu_links(datasette, actor, request):\n    \"\"\"Links for the navigation menu\"\"\"\n\n\n@hookspec\ndef row_actions(datasette, actor, request, database, table, row):\n    \"\"\"Links for the row actions menu\"\"\"\n\n\n@hookspec\ndef table_actions(datasette, actor, database, table, request):\n    \"\"\"Links for the table actions menu\"\"\"\n\n\n@hookspec\ndef view_actions(datasette, actor, database, view, request):\n    \"\"\"Links for the view actions menu\"\"\"\n\n\n@hookspec\ndef query_actions(datasette, actor, database, query_name, request, sql, params):\n    \"\"\"Links for the query and canned query actions menu\"\"\"\n\n\n@hookspec\ndef database_actions(datasette, actor, database, request):\n    \"\"\"Links for the database actions menu\"\"\"\n\n\n@hookspec\ndef homepage_actions(datasette, actor, request):\n    \"\"\"Links for the homepage actions menu\"\"\"\n\n\n@hookspec\ndef skip_csrf(datasette, scope):\n    \"\"\"Mechanism for skipping CSRF checks for certain requests\"\"\"\n\n\n@hookspec\ndef handle_exception(datasette, request, exception):\n    \"\"\"Handle an uncaught exception. Can return a Response or None.\"\"\"\n\n\n@hookspec\ndef track_event(datasette, event):\n    \"\"\"Respond to an event tracked by Datasette\"\"\"\n\n\n@hookspec\ndef register_events(datasette):\n    \"\"\"Return a list of Event subclasses to use with track_event()\"\"\"\n\n\n@hookspec\ndef top_homepage(datasette, request):\n    \"\"\"HTML to include at the top of the homepage\"\"\"\n\n\n@hookspec\ndef top_database(datasette, request, database):\n    \"\"\"HTML to include at the top of the database page\"\"\"\n\n\n@hookspec\ndef top_table(datasette, request, database, table):\n    \"\"\"HTML to include at the top of the table page\"\"\"\n\n\n@hookspec\ndef top_row(datasette, request, database, table, row):\n    \"\"\"HTML to include at the top of the row page\"\"\"\n\n\n@hookspec\ndef top_query(datasette, request, database, sql):\n    \"\"\"HTML to include at the top of the query results page\"\"\"\n\n\n@hookspec\ndef top_canned_query(datasette, request, database, query_name):\n    \"\"\"HTML to include at the top of the canned query page\"\"\"\n\n\n@hookspec\ndef register_token_handler(datasette):\n    \"\"\"Return a TokenHandler instance for token creation and verification\"\"\"\n\n\n@hookspec\ndef write_wrapper(datasette, database, request, transaction):\n    \"\"\"Called when a write function is about to execute.\n\n    Return a generator function that accepts a ``conn`` argument.\n    The generator should ``yield`` exactly once: code before the\n    ``yield`` runs before the write, code after the ``yield`` runs\n    after the write completes. The result of the write is sent\n    back through the ``yield``, so you can capture it with\n    ``result = yield``.\n\n    If the write raises an exception, it is thrown into the generator\n    so you can handle it with a try/except around the ``yield``.\n\n    ``request`` may be ``None`` for writes not originating from an\n    HTTP request.  ``transaction`` is ``True`` if the write will\n    be wrapped in a transaction.\n\n    Return ``None`` to skip wrapping.\n    \"\"\"\n"
  },
  {
    "path": "datasette/inspect.py",
    "content": "import hashlib\n\nfrom .utils import (\n    detect_spatialite,\n    detect_fts,\n    detect_primary_keys,\n    escape_sqlite,\n    get_all_foreign_keys,\n    table_columns,\n    sqlite3,\n)\n\nHASH_BLOCK_SIZE = 1024 * 1024\n\n\ndef inspect_hash(path):\n    \"\"\"Calculate the hash of a database, efficiently.\"\"\"\n    m = hashlib.sha256()\n    with path.open(\"rb\") as fp:\n        while True:\n            data = fp.read(HASH_BLOCK_SIZE)\n            if not data:\n                break\n            m.update(data)\n\n    return m.hexdigest()\n\n\ndef inspect_views(conn):\n    \"\"\"List views in a database.\"\"\"\n    return [\n        v[0] for v in conn.execute('select name from sqlite_master where type = \"view\"')\n    ]\n\n\ndef inspect_tables(conn, database_metadata):\n    \"\"\"List tables and their row counts, excluding uninteresting tables.\"\"\"\n    tables = {}\n    table_names = [\n        r[\"name\"]\n        for r in conn.execute('select * from sqlite_master where type=\"table\"')\n    ]\n\n    for table in table_names:\n        table_metadata = database_metadata.get(\"tables\", {}).get(table, {})\n\n        try:\n            count = conn.execute(\n                f\"select count(*) from {escape_sqlite(table)}\"\n            ).fetchone()[0]\n        except sqlite3.OperationalError:\n            # This can happen when running against a FTS virtual table\n            # e.g. \"select count(*) from some_fts;\"\n            count = 0\n\n        column_names = table_columns(conn, table)\n\n        tables[table] = {\n            \"name\": table,\n            \"columns\": column_names,\n            \"primary_keys\": detect_primary_keys(conn, table),\n            \"count\": count,\n            \"hidden\": table_metadata.get(\"hidden\") or False,\n            \"fts_table\": detect_fts(conn, table),\n        }\n\n    foreign_keys = get_all_foreign_keys(conn)\n    for table, info in foreign_keys.items():\n        tables[table][\"foreign_keys\"] = info\n\n    # Mark tables 'hidden' if they relate to FTS virtual tables\n    hidden_tables = [r[\"name\"] for r in conn.execute(\"\"\"\n                select name from sqlite_master\n                where rootpage = 0\n                and sql like '%VIRTUAL TABLE%USING FTS%'\n            \"\"\")]\n\n    if detect_spatialite(conn):\n        # Also hide Spatialite internal tables\n        hidden_tables += [\n            \"ElementaryGeometries\",\n            \"SpatialIndex\",\n            \"geometry_columns\",\n            \"spatial_ref_sys\",\n            \"spatialite_history\",\n            \"sql_statements_log\",\n            \"sqlite_sequence\",\n            \"views_geometry_columns\",\n            \"virts_geometry_columns\",\n        ] + [\n            r[\"name\"] for r in conn.execute(\"\"\"\n                    select name from sqlite_master\n                    where name like \"idx_%\"\n                    and type = \"table\"\n                \"\"\")\n        ]\n\n    for t in tables.keys():\n        for hidden_table in hidden_tables:\n            if t == hidden_table or t.startswith(hidden_table):\n                tables[t][\"hidden\"] = True\n                continue\n\n    return tables\n"
  },
  {
    "path": "datasette/permissions.py",
    "content": "from abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom typing import Any, NamedTuple\nimport contextvars\n\n# Context variable to track when permission checks should be skipped\n_skip_permission_checks = contextvars.ContextVar(\n    \"skip_permission_checks\", default=False\n)\n\n\nclass SkipPermissions:\n    \"\"\"Context manager to temporarily skip permission checks.\n\n    This is not a stable API and may change in future releases.\n\n    Usage:\n        with SkipPermissions():\n            # Permission checks are skipped within this block\n            response = await datasette.client.get(\"/protected\")\n    \"\"\"\n\n    def __enter__(self):\n        self.token = _skip_permission_checks.set(True)\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        _skip_permission_checks.reset(self.token)\n        return False\n\n\nclass Resource(ABC):\n    \"\"\"\n    Base class for all resource types.\n\n    Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).\n    The class itself carries metadata about the resource type.\n    Instances represent specific resources.\n    \"\"\"\n\n    # Class-level metadata (subclasses must define these)\n    name: str = None  # e.g., \"table\", \"database\", \"model\"\n    parent_class: type[\"Resource\"] | None = None  # e.g., DatabaseResource for tables\n\n    # Instance-level optional extra attributes\n    reasons: list[str] | None = None\n    include_reasons: bool | None = None\n\n    def __init__(self, parent: str | None = None, child: str | None = None):\n        \"\"\"\n        Create a resource instance.\n\n        Args:\n            parent: The parent identifier (meaning depends on resource type)\n            child: The child identifier (meaning depends on resource type)\n        \"\"\"\n        self.parent = parent\n        self.child = child\n        self._private = None  # Sentinel to track if private was set\n\n    @property\n    def private(self) -> bool:\n        \"\"\"\n        Whether this resource is private (accessible to actor but not anonymous).\n\n        This property is only available on Resource objects returned from\n        allowed_resources() when include_is_private=True is used.\n\n        Raises:\n            AttributeError: If accessed without calling include_is_private=True\n        \"\"\"\n        if self._private is None:\n            raise AttributeError(\n                \"The 'private' attribute is only available when using \"\n                \"allowed_resources(..., include_is_private=True)\"\n            )\n        return self._private\n\n    @private.setter\n    def private(self, value: bool):\n        self._private = value\n\n    @classmethod\n    def __init_subclass__(cls):\n        \"\"\"\n        Validate resource hierarchy doesn't exceed 2 levels.\n\n        Raises:\n            ValueError: If this resource would create a 3-level hierarchy\n        \"\"\"\n        super().__init_subclass__()\n\n        if cls.parent_class is None:\n            return  # Top of hierarchy, nothing to validate\n\n        # Check if our parent has a parent - that would create 3 levels\n        if cls.parent_class.parent_class is not None:\n            # We have a parent, and that parent has a parent\n            # This creates a 3-level hierarchy, which is not allowed\n            raise ValueError(\n                f\"Resource {cls.__name__} creates a 3-level hierarchy: \"\n                f\"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. \"\n                f\"Maximum 2 levels allowed (parent -> child).\"\n            )\n\n    @classmethod\n    @abstractmethod\n    async def resources_sql(cls, datasette, actor=None) -> str:\n        \"\"\"\n        Return SQL query that returns all resources of this type.\n\n        Must return two columns: parent, child\n        \"\"\"\n        pass\n\n\nclass AllowedResource(NamedTuple):\n    \"\"\"A resource with the reason it was allowed (for debugging).\"\"\"\n\n    resource: Resource\n    reason: str\n\n\n@dataclass(frozen=True, kw_only=True)\nclass Action:\n    name: str\n    description: str | None\n    abbr: str | None = None\n    resource_class: type[Resource] | None = None\n    also_requires: str | None = None  # Optional action name that must also be allowed\n\n    @property\n    def takes_parent(self) -> bool:\n        \"\"\"\n        Whether this action requires a parent identifier when instantiating its resource.\n\n        Returns False for global-only actions (no resource_class).\n        Returns True for all actions with a resource_class (all resources require a parent identifier).\n        \"\"\"\n        return self.resource_class is not None\n\n    @property\n    def takes_child(self) -> bool:\n        \"\"\"\n        Whether this action requires a child identifier when instantiating its resource.\n\n        Returns False for global actions (no resource_class).\n        Returns False for parent-level resources (DatabaseResource - parent_class is None).\n        Returns True for child-level resources (TableResource, QueryResource - have a parent_class).\n        \"\"\"\n        if self.resource_class is None:\n            return False\n        return self.resource_class.parent_class is not None\n\n\n_reason_id = 1\n\n\n@dataclass\nclass PermissionSQL:\n    \"\"\"\n    A plugin contributes SQL that yields:\n      parent TEXT NULL,\n      child  TEXT NULL,\n      allow  INTEGER,    -- 1 allow, 0 deny\n      reason TEXT\n\n    For restriction-only plugins, sql can be None and only restriction_sql is provided.\n    \"\"\"\n\n    sql: str | None = (\n        None  # SQL that SELECTs the 4 columns above (can be None for restriction-only)\n    )\n    params: dict[str, Any] | None = (\n        None  # bound params for the SQL (values only; no ':' prefix)\n    )\n    source: str | None = None  # System will set this to the plugin name\n    restriction_sql: str | None = (\n        None  # Optional SQL that returns (parent, child) for restriction filtering\n    )\n\n    @classmethod\n    def allow(cls, reason: str, _allow: bool = True) -> \"PermissionSQL\":\n        global _reason_id\n        i = _reason_id\n        _reason_id += 1\n        return cls(\n            sql=f\"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason\",\n            params={f\"reason_{i}\": reason},\n        )\n\n    @classmethod\n    def deny(cls, reason: str) -> \"PermissionSQL\":\n        return cls.allow(reason=reason, _allow=False)\n\n\n# This is obsolete, replaced by Action and ResourceType\n@dataclass\nclass Permission:\n    name: str\n    abbr: str | None\n    description: str | None\n    takes_database: bool\n    takes_resource: bool\n    default: bool\n    # This is deliberately undocumented: it's considered an internal\n    # implementation detail for view-table/view-database and should\n    # not be used by plugins as it may change in the future.\n    implies_can_view: bool = False\n"
  },
  {
    "path": "datasette/plugins.py",
    "content": "import importlib\nimport os\nimport pluggy\nfrom pprint import pprint\nimport sys\nfrom . import hookspecs\n\nif sys.version_info >= (3, 9):\n    import importlib.resources as importlib_resources\nelse:\n    import importlib_resources\nif sys.version_info >= (3, 10):\n    import importlib.metadata as importlib_metadata\nelse:\n    import importlib_metadata\n\n\nDEFAULT_PLUGINS = (\n    \"datasette.publish.heroku\",\n    \"datasette.publish.cloudrun\",\n    \"datasette.facets\",\n    \"datasette.filters\",\n    \"datasette.sql_functions\",\n    \"datasette.actor_auth_cookie\",\n    \"datasette.default_permissions\",\n    \"datasette.default_permissions.tokens\",\n    \"datasette.default_actions\",\n    \"datasette.default_column_types\",\n    \"datasette.default_magic_parameters\",\n    \"datasette.blob_renderer\",\n    \"datasette.default_menu_links\",\n    \"datasette.handle_exception\",\n    \"datasette.forbidden\",\n    \"datasette.events\",\n)\n\npm = pluggy.PluginManager(\"datasette\")\npm.add_hookspecs(hookspecs)\n\nDATASETTE_TRACE_PLUGINS = os.environ.get(\"DATASETTE_TRACE_PLUGINS\", None)\n\n\ndef before(hook_name, hook_impls, kwargs):\n    print(file=sys.stderr)\n    print(f\"{hook_name}:\", file=sys.stderr)\n    pprint(kwargs, width=40, indent=4, stream=sys.stderr)\n    print(\"Hook implementations:\", file=sys.stderr)\n    pprint(hook_impls, width=40, indent=4, stream=sys.stderr)\n\n\ndef after(outcome, hook_name, hook_impls, kwargs):\n    results = outcome.get_result()\n    if not isinstance(results, list):\n        results = [results]\n    print(\"Results:\", file=sys.stderr)\n    pprint(results, width=40, indent=4, stream=sys.stderr)\n\n\nif DATASETTE_TRACE_PLUGINS:\n    pm.add_hookcall_monitoring(before, after)\n\n\nDATASETTE_LOAD_PLUGINS = os.environ.get(\"DATASETTE_LOAD_PLUGINS\", None)\n\nif not hasattr(sys, \"_called_from_test\") and DATASETTE_LOAD_PLUGINS is None:\n    # Only load plugins if not running tests\n    pm.load_setuptools_entrypoints(\"datasette\")\n\n# Load any plugins specified in DATASETTE_LOAD_PLUGINS\")\nif DATASETTE_LOAD_PLUGINS is not None:\n    for package_name in [\n        name for name in DATASETTE_LOAD_PLUGINS.split(\",\") if name.strip()\n    ]:\n        try:\n            distribution = importlib_metadata.distribution(package_name)\n            entry_points = distribution.entry_points\n            for entry_point in entry_points:\n                if entry_point.group == \"datasette\":\n                    mod = entry_point.load()\n                    pm.register(mod, name=entry_point.name)\n                    # Ensure name can be found in plugin_to_distinfo later:\n                    pm._plugin_distinfo.append((mod, distribution))\n        except importlib_metadata.PackageNotFoundError:\n            sys.stderr.write(\"Plugin {} could not be found\\n\".format(package_name))\n\n\n# Load default plugins\nfor plugin in DEFAULT_PLUGINS:\n    mod = importlib.import_module(plugin)\n    pm.register(mod, plugin)\n\n\ndef get_plugins():\n    plugins = []\n    plugin_to_distinfo = dict(pm.list_plugin_distinfo())\n    for plugin in pm.get_plugins():\n        static_path = None\n        templates_path = None\n        plugin_name = (\n            plugin.__name__\n            if hasattr(plugin, \"__name__\")\n            else plugin.__class__.__name__\n        )\n        if plugin_name not in DEFAULT_PLUGINS:\n            try:\n                if (importlib_resources.files(plugin_name) / \"static\").is_dir():\n                    static_path = str(importlib_resources.files(plugin_name) / \"static\")\n                if (importlib_resources.files(plugin_name) / \"templates\").is_dir():\n                    templates_path = str(\n                        importlib_resources.files(plugin_name) / \"templates\"\n                    )\n            except (TypeError, ModuleNotFoundError):\n                # Caused by --plugins_dir= plugins\n                pass\n        plugin_info = {\n            \"name\": plugin_name,\n            \"static_path\": static_path,\n            \"templates_path\": templates_path,\n            \"hooks\": [h.name for h in pm.get_hookcallers(plugin)],\n        }\n        distinfo = plugin_to_distinfo.get(plugin)\n        if distinfo:\n            plugin_info[\"version\"] = distinfo.version\n            plugin_info[\"name\"] = distinfo.name or distinfo.project_name\n        plugins.append(plugin_info)\n    return plugins\n"
  },
  {
    "path": "datasette/publish/__init__.py",
    "content": ""
  },
  {
    "path": "datasette/publish/cloudrun.py",
    "content": "from datasette import hookimpl\nimport click\nimport json\nimport os\nimport re\nfrom subprocess import CalledProcessError, check_call, check_output\n\nfrom .common import (\n    add_common_publish_arguments_and_options,\n    fail_if_publish_binary_not_installed,\n)\nfrom ..utils import temporary_docker_directory\n\n\n@hookimpl\ndef publish_subcommand(publish):\n    @publish.command()\n    @add_common_publish_arguments_and_options\n    @click.option(\n        \"-n\",\n        \"--name\",\n        default=\"datasette\",\n        help=\"Application name to use when building\",\n    )\n    @click.option(\n        \"--service\",\n        default=\"\",\n        help=\"Cloud Run service to deploy (or over-write)\",\n    )\n    @click.option(\"--spatialite\", is_flag=True, help=\"Enable SpatialLite extension\")\n    @click.option(\n        \"--show-files\",\n        is_flag=True,\n        help=\"Output the generated Dockerfile and metadata.json\",\n    )\n    @click.option(\n        \"--memory\",\n        callback=_validate_memory,\n        help=\"Memory to allocate in Cloud Run, e.g. 1Gi\",\n    )\n    @click.option(\n        \"--cpu\",\n        type=click.Choice([\"1\", \"2\", \"4\"]),\n        help=\"Number of vCPUs to allocate in Cloud Run\",\n    )\n    @click.option(\n        \"--timeout\",\n        type=int,\n        help=\"Build timeout in seconds\",\n    )\n    @click.option(\n        \"--apt-get-install\",\n        \"apt_get_extras\",\n        multiple=True,\n        help=\"Additional packages to apt-get install\",\n    )\n    @click.option(\n        \"--max-instances\",\n        type=int,\n        default=1,\n        show_default=True,\n        help=\"Maximum Cloud Run instances (use 0 to remove the limit)\",\n    )\n    @click.option(\n        \"--min-instances\",\n        type=int,\n        help=\"Minimum Cloud Run instances\",\n    )\n    @click.option(\n        \"--artifact-repository\",\n        default=\"datasette\",\n        show_default=True,\n        help=\"Artifact Registry repository to store the image\",\n    )\n    @click.option(\n        \"--artifact-region\",\n        default=\"us\",\n        show_default=True,\n        help=\"Artifact Registry location (region or multi-region)\",\n    )\n    @click.option(\n        \"--artifact-project\",\n        default=None,\n        help=\"Project ID for Artifact Registry (defaults to the active project)\",\n    )\n    def cloudrun(\n        files,\n        metadata,\n        extra_options,\n        branch,\n        template_dir,\n        plugins_dir,\n        static,\n        install,\n        plugin_secret,\n        version_note,\n        secret,\n        title,\n        license,\n        license_url,\n        source,\n        source_url,\n        about,\n        about_url,\n        name,\n        service,\n        spatialite,\n        show_files,\n        memory,\n        cpu,\n        timeout,\n        apt_get_extras,\n        max_instances,\n        min_instances,\n        artifact_repository,\n        artifact_region,\n        artifact_project,\n    ):\n        \"Publish databases to Datasette running on Cloud Run\"\n        fail_if_publish_binary_not_installed(\n            \"gcloud\", \"Google Cloud\", \"https://cloud.google.com/sdk/\"\n        )\n        project = check_output(\n            \"gcloud config get-value project\", shell=True, universal_newlines=True\n        ).strip()\n\n        artifact_project = artifact_project or project\n\n        # Ensure Artifact Registry exists for the target image\n        _ensure_artifact_registry(\n            artifact_project=artifact_project,\n            artifact_region=artifact_region,\n            artifact_repository=artifact_repository,\n        )\n\n        artifact_host = (\n            artifact_region\n            if artifact_region.endswith(\"-docker.pkg.dev\")\n            else f\"{artifact_region}-docker.pkg.dev\"\n        )\n\n        if not service:\n            # Show the user their current services, then prompt for one\n            click.echo(\"Please provide a service name for this deployment\\n\")\n            click.echo(\"Using an existing service name will over-write it\")\n            click.echo(\"\")\n            existing_services = get_existing_services()\n            if existing_services:\n                click.echo(\"Your existing services:\\n\")\n                for existing_service in existing_services:\n                    click.echo(\n                        \"  {name} - created {created} - {url}\".format(\n                            **existing_service\n                        )\n                    )\n                click.echo(\"\")\n            service = click.prompt(\"Service name\", type=str)\n\n        image_id = (\n            f\"{artifact_host}/{artifact_project}/\"\n            f\"{artifact_repository}/datasette-{service}\"\n        )\n\n        extra_metadata = {\n            \"title\": title,\n            \"license\": license,\n            \"license_url\": license_url,\n            \"source\": source,\n            \"source_url\": source_url,\n            \"about\": about,\n            \"about_url\": about_url,\n        }\n\n        if not extra_options:\n            extra_options = \"\"\n        if \"force_https_urls\" not in extra_options:\n            if extra_options:\n                extra_options += \" \"\n            extra_options += \"--setting force_https_urls on\"\n\n        environment_variables = {}\n        if plugin_secret:\n            extra_metadata[\"plugins\"] = {}\n            for plugin_name, plugin_setting, setting_value in plugin_secret:\n                environment_variable = (\n                    f\"{plugin_name}_{plugin_setting}\".upper().replace(\"-\", \"_\")\n                )\n                environment_variables[environment_variable] = setting_value\n                extra_metadata[\"plugins\"].setdefault(plugin_name, {})[\n                    plugin_setting\n                ] = {\"$env\": environment_variable}\n\n        with temporary_docker_directory(\n            files,\n            name,\n            metadata,\n            extra_options,\n            branch,\n            template_dir,\n            plugins_dir,\n            static,\n            install,\n            spatialite,\n            version_note,\n            secret,\n            extra_metadata,\n            environment_variables,\n            apt_get_extras=apt_get_extras,\n        ):\n            if show_files:\n                if os.path.exists(\"metadata.json\"):\n                    print(\"=== metadata.json ===\\n\")\n                    with open(\"metadata.json\") as fp:\n                        print(fp.read())\n                print(\"\\n==== Dockerfile ====\\n\")\n                with open(\"Dockerfile\") as fp:\n                    print(fp.read())\n                print(\"\\n====================\\n\")\n\n            check_call(\n                \"gcloud builds submit --tag {}{}\".format(\n                    image_id, \" --timeout {}\".format(timeout) if timeout else \"\"\n                ),\n                shell=True,\n            )\n        extra_deploy_options = []\n        for option, value in (\n            (\"--memory\", memory),\n            (\"--cpu\", cpu),\n            (\"--max-instances\", max_instances),\n            (\"--min-instances\", min_instances),\n        ):\n            if value is not None:\n                extra_deploy_options.append(\"{} {}\".format(option, value))\n        check_call(\n            \"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}\".format(\n                image_id,\n                service,\n                \" \" + \" \".join(extra_deploy_options) if extra_deploy_options else \"\",\n            ),\n            shell=True,\n        )\n\n\ndef _ensure_artifact_registry(artifact_project, artifact_region, artifact_repository):\n    \"\"\"Ensure Artifact Registry API is enabled and the repository exists.\"\"\"\n\n    enable_cmd = (\n        \"gcloud services enable artifactregistry.googleapis.com \"\n        f\"--project {artifact_project} --quiet\"\n    )\n    try:\n        check_call(enable_cmd, shell=True)\n    except CalledProcessError as exc:\n        raise click.ClickException(\n            \"Failed to enable artifactregistry.googleapis.com. \"\n            \"Please ensure you have permissions to manage services.\"\n        ) from exc\n\n    describe_cmd = (\n        \"gcloud artifacts repositories describe {repo} --project {project} \"\n        \"--location {location} --quiet\"\n    ).format(\n        repo=artifact_repository,\n        project=artifact_project,\n        location=artifact_region,\n    )\n    try:\n        check_call(describe_cmd, shell=True)\n        return\n    except CalledProcessError:\n        create_cmd = (\n            \"gcloud artifacts repositories create {repo} --repository-format=docker \"\n            '--location {location} --project {project} --description \"Datasette Cloud Run images\" --quiet'\n        ).format(\n            repo=artifact_repository,\n            location=artifact_region,\n            project=artifact_project,\n        )\n        try:\n            check_call(create_cmd, shell=True)\n            click.echo(f\"Created Artifact Registry repository '{artifact_repository}'\")\n        except CalledProcessError as exc:\n            raise click.ClickException(\n                \"Failed to create Artifact Registry repository. \"\n                \"Use --artifact-repository/--artifact-region to point to an existing repo \"\n                \"or create one manually.\"\n            ) from exc\n\n\ndef get_existing_services():\n    services = json.loads(\n        check_output(\n            \"gcloud run services list --platform=managed --format json\",\n            shell=True,\n            universal_newlines=True,\n        )\n    )\n    return [\n        {\n            \"name\": service[\"metadata\"][\"name\"],\n            \"created\": service[\"metadata\"][\"creationTimestamp\"],\n            \"url\": service[\"status\"][\"address\"][\"url\"],\n        }\n        for service in services\n        if \"url\" in service[\"status\"]\n    ]\n\n\ndef _validate_memory(ctx, param, value):\n    if value and re.match(r\"^\\d+(Gi|G|Mi|M)$\", value) is None:\n        raise click.BadParameter(\"--memory should be a number then Gi/G/Mi/M e.g 1Gi\")\n    return value\n"
  },
  {
    "path": "datasette/publish/common.py",
    "content": "from ..utils import StaticMount\nimport click\nimport os\nimport shutil\nimport sys\n\n\ndef add_common_publish_arguments_and_options(subcommand):\n    for decorator in reversed(\n        (\n            click.argument(\"files\", type=click.Path(exists=True), nargs=-1),\n            click.option(\n                \"-m\",\n                \"--metadata\",\n                type=click.File(mode=\"r\"),\n                help=\"Path to JSON/YAML file containing metadata to publish\",\n            ),\n            click.option(\n                \"--extra-options\", help=\"Extra options to pass to datasette serve\"\n            ),\n            click.option(\n                \"--branch\", help=\"Install datasette from a GitHub branch e.g. main\"\n            ),\n            click.option(\n                \"--template-dir\",\n                type=click.Path(exists=True, file_okay=False, dir_okay=True),\n                help=\"Path to directory containing custom templates\",\n            ),\n            click.option(\n                \"--plugins-dir\",\n                type=click.Path(exists=True, file_okay=False, dir_okay=True),\n                help=\"Path to directory containing custom plugins\",\n            ),\n            click.option(\n                \"--static\",\n                type=StaticMount(),\n                help=\"Serve static files from this directory at /MOUNT/...\",\n                multiple=True,\n            ),\n            click.option(\n                \"--install\",\n                help=\"Additional packages (e.g. plugins) to install\",\n                multiple=True,\n            ),\n            click.option(\n                \"--plugin-secret\",\n                nargs=3,\n                type=(str, str, str),\n                callback=validate_plugin_secret,\n                multiple=True,\n                help=\"Secrets to pass to plugins, e.g. --plugin-secret datasette-auth-github client_id xxx\",\n            ),\n            click.option(\n                \"--version-note\", help=\"Additional note to show on /-/versions\"\n            ),\n            click.option(\n                \"--secret\",\n                help=\"Secret used for signing secure values, such as signed cookies\",\n                envvar=\"DATASETTE_PUBLISH_SECRET\",\n                default=lambda: os.urandom(32).hex(),\n            ),\n            click.option(\"--title\", help=\"Title for metadata\"),\n            click.option(\"--license\", help=\"License label for metadata\"),\n            click.option(\"--license_url\", help=\"License URL for metadata\"),\n            click.option(\"--source\", help=\"Source label for metadata\"),\n            click.option(\"--source_url\", help=\"Source URL for metadata\"),\n            click.option(\"--about\", help=\"About label for metadata\"),\n            click.option(\"--about_url\", help=\"About URL for metadata\"),\n        )\n    ):\n        subcommand = decorator(subcommand)\n    return subcommand\n\n\ndef fail_if_publish_binary_not_installed(binary, publish_target, install_link):\n    \"\"\"Exit (with error message) if ``binary` isn't installed\"\"\"\n    if not shutil.which(binary):\n        click.secho(\n            \"Publishing to {publish_target} requires {binary} to be installed and configured\".format(\n                publish_target=publish_target, binary=binary\n            ),\n            bg=\"red\",\n            fg=\"white\",\n            bold=True,\n            err=True,\n        )\n        click.echo(\n            f\"Follow the instructions at {install_link}\",\n            err=True,\n        )\n        sys.exit(1)\n\n\ndef validate_plugin_secret(ctx, param, value):\n    for plugin_name, plugin_setting, setting_value in value:\n        if \"'\" in setting_value:\n            raise click.BadParameter(\"--plugin-secret cannot contain single quotes\")\n    return value\n"
  },
  {
    "path": "datasette/publish/heroku.py",
    "content": "from contextlib import contextmanager\nfrom datasette import hookimpl\nimport click\nimport json\nimport os\nimport pathlib\nimport shlex\nimport shutil\nfrom subprocess import call, check_output\nimport tempfile\n\nfrom .common import (\n    add_common_publish_arguments_and_options,\n    fail_if_publish_binary_not_installed,\n)\nfrom datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata\n\n\n@hookimpl\ndef publish_subcommand(publish):\n    @publish.command()\n    @add_common_publish_arguments_and_options\n    @click.option(\n        \"-n\",\n        \"--name\",\n        default=\"datasette\",\n        help=\"Application name to use when deploying\",\n    )\n    @click.option(\n        \"--tar\",\n        help=\"--tar option to pass to Heroku, e.g. --tar=/usr/local/bin/gtar\",\n    )\n    @click.option(\n        \"--generate-dir\",\n        type=click.Path(dir_okay=True, file_okay=False),\n        help=\"Output generated application files and stop without deploying\",\n    )\n    def heroku(\n        files,\n        metadata,\n        extra_options,\n        branch,\n        template_dir,\n        plugins_dir,\n        static,\n        install,\n        plugin_secret,\n        version_note,\n        secret,\n        title,\n        license,\n        license_url,\n        source,\n        source_url,\n        about,\n        about_url,\n        name,\n        tar,\n        generate_dir,\n    ):\n        \"Publish databases to Datasette running on Heroku\"\n        fail_if_publish_binary_not_installed(\n            \"heroku\", \"Heroku\", \"https://cli.heroku.com\"\n        )\n\n        # Check for heroku-builds plugin\n        plugins = [\n            line.split()[0] for line in check_output([\"heroku\", \"plugins\"]).splitlines()\n        ]\n        if b\"heroku-builds\" not in plugins:\n            click.echo(\n                \"Publishing to Heroku requires the heroku-builds plugin to be installed.\"\n            )\n            click.confirm(\n                \"Install it? (this will run `heroku plugins:install heroku-builds`)\",\n                abort=True,\n            )\n            call([\"heroku\", \"plugins:install\", \"heroku-builds\"])\n\n        extra_metadata = {\n            \"title\": title,\n            \"license\": license,\n            \"license_url\": license_url,\n            \"source\": source,\n            \"source_url\": source_url,\n            \"about\": about,\n            \"about_url\": about_url,\n        }\n\n        environment_variables = {}\n        if plugin_secret:\n            extra_metadata[\"plugins\"] = {}\n            for plugin_name, plugin_setting, setting_value in plugin_secret:\n                environment_variable = (\n                    f\"{plugin_name}_{plugin_setting}\".upper().replace(\"-\", \"_\")\n                )\n                environment_variables[environment_variable] = setting_value\n                extra_metadata[\"plugins\"].setdefault(plugin_name, {})[\n                    plugin_setting\n                ] = {\"$env\": environment_variable}\n\n        with temporary_heroku_directory(\n            files,\n            name,\n            metadata,\n            extra_options,\n            branch,\n            template_dir,\n            plugins_dir,\n            static,\n            install,\n            version_note,\n            secret,\n            extra_metadata,\n        ):\n            if generate_dir:\n                # Recursively copy files from current working directory to it\n                if pathlib.Path(generate_dir).exists():\n                    raise click.ClickException(\"Directory already exists\")\n                shutil.copytree(\".\", generate_dir)\n                click.echo(\n                    f\"Generated files written to {generate_dir}, stopping without deploying\",\n                    err=True,\n                )\n                return\n            app_name = None\n            if name:\n                # Check to see if this app already exists\n                list_output = check_output([\"heroku\", \"apps:list\", \"--json\"]).decode(\n                    \"utf8\"\n                )\n                apps = json.loads(list_output)\n\n                for app in apps:\n                    if app[\"name\"] == name:\n                        app_name = name\n                        break\n\n            if not app_name:\n                # Create a new app\n                cmd = [\"heroku\", \"apps:create\"]\n                if name:\n                    cmd.append(name)\n                cmd.append(\"--json\")\n                create_output = check_output(cmd).decode(\"utf8\")\n                app_name = json.loads(create_output)[\"name\"]\n\n            for key, value in environment_variables.items():\n                call([\"heroku\", \"config:set\", \"-a\", app_name, f\"{key}={value}\"])\n            tar_option = []\n            if tar:\n                tar_option = [\"--tar\", tar]\n            call(\n                [\"heroku\", \"builds:create\", \"-a\", app_name, \"--include-vcs-ignore\"]\n                + tar_option\n            )\n\n\n@contextmanager\ndef temporary_heroku_directory(\n    files,\n    name,\n    metadata,\n    extra_options,\n    branch,\n    template_dir,\n    plugins_dir,\n    static,\n    install,\n    version_note,\n    secret,\n    extra_metadata=None,\n):\n    extra_metadata = extra_metadata or {}\n    tmp = tempfile.TemporaryDirectory()\n    saved_cwd = os.getcwd()\n\n    file_paths = [os.path.join(saved_cwd, file_path) for file_path in files]\n    file_names = [os.path.split(f)[-1] for f in files]\n\n    if metadata:\n        metadata_content = parse_metadata(metadata.read())\n    else:\n        metadata_content = {}\n    for key, value in extra_metadata.items():\n        if value:\n            metadata_content[key] = value\n\n    try:\n        os.chdir(tmp.name)\n\n        if metadata_content:\n            with open(\"metadata.json\", \"w\") as fp:\n                fp.write(json.dumps(metadata_content, indent=2))\n\n        with open(\"runtime.txt\", \"w\") as fp:\n            fp.write(\"python-3.11.0\")\n\n        if branch:\n            install = [\n                f\"https://github.com/simonw/datasette/archive/{branch}.zip\"\n            ] + list(install)\n        else:\n            install = [\"datasette\"] + list(install)\n\n        with open(\"requirements.txt\", \"w\") as fp:\n            fp.write(\"\\n\".join(install))\n        os.mkdir(\"bin\")\n        with open(\"bin/post_compile\", \"w\") as fp:\n            fp.write(\"datasette inspect --inspect-file inspect-data.json\")\n\n        extras = []\n        if template_dir:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, template_dir),\n                os.path.join(tmp.name, \"templates\"),\n            )\n            extras.extend([\"--template-dir\", \"templates/\"])\n        if plugins_dir:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, plugins_dir), os.path.join(tmp.name, \"plugins\")\n            )\n            extras.extend([\"--plugins-dir\", \"plugins/\"])\n        if version_note:\n            extras.extend([\"--version-note\", version_note])\n        if metadata_content:\n            extras.extend([\"--metadata\", \"metadata.json\"])\n        if extra_options:\n            extras.extend(extra_options.split())\n        for mount_point, path in static:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, path), os.path.join(tmp.name, mount_point)\n            )\n            extras.extend([\"--static\", f\"{mount_point}:{mount_point}\"])\n\n        quoted_files = \" \".join(\n            [\"-i {}\".format(shlex.quote(file_name)) for file_name in file_names]\n        )\n        procfile_cmd = \"web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}\".format(\n            quoted_files=quoted_files, extras=\" \".join(extras)\n        )\n        with open(\"Procfile\", \"w\") as fp:\n            fp.write(procfile_cmd)\n\n        for path, filename in zip(file_paths, file_names):\n            link_or_copy(path, os.path.join(tmp.name, filename))\n\n        yield\n\n    finally:\n        tmp.cleanup()\n        os.chdir(saved_cwd)\n"
  },
  {
    "path": "datasette/renderer.py",
    "content": "import json\nfrom datasette.utils import (\n    value_as_boolean,\n    remove_infinites,\n    CustomJSONEncoder,\n    path_from_row_pks,\n    sqlite3,\n)\nfrom datasette.utils.asgi import Response\n\n\ndef convert_specific_columns_to_json(rows, columns, json_cols):\n    json_cols = set(json_cols)\n    if not json_cols.intersection(columns):\n        return rows\n    new_rows = []\n    for row in rows:\n        new_row = []\n        for value, column in zip(row, columns):\n            if column in json_cols:\n                try:\n                    value = json.loads(value)\n                except (TypeError, ValueError):\n                    pass\n            new_row.append(value)\n        new_rows.append(new_row)\n    return new_rows\n\n\ndef json_renderer(request, args, data, error, truncated=None):\n    \"\"\"Render a response as JSON\"\"\"\n    status_code = 200\n\n    # Handle the _json= parameter which may modify data[\"rows\"]\n    json_cols = []\n    if \"_json\" in args:\n        json_cols = args.getlist(\"_json\")\n    if json_cols and \"rows\" in data and \"columns\" in data:\n        data[\"rows\"] = convert_specific_columns_to_json(\n            data[\"rows\"], data[\"columns\"], json_cols\n        )\n\n    # unless _json_infinity=1 requested, replace infinity with None\n    if \"rows\" in data and not value_as_boolean(args.get(\"_json_infinity\", \"0\")):\n        data[\"rows\"] = [remove_infinites(row) for row in data[\"rows\"]]\n\n    # Deal with the _shape option\n    shape = args.get(\"_shape\", \"objects\")\n    # if there's an error, ignore the shape entirely\n    data[\"ok\"] = True\n    if error:\n        shape = \"objects\"\n        status_code = 400\n        data[\"error\"] = error\n        data[\"ok\"] = False\n\n    if truncated is not None:\n        data[\"truncated\"] = truncated\n    if shape == \"arrayfirst\":\n        if not data[\"rows\"]:\n            data = []\n        elif isinstance(data[\"rows\"][0], sqlite3.Row):\n            data = [row[0] for row in data[\"rows\"]]\n        else:\n            assert isinstance(data[\"rows\"][0], dict)\n            data = [next(iter(row.values())) for row in data[\"rows\"]]\n    elif shape in (\"objects\", \"object\", \"array\"):\n        columns = data.get(\"columns\")\n        rows = data.get(\"rows\")\n        if rows and columns and not isinstance(rows[0], dict):\n            data[\"rows\"] = [dict(zip(columns, row)) for row in rows]\n        if shape == \"object\":\n            shape_error = None\n            if \"primary_keys\" not in data:\n                shape_error = \"_shape=object is only available on tables\"\n            else:\n                pks = data[\"primary_keys\"]\n                if not pks:\n                    shape_error = (\n                        \"_shape=object not available for tables with no primary keys\"\n                    )\n                else:\n                    object_rows = {}\n                    for row in data[\"rows\"]:\n                        pk_string = path_from_row_pks(row, pks, not pks)\n                        object_rows[pk_string] = row\n                    data = object_rows\n            if shape_error:\n                data = {\"ok\": False, \"error\": shape_error}\n        elif shape == \"array\":\n            data = data[\"rows\"]\n\n    elif shape == \"arrays\":\n        if not data[\"rows\"]:\n            pass\n        elif isinstance(data[\"rows\"][0], sqlite3.Row):\n            data[\"rows\"] = [list(row) for row in data[\"rows\"]]\n        else:\n            data[\"rows\"] = [list(row.values()) for row in data[\"rows\"]]\n    else:\n        status_code = 400\n        data = {\n            \"ok\": False,\n            \"error\": f\"Invalid _shape: {shape}\",\n            \"status\": 400,\n            \"title\": None,\n        }\n\n    # Don't include \"columns\" in output\n    # https://github.com/simonw/datasette/issues/2136\n    if isinstance(data, dict) and \"columns\" not in request.args.getlist(\"_extra\"):\n        data.pop(\"columns\", None)\n\n    # Handle _nl option for _shape=array\n    nl = args.get(\"_nl\", \"\")\n    if nl and shape == \"array\":\n        body = \"\\n\".join(json.dumps(item, cls=CustomJSONEncoder) for item in data)\n        content_type = \"text/plain\"\n    else:\n        body = json.dumps(data, cls=CustomJSONEncoder)\n        content_type = \"application/json; charset=utf-8\"\n    headers = {}\n    return Response(\n        body, status=status_code, headers=headers, content_type=content_type\n    )\n"
  },
  {
    "path": "datasette/resources.py",
    "content": "\"\"\"Core resource types for Datasette's permission system.\"\"\"\n\nfrom datasette.permissions import Resource\n\n\nclass DatabaseResource(Resource):\n    \"\"\"A database in Datasette.\"\"\"\n\n    name = \"database\"\n    parent_class = None  # Top of the resource hierarchy\n\n    def __init__(self, database: str):\n        super().__init__(parent=database, child=None)\n\n    @classmethod\n    async def resources_sql(cls, datasette, actor=None) -> str:\n        return \"\"\"\n            SELECT database_name AS parent, NULL AS child\n            FROM catalog_databases\n        \"\"\"\n\n\nclass TableResource(Resource):\n    \"\"\"A table in a database.\"\"\"\n\n    name = \"table\"\n    parent_class = DatabaseResource\n\n    def __init__(self, database: str, table: str):\n        super().__init__(parent=database, child=table)\n\n    @classmethod\n    async def resources_sql(cls, datasette, actor=None) -> str:\n        return \"\"\"\n            SELECT database_name AS parent, table_name AS child\n            FROM catalog_tables\n            UNION ALL\n            SELECT database_name AS parent, view_name AS child\n            FROM catalog_views\n        \"\"\"\n\n\nclass QueryResource(Resource):\n    \"\"\"A canned query in a database.\"\"\"\n\n    name = \"query\"\n    parent_class = DatabaseResource\n\n    def __init__(self, database: str, query: str):\n        super().__init__(parent=database, child=query)\n\n    @classmethod\n    async def resources_sql(cls, datasette, actor=None) -> str:\n        from datasette.plugins import pm\n        from datasette.utils import await_me_maybe\n\n        # Get all databases from catalog\n        db = datasette.get_internal_database()\n        result = await db.execute(\"SELECT database_name FROM catalog_databases\")\n        databases = [row[0] for row in result.rows]\n\n        # Gather canned queries for this actor from all databases.\n        # This keeps allowed_resources(\"view-query\", actor=...) consistent with\n        # actor-specific canned_queries() implementations.\n        query_pairs = []\n        for database_name in databases:\n            # Call the hook to get queries (including from config via default plugin)\n            for queries_result in pm.hook.canned_queries(\n                datasette=datasette,\n                database=database_name,\n                actor=actor,\n            ):\n                queries = await await_me_maybe(queries_result)\n                if queries:\n                    for query_name in queries.keys():\n                        query_pairs.append((database_name, query_name))\n\n        # Build SQL\n        if not query_pairs:\n            return \"SELECT NULL AS parent, NULL AS child WHERE 0\"\n\n        # Generate UNION ALL query\n        selects = []\n        for db_name, query_name in query_pairs:\n            # Escape single quotes by doubling them\n            db_escaped = db_name.replace(\"'\", \"''\")\n            query_escaped = query_name.replace(\"'\", \"''\")\n            selects.append(\n                f\"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child\"\n            )\n\n        return \" UNION ALL \".join(selects)\n"
  },
  {
    "path": "datasette/sql_functions.py",
    "content": "from datasette import hookimpl\nfrom datasette.utils import escape_fts\n\n\n@hookimpl\ndef prepare_connection(conn):\n    conn.create_function(\"escape_fts\", 1, escape_fts)\n"
  },
  {
    "path": "datasette/static/app.css",
    "content": "/* Reset and Page Setup ==================================================== */\n\n/* Reset from http://meyerweb.com/eric/tools/css/reset/\n   v2.0 | 20110126\n   License: none (public domain)\n*/\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 0;\n\tfont-size: 100%;\n\tfont: inherit;\n\tvertical-align: baseline;\n}\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n\tdisplay: block;\n}\nbody {\n\tline-height: 1;\n}\nol,\nul {\n\tlist-style: none;\n}\nblockquote,\nq {\n\tquotes: none;\n}\nblockquote:before,\nblockquote:after,\nq:before,\nq:after {\n\tcontent: '';\n\tcontent: none;\n}\ntable {\n\tborder-collapse: collapse;\n\tborder-spacing: 0;\n}\nth {\n    padding-right: 1em;\n    white-space: nowrap;\n}\nstrong {\n    font-weight: bold;\n}\nem {\n    font-style: italic;\n}\n/* end reset */\n\n/* Modal CSS variables (shared by web components via Shadow DOM) */\n:root {\n    --modal-backdrop-bg: rgba(0, 0, 0, 0.5);\n    --modal-backdrop-blur: blur(4px);\n    --modal-border-radius: 0.75rem;\n    --modal-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);\n    --modal-animation-duration: 0.2s;\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    font-family: \"Helvetica Neue\", sans-serif;\n    font-size: 1rem;\n    font-weight: 400;\n    line-height: 1.5;\n    color: #111A35;\n    text-align: left;\n    background-color: #F8FAFB;\n}\n\n/* Helper Styles  ===========================================================*/\n\n.intro {\n  font-size: 1rem;\n}\n.metadata-description {\n  margin-bottom: 1em;\n}\np {\n  margin: 0 0 0.75rem 0;\n  padding: 0;\n}\n.meta {\n  color: rgba(0,0,0,0.3);\n  font-size: 0.75rem\n}\n.intro {\n\tfont-size: 1.5rem;\n  margin-bottom: 0.75rem;\n}\n.context-text {\n\t/* for accessibility and hidden from sight */\n\ttext-indent: -999em;\n\tdisplay: block;\n\twidth:0;\n\toverflow: hidden;\n\tmargin: 0;\n\tpadding: 0;\n\tline-height: 0;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.header1,\n.header2,\n.header3,\n.header4,\n.header5,\n.header6 {\n  font-weight: 700;\n  font-size: 1rem;\n  margin: 0;\n  padding: 0;\n  word-break: break-word;\n}\nh1,\n.header1 {\n  font-size: 2rem;\n  margin-bottom: 0.75rem;\n  margin-top: 1rem;\n}\nh2,\n.header2 {\n  font-size: 1.5rem;\n  margin-bottom: 0.75rem;\n  margin-top: 1rem;\n}\nh3,\n.header3 {\n  font-size: 1.25rem;\n  margin: 1rem 0 0.25rem 0;\n}\nh4,\n.header4 {\n  margin: 1rem 0 0.25rem 0;\n  font-weight: 400;\n  text-decoration: underline;\n}\nh5,\n.header5 {\n  margin: 1rem 0 0.25rem 0;\n  font-weight: 700;\n  text-decoration: underline;\n}\nh6,\n.header6 {\n  margin: 1rem 0 0.25rem 0;\n  font-weight: 400;\n  font-style: italic;\n  text-decoration: underline;\n}\n\n.page-header {\n  padding-left: 10px;\n  border-left: 10px solid #666;\n  margin-bottom: 0.75rem;\n  margin-top: 1rem;\n}\n.page-header h1 {\n    margin: 0;\n    font-size: 2rem;\n    padding-right: 0.2em;\n}\n\n.page-action-menu details > summary {\n    list-style: none;\n    cursor: pointer;\n}\n.page-action-menu details > summary::-webkit-details-marker {\n    display: none;\n}\n\ndiv,\nsection,\narticle,\nheader,\nnav,\nfooter,\n.wrapper {\n\tdisplay: block;\n\tbox-sizing: border-box;\n}\n\na:link {\n\tcolor: #276890;\n\ttext-decoration: underline;\n}\na:visited {\n\tcolor: #54AC8E;\n\ttext-decoration: underline;\n}\na:hover,\na:focus,\na:active  {\n\tcolor: #67C98D;\n\ttext-decoration: underline;\n}\n\nbutton.button-as-link {\n    background: none;\n    border: none;\n    padding: 0;\n    color: #276890;\n    text-decoration: underline;\n    cursor: pointer;\n    font-size: 1rem;\n}\nbutton.button-as-link:hover,\nbutton.button-as-link:focus {\n\tcolor: #67C98D;\n}\n\ncode,\npre {\n  font-family: monospace;\n}\n\nul.bullets,\nul.tight-bullets,\nul.spaced,\nol.spaced {\n\tmargin-bottom: 0.8rem;\n}\nul.bullets,\nul.tight-bullets {\n\tpadding-left: 1.25rem;\n}\nul.bullets li,\nul.spaced li,\nol.spaced li {\n\tmargin-bottom: 0.4rem;\n}\nul.bullets li {\n\tlist-style-type: circle;\n}\nul.tight-bullets li {\n    list-style-type: disc;\n    margin-bottom: 0;\n    word-break: break-all;\n}\na.not-underlined {\n    text-decoration: none;\n}\n.not-underlined .underlined {\n    text-decoration: underline;\n}\n\n/* Page Furniture ========================================================= */\n/* Header */\nheader.hd,\nfooter.ft {\n    padding: 0.6rem 1rem 0.5rem 1rem;\n    background-color: #276890;\n    background: linear-gradient(180deg, rgba(96,144,173,1) 0%, rgba(39,104,144,1) 50%);\n    color: rgba(255,255,244,0.9);\n    overflow: hidden;\n    box-sizing: border-box;\n    min-height: 2.6rem;\n}\nfooter.ft {\n    margin-top: 1rem;\n}\nheader.hd p,\nfooter.ft p {\n    margin: 0;\n    padding: 0;\n}\nheader.hd .crumbs {\n    float: left;\n}\nheader.hd .actor {\n    float: right;\n    text-align: right;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    position: relative;\n    top: -3px;\n}\n\nfooter.ft a:link,\nfooter.ft a:visited,\nfooter.ft a:hover,\nfooter.ft a:focus,\nfooter.ft a:active,\nfooter.ft button.button-as-link {\n    color: rgba(255,255,244,0.8);\n}\nheader.hd a:link,\nheader.hd a:visited,\nheader.hd a:hover,\nheader.hd a:focus,\nheader.hd a:active,\nheader.hd button.button-as-link {\n    color: rgba(255,255,244,0.8);\n    text-decoration: none;\n}\n\nfooter.ft a:hover,\nfooter.ft a:focus,\nfooter.ft a:active,\nfooter.ft .button-as-link:hover,\nfooter.ft .button-as-link:focus,\nheader.hd a:hover,\nheader.hd a:focus,\nheader.hd a:active,\nbutton.button-as-link:hover,\nbutton.button-as-link:focus {\n\tcolor: rgba(255,255,244,1);\n}\n\n\n/* Body */\nsection.content {\n    margin: 0 1rem;\n}\n\n/* Navigation menu */\ndetails.nav-menu > summary {\n    list-style: none;\n    display: inline;\n    float: right;\n    position: relative;\n    cursor: pointer;\n}\ndetails.nav-menu > summary::-webkit-details-marker {\n    display: none;\n}\ndetails .nav-menu-inner {\n    position: absolute;\n    top: 2.6rem;\n    right: 10px;\n    width: 180px;\n    background-color: #276890;\n    z-index: 1000;\n    padding: 0;\n}\n.nav-menu-inner li,\nform.nav-menu-logout {\n    padding: 0.3rem 0.5rem;\n    border-top: 1px solid #ffffff69;\n}\n.nav-menu-inner a {\n    display: block;\n}\n\n/* Table/database actions menu */\n.page-action-menu {\n    position: relative;\n    margin-bottom: 0.5em;\n}\n.actions-menu-links {\n    display: inline;\n}\n.actions-menu-links .dropdown-menu {\n    position: absolute;\n    top: calc(100% + 10px);\n    left: 0;\n    z-index: 10000;\n}\n.page-action-menu .icon-text {\n    display: inline-flex;\n    align-items: center;\n    border-radius: .25rem;\n    padding: 5px 12px 3px 7px;\n    color: #fff;\n    font-weight: 400;\n    font-size: 0.8em;\n    background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);\n    border-color: #007bff;\n}\n.page-action-menu .icon-text span {\n    /* Nudge text up a bit */\n    position: relative;\n    top: -2px;\n}\n.page-action-menu .icon-text:hover {\n    cursor: pointer;\n}\n.page-action-menu .icon {\n    width: 18px;\n    height: 18px;\n    margin-right: 4px;\n}\n\n/* Components ============================================================== */\n\n\nh2 em {\n    font-style: normal;\n    font-weight: lighter;\n}\n\n/* Messages */\n\n.message-info,\n.message-warning,\n.message-error {\n    padding: 1rem;\n    margin-bottom: 1rem;\n    background-color: rgba(103,201,141,0.3);\n}\n.message-warning {\n    background-color: rgba(245,166,35,0.3);\n}\n.message-error {\n    background-color: rgba(208,2,27,0.3);\n}\n\n.pattern-heading {\n  padding: 1rem;\n  margin-top: 2rem;\n  border-top: 1px solid rgba(208,2,27,0.8);\n  border-bottom: 1px solid rgba(208,2,27,0.8);\n  background-color: rgba(208,2,27,0.2)\n}\n\n/* URL arguments */\n.extra-wheres ul,\n.extra-wheres li {\n    list-style-type: none;\n    padding: 0;\n    margin: 0;\n}\n\n.wrapped-sql {\n    white-space: pre-wrap;\n    margin: 1rem 0;\n    font-family: monospace;\n}\n\n/* Tables ================================================================== */\n.table-wrapper {\n    overflow-x: auto;\n}\ntable.rows-and-columns {\n    border-collapse: collapse;\n}\ntable.rows-and-columns td {\n    border-top: 1px solid #aaa;\n    border-right: 1px solid #eee;\n    padding: 4px;\n    vertical-align: top;\n    white-space: pre-wrap;\n}\ntable.rows-and-columns td.type-pk {\n    font-weight: bold;\n}\ntable.rows-and-columns td em {\n    font-style: normal;\n    font-size: 0.8em;\n    color: #aaa;\n}\ntable.rows-and-columns th {\n    padding-right: 1em;\n}\ntable.rows-and-columns a:link {\n    text-decoration: none;\n}\n.rows-and-columns td ol,\n.rows-and-columns td ul {\n    list-style: initial;\n    list-style-position: inside;\n}\na.blob-download {\n    display: inline-block;\n}\n.db-table p {\n    margin-top: 0;\n    margin-bottom: 0.3em;\n}\n.db-table h2 {\n    margin-top: 1em;\n    margin-bottom: 0;\n}\n\n/* Forms =================================================================== */\n\nform.sql textarea {\n    border: 1px solid #ccc;\n    width: 70%;\n    height: 3em;\n    padding: 4px;\n    font-family: monospace;\n    font-size: 1.3em;\n}\nform.sql label {\n    width: 15%;\n}\n.advanced-export input[type=submit] {\n    font-size: 0.6em;\n    margin-left: 1em;\n}\nlabel.sort_by_desc {\n    padding-right: 1em;\n}\npre#sql-query {\n    margin-bottom: 1em;\n}\n\n.core label,\nlabel.core {\n    font-weight: bold;\n    display: inline-block;\n}\n\n.core input[type=text],\ninput.core[type=text],\n.core input[type=search],\ninput.core[type=search] {\n    border: 1px solid #ccc;\n    border-radius: 3px;\n    width: 60%;\n    padding: 9px 4px;\n    display: inline-block;\n    font-size: 1em;\n    font-family: Helvetica, sans-serif;\n}\n.core input[type=search],\ninput.core[type=search] {\n    /* Stop Webkit from styling search boxes in an inconsistent way */\n    /* https://css-tricks.com/webkit-html5-search-inputs/ comments */\n    -webkit-appearance: textfield;\n}\n.core input[type=\"search\"]::-webkit-search-decoration,\ninput.core[type=\"search\"]::-webkit-search-decoration,\n.core input[type=\"search\"]::-webkit-search-cancel-button,\ninput.core[type=\"search\"]::-webkit-search-cancel-button,\n.core input[type=\"search\"]::-webkit-search-results-button,\ninput.core[type=\"search\"]::-webkit-search-results-button,\n.core input[type=\"search\"]::-webkit-search-results-decoration,\ninput.core[type=\"search\"]::-webkit-search-results-decoration {\n    display: none;\n}\n\n.core input[type=submit],\n.core button[type=button],\ninput.core[type=submit],\nbutton.core[type=button] {\n    font-weight: 400;\n    cursor: pointer;\n    text-align: center;\n    vertical-align: middle;\n    border-width: 1px;\n    border-style: solid;\n    padding: .5em 0.8em;\n    font-size: 0.9rem;\n    line-height: 1;\n    border-radius: .25rem;\n}\n\n.core input[type=submit],\ninput.core[type=submit] {\n    color: #fff;\n    background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);\n    border-color: #007bff;\n    -webkit-appearance: button;\n}\n\n.core button[type=button],\nbutton.core[type=button] {\n    color: #007bff;\n    background-color: #fff;\n    border-color: #007bff;\n}\n\n.filter-row {\n    margin-bottom: 0.6em;\n}\n.search-row {\n    margin-bottom: 1.8em;\n}\n\n.search-row label {\n    font-size: 1.2em;\n    padding-right: 0.5em;\n    display: inline-block;\n    width: 80px;\n}\n\n.select-wrapper {\n    border: 1px solid #ccc;\n    width: 120px;\n    border-radius: 3px;\n    padding: 0;\n    background-color: #fafafa;\n    position: relative;\n    display: inline-block;\n    margin-right: 0.3em;\n}\n.select-wrapper:focus-within {\n    border: 1px solid black;\n}\n.select-wrapper.filter-op {\n    width: 80px;\n}\n.select-wrapper::after {\n    content: \"\\25BE\";\n    position: absolute;\n    top: 0px;\n    right: 0.4em;\n    color: #bbb;\n    pointer-events: none;\n    font-size: 1.2em;\n    padding-top: 0.16em;\n}\n\n.select-wrapper select {\n    padding: 9px 8px;\n    width: 100%;\n    border: none;\n    box-shadow: none;\n    background: transparent;\n    background-image: none;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n    cursor: pointer;\n}\n.select-wrapper select {\n    font-size: 1em;\n    font-family: Helvetica, sans-serif;\n}\n.select-wrapper option {\n    font-size: 1em;\n    font-family: Helvetica, sans-serif;\n}\n\n.select-wrapper select:focus {\n    outline: none;\n}\n.filters {\n    font-size: 0.8em;\n}\n.filters input.filter-value {\n    width: 200px;\n    border-radius: 3px;\n    -webkit-appearance: none;\n    padding: 9px 4px;\n    font-size: 16px;\n    font-family: Helvetica, sans-serif;\n}\n\n#_search {\n    font-size: 16px;\n}\n\n\n\n\n.facet-results {\n    display: flex;\n    flex-direction: row;\n    flex-wrap: wrap;\n}\n.facet-info {\n    width: 250px;\n    margin-right: 15px;\n}\n.facet-info-total {\n    font-size: 0.8em;\n    color: #666;\n    padding-right: 0.25em;\n}\n.facet-info li,\n.facet-info ul {\n    margin: 0;\n    padding: 0;\n}\n.facet-info ul {\n    padding-left: 1.25em;\n    margin-bottom: 1em;\n}\n.facet-info a.cross:link,\n.facet-info a.cross:visited,\n.facet-info a.cross:hover,\n.facet-info a.cross:focus,\n.facet-info a.cross:active {\n    text-decoration: none;\n}\nul li.facet-truncated {\n    list-style-type: none;\n    position: relative;\n    top: -0.35em;\n    text-indent: 0.85em;\n}\n\n.advanced-export {\n    margin-top: 1em;\n    padding: 0.01em 2em 0.01em 1em;\n    width: auto;\n    display: inline-block;\n    box-shadow: 1px 2px 8px 2px rgba(0,0,0,0.08);\n\tbackground-color: white;\n}\n\n.download-sqlite em {\n    font-style: normal;\n    font-size: 0.8em;\n}\n\n\n\np.zero-results {\n    border: 2px solid #ccc;\n    background-color: #eee;\n    padding: 0.5em;\n    font-style: italic;\n}\n\n/* Value types */\n.type-float, .type-int {\n    color: #666;\n}\n\n\n\n\n\n\n/* Overrides ===============================================================*/\n\n.small-screen-only,\n.select-wrapper.small-screen-only {\n    display: none;\n}\n\n@keyframes datasette-modal-slide-in {\n    from {\n        opacity: 0;\n        transform: translateY(-20px) scale(0.95);\n    }\n    to {\n        opacity: 1;\n        transform: translateY(0) scale(1);\n    }\n}\n\n@keyframes datasette-modal-fade-in {\n    from { opacity: 0; }\n    to { opacity: 1; }\n}\n\ndialog.mobile-column-actions-dialog {\n    --ink: #0f0f0f;\n    --paper: #f5f3ef;\n    --muted: #6b6b6b;\n    --rule: #e2dfd8;\n    --accent: #1a56db;\n    --card: #ffffff;\n    border: none;\n    border-radius: var(--modal-border-radius, 0.75rem);\n    padding: 0;\n    margin: auto;\n    width: min(420px, calc(100vw - 32px));\n    max-width: 95vw;\n    max-height: min(640px, calc(100vh - 32px));\n    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));\n    animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;\n    overflow: hidden;\n    font-family: system-ui, -apple-system, sans-serif;\n    background: var(--card);\n}\n\ndialog.mobile-column-actions-dialog[open] {\n    display: flex;\n    flex-direction: column;\n}\n\ndialog.mobile-column-actions-dialog::backdrop {\n    background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));\n    backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n    -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n    animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;\n}\n\n.mobile-column-actions-dialog .modal-header {\n    padding: 20px 24px 16px;\n    border-bottom: 1px solid var(--rule);\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n    flex-shrink: 0;\n}\n\n.mobile-column-actions-dialog .modal-title {\n    font-size: 1rem;\n    font-weight: 600;\n    color: var(--ink);\n}\n\n.mobile-column-actions-dialog .modal-meta {\n    font-family: ui-monospace, monospace;\n    font-size: 0.7rem;\n    color: var(--muted);\n    background: var(--paper);\n    padding: 3px 9px;\n    border-radius: 20px;\n}\n\n.mobile-column-actions-dialog .list-wrap {\n    flex: 1;\n    overflow-y: auto;\n    overflow-x: hidden;\n    position: relative;\n    overscroll-behavior: contain;\n    -webkit-overflow-scrolling: touch;\n}\n\n.mobile-column-actions-dialog .list-wrap::before,\n.mobile-column-actions-dialog .list-wrap::after {\n    content: \"\";\n    position: sticky;\n    display: block;\n    left: 0;\n    right: 0;\n    height: 20px;\n    pointer-events: none;\n    z-index: 5;\n}\n\n.mobile-column-actions-dialog .list-wrap::before {\n    top: 0;\n    background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);\n}\n\n.mobile-column-actions-dialog .list-wrap::after {\n    bottom: 0;\n    background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);\n    margin-top: -20px;\n}\n\n.mobile-column-top-actions {\n    padding: 10px 24px 0;\n}\n\n.mobile-column-top-action {\n    display: inline-block;\n    text-decoration: none;\n}\n\n.mobile-column-section {\n    border-bottom: 1px solid var(--rule);\n}\n\n.mobile-column-actions-dialog .col-header {\n    width: 100%;\n    padding: 12px 24px;\n    font: inherit;\n    font-weight: 600;\n    border: 0;\n    background: none;\n    cursor: pointer;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    text-align: left;\n}\n\n.mobile-column-header-text {\n    display: flex;\n    flex-direction: column;\n    gap: 0.15rem;\n}\n\n.mobile-column-name {\n    color: var(--ink);\n}\n\n.mobile-column-meta {\n    color: var(--muted);\n    font-size: 0.78em;\n    font-family: ui-monospace, monospace;\n    font-weight: normal;\n}\n\n.mobile-column-chevron {\n    color: var(--muted);\n    transition: transform 0.2s ease-out;\n}\n\n.mobile-column-actions-dialog .col-header[aria-expanded=\"true\"] .mobile-column-chevron {\n    transform: rotate(180deg);\n}\n\n.mobile-column-actions-dialog .col-actions[hidden] {\n    display: none;\n}\n\n.mobile-column-actions-dialog .col-actions ul,\n.mobile-column-actions-dialog .col-actions li {\n    margin: 0;\n    padding: 0;\n    list-style-type: none;\n}\n\n.mobile-column-actions-dialog .col-actions a,\n.mobile-column-actions-dialog .col-actions button {\n    display: block;\n    width: 100%;\n    padding: 10px 24px 10px 40px;\n    color: var(--ink);\n    text-align: left;\n    font: inherit;\n    text-decoration: none;\n    background: none;\n    border: 0;\n    border-top: 1px solid #f5f5f5;\n    cursor: pointer;\n}\n\n.mobile-column-actions-dialog .col-actions a:hover,\n.mobile-column-actions-dialog .col-actions button:hover {\n    background: var(--paper);\n}\n\n.mobile-column-actions-dialog .col-actions a:active,\n.mobile-column-actions-dialog .col-actions button:active {\n    background: #eee;\n}\n\n.mobile-column-description,\n.mobile-column-no-actions {\n    margin: 0;\n    padding: 0 24px 12px 24px;\n    color: var(--muted);\n    font-size: 0.85em;\n}\n\n.mobile-column-actions-dialog .modal-footer {\n    padding: 14px 20px;\n    border-top: 1px solid var(--rule);\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    flex-shrink: 0;\n    background: var(--paper);\n}\n\n.mobile-column-actions-dialog .footer-info {\n    flex: 1;\n    font-family: ui-monospace, monospace;\n    font-size: 0.68rem;\n    color: var(--muted);\n}\n\n.mobile-column-actions-dialog .btn {\n    border: none;\n    border-radius: 5px;\n    padding: 9px 20px;\n    font-size: 0.85rem;\n    font-weight: 500;\n    cursor: pointer;\n    touch-action: manipulation;\n    font-family: inherit;\n    transition: background 0.12s;\n}\n\n.mobile-column-actions-dialog .btn-ghost {\n    background: transparent;\n    color: var(--muted);\n    border: 1px solid var(--rule);\n}\n\n.mobile-column-actions-dialog .btn-ghost:hover {\n    background: var(--rule);\n    color: var(--ink);\n}\n\ndialog.set-column-type-dialog {\n    --ink: #0f0f0f;\n    --paper: #f5f3ef;\n    --muted: #6b6b6b;\n    --rule: #e2dfd8;\n    --accent: #1a56db;\n    --card: #ffffff;\n    border: none;\n    border-radius: var(--modal-border-radius, 0.75rem);\n    padding: 0;\n    margin: auto;\n    width: min(520px, calc(100vw - 32px));\n    max-width: 95vw;\n    max-height: min(720px, calc(100vh - 32px));\n    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));\n    animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;\n    overflow: hidden;\n    font-family: system-ui, -apple-system, sans-serif;\n    background: var(--card);\n}\n\ndialog.set-column-type-dialog[open] {\n    display: flex;\n    flex-direction: column;\n}\n\ndialog.set-column-type-dialog::backdrop {\n    background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));\n    backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n    -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n    animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;\n}\n\n.set-column-type-dialog .modal-header {\n    padding: 20px 24px 12px;\n    border-bottom: 1px solid var(--rule);\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n    flex-shrink: 0;\n}\n\n.set-column-type-dialog .modal-title {\n    font-size: 1rem;\n    font-weight: 600;\n    color: var(--ink);\n}\n\n.set-column-type-dialog .modal-meta {\n    font-family: ui-monospace, monospace;\n    font-size: 0.7rem;\n    color: var(--muted);\n    background: var(--paper);\n    padding: 3px 9px;\n    border-radius: 20px;\n}\n\n.set-column-type-status,\n.set-column-type-empty,\n.set-column-type-error {\n    margin: 0;\n    padding: 12px 24px 0;\n}\n\n.set-column-type-status,\n.set-column-type-empty {\n    color: var(--muted);\n    font-size: 0.9rem;\n}\n\n.set-column-type-error {\n    color: #b91c1c;\n    font-size: 0.9rem;\n}\n\n.set-column-type-options {\n    padding: 16px 24px 24px;\n    overflow-y: auto;\n    display: grid;\n    gap: 12px;\n}\n\n.set-column-type-option {\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: 12px;\n    align-items: start;\n    padding: 14px 16px;\n    border: 1px solid var(--rule);\n    border-radius: 8px;\n    background: #fcfbf9;\n    cursor: pointer;\n}\n\n.set-column-type-option:focus-within {\n    border-color: var(--accent);\n    box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12);\n}\n\n.set-column-type-option input {\n    margin-top: 3px;\n}\n\n.set-column-type-option-content {\n    display: grid;\n    gap: 4px;\n}\n\n.set-column-type-option-name {\n    font-family: ui-monospace, monospace;\n    font-size: 0.95rem;\n    color: var(--ink);\n}\n\n.set-column-type-option-description {\n    color: var(--muted);\n    font-size: 0.9rem;\n}\n\n.set-column-type-dialog .modal-footer {\n    padding: 14px 20px;\n    border-top: 1px solid var(--rule);\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    flex-shrink: 0;\n    background: var(--paper);\n}\n\n.set-column-type-dialog .footer-info {\n    flex: 1;\n    font-family: ui-monospace, monospace;\n    font-size: 0.68rem;\n    color: var(--muted);\n}\n\n.set-column-type-dialog .btn {\n    border: none;\n    border-radius: 5px;\n    padding: 9px 20px;\n    font-size: 0.85rem;\n    font-weight: 500;\n    cursor: pointer;\n    touch-action: manipulation;\n    font-family: inherit;\n    transition: background 0.12s;\n}\n\n.set-column-type-dialog .btn-ghost {\n    background: transparent;\n    color: var(--muted);\n    border: 1px solid var(--rule);\n}\n\n.set-column-type-dialog .btn-ghost:hover {\n    background: var(--rule);\n    color: var(--ink);\n}\n\n.set-column-type-dialog .btn-primary {\n    background: var(--accent);\n    color: #fff;\n}\n\n.set-column-type-dialog .btn-primary:hover {\n    background: #1949b8;\n}\n\n.set-column-type-dialog .btn:disabled {\n    opacity: 0.65;\n    cursor: wait;\n}\n\n@media (max-width: 640px) {\n    dialog.mobile-column-actions-dialog {\n        width: 95vw;\n        max-height: 85vh;\n        border-radius: 0.5rem;\n    }\n\n    .mobile-column-actions-dialog .modal-header {\n        padding: 16px 18px 14px;\n    }\n\n    .mobile-column-top-actions {\n        padding-left: 18px;\n        padding-right: 18px;\n    }\n\n    .mobile-column-actions-dialog .col-header {\n        padding-left: 18px;\n        padding-right: 18px;\n    }\n\n    .mobile-column-actions-dialog .col-actions a,\n    .mobile-column-actions-dialog .col-actions button {\n        padding-left: 34px;\n        padding-right: 18px;\n    }\n\n    .mobile-column-description,\n    .mobile-column-no-actions {\n        padding-left: 18px;\n        padding-right: 18px;\n    }\n\n    dialog.set-column-type-dialog {\n        width: 95vw;\n        max-height: 85vh;\n        border-radius: 0.5rem;\n    }\n\n    .set-column-type-dialog .modal-header,\n    .set-column-type-status,\n    .set-column-type-empty,\n    .set-column-type-error,\n    .set-column-type-options {\n        padding-left: 18px;\n        padding-right: 18px;\n    }\n}\n\n@media only screen and (max-width: 576px) {\n\n    .small-screen-only {\n        display: initial;\n    }\n    .select-wrapper.small-screen-only {\n        display: inline-block;\n    }\n\n    form.sql textarea {\n        width: 95%;\n    }\n    /* Force table to not be like tables anymore */\n    table.rows-and-columns,\n    .rows-and-columns thead,\n    .rows-and-columns tbody,\n    .rows-and-columns th,\n    .rows-and-columns td,\n    .rows-and-columns tr {\n        display: block;\n    }\n\n    /* Hide table headers (but not display: none;, for accessibility) */\n    .rows-and-columns thead tr {\n        position: absolute;\n        top: -9999px;\n        left: -9999px;\n    }\n\n    table.rows-and-columns tr {\n        border: 1px solid #ccc;\n        margin-bottom: 1em;\n        border-radius: 10px;\n        background-color: white;\n        padding: 0.2rem;\n    }\n\n    table.rows-and-columns td {\n        /* Behave  like a \"row\" */\n        border: none;\n        border-bottom: 1px solid #eee;\n        padding: 0;\n        padding-left: 10%;\n    }\n\n    table.rows-and-columns td:before {\n        display: block;\n        color: black;\n        margin-left: -10%;\n        font-size: 0.8em;\n    }\n\n    .select-wrapper {\n        width: 100px;\n    }\n    .select-wrapper.filter-op {\n        width: 60px;\n    }\n    .filters input.filter-value {\n        width: 140px;\n    }\n    button.choose-columns-mobile,\n    button.column-actions-mobile {\n        display: inline-flex;\n        align-items: center;\n        justify-content: center;\n        padding: 0.5rem 1rem;\n        margin-bottom: 1em;\n        font-size: 0.9rem;\n        line-height: 1.2;\n        font-family: inherit;\n        background: white;\n        border: 1px solid #ccc;\n        border-radius: 5px;\n        cursor: pointer;\n        vertical-align: top;\n        box-sizing: border-box;\n        min-height: 2.5rem;\n    }\n\n    button.column-actions-mobile {\n        gap: 0.55rem;\n    }\n\n    button.column-actions-mobile svg {\n        display: block;\n        width: 16px;\n        height: 16px;\n        flex-shrink: 0;\n    }\n\n    button.column-actions-mobile span {\n        line-height: 1.2;\n    }\n\n    button.choose-columns-mobile {\n        margin-right: 0.5rem;\n    }\n}\n\nsvg.dropdown-menu-icon {\n    display: inline-block;\n    position: relative;\n    top: 2px;\n    cursor: pointer;\n    opacity: 0.8;\n}\n.dropdown-menu {\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    line-height: 1.4;\n    font-size: 16px;\n    box-shadow: 2px 2px 2px #aaa;\n    background-color: #fff;\n    z-index: 1000;\n}\n.dropdown-menu ul,\n.dropdown-menu li {\n    list-style-type: none;\n    margin: 0;\n    padding: 0;\n}\n.dropdown-menu .dropdown-column-type {\n    font-size: 0.7em;\n    color: #666;\n    margin: 0;\n    padding: 4px 8px 4px 8px;\n}\n.dropdown-menu .dropdown-column-description {\n    margin: 0;\n    color: #666;\n    padding: 4px 8px 4px 8px;\n    max-width: 20em;\n}\n.dropdown-menu li {\n    border-bottom: 1px solid #ccc;\n}\n.dropdown-menu li:last-child {\n    border: none;\n}\n.dropdown-menu a:link,\n.dropdown-menu a:visited,\n.dropdown-menu a:hover,\n.dropdown-menu a:focus\n.dropdown-menu a:active {\n    text-decoration: none;\n    display: block;\n    padding: 4px 8px 2px 8px;\n    color: #222;\n    white-space: nowrap;\n}\n.dropdown-menu a:hover {\n    background-color: #eee;\n}\n.dropdown-menu .dropdown-description {\n    margin: 0;\n    color: #666;\n    font-size: 0.8em;\n    max-width: 80vw;\n    white-space: normal;\n}\n.dropdown-menu .hook {\n    display: block;\n    position: absolute;\n    top: -5px;\n    left: 6px;\n    width: 0;\n    height: 0;\n    border-left: 5px solid transparent;\n    border-right: 5px solid transparent;\n    border-bottom: 5px solid #666;\n}\n\n.canned-query-edit-sql {\n    padding-left: 0.5em;\n    position: relative;\n    top: 1px;\n}\n\n.blob-download {\n    display: block;\n    white-space: nowrap;\n    padding-right: 20px;\n    position: relative;\n    background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ij48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik03LjQ3IDEwLjc4YS43NS43NSAwIDAwMS4wNiAwbDMuNzUtMy43NWEuNzUuNzUgMCAwMC0xLjA2LTEuMDZMOC43NSA4LjQ0VjEuNzVhLjc1Ljc1IDAgMDAtMS41IDB2Ni42OUw0Ljc4IDUuOTdhLjc1Ljc1IDAgMDAtMS4wNiAxLjA2bDMuNzUgMy43NXpNMy43NSAxM2EuNzUuNzUgMCAwMDAgMS41aDguNWEuNzUuNzUgMCAwMDAtMS41aC04LjV6Ij48L3BhdGg+PC9zdmc+\");\n    background-size: 16px 16px;\n    background-position: right;\n    background-repeat: no-repeat;\n}\n\ndl.column-descriptions dt {\n    font-weight: bold;\n}\ndl.column-descriptions dd {\n    padding-left: 1.5em;\n    white-space: pre-wrap;\n    line-height: 1.1em;\n    color: #666;\n}\n\n.anim-scale-in {\n    animation-name: scale-in;\n    animation-duration: 0.15s;\n    animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5);\n}\n\n@keyframes scale-in {\n    0% {\n        opacity: 0;\n        transform: scale(0.6);\n    }\n    100% {\n        opacity: 1;\n        transform: scale(1);\n    }\n}\n"
  },
  {
    "path": "datasette/static/cm-editor-6.0.1.bundle.js",
    "content": "var cm=function(t){\"use strict\";class e{constructor(){}lineAt(t){if(t<0||t>this.length)throw new RangeError(`Invalid position ${t} in document of length ${this.length}`);return this.lineInner(t,!1,1,0)}line(t){if(t<1||t>this.lines)throw new RangeError(`Invalid line number ${t} in ${this.lines}-line document`);return this.lineInner(t,!0,1,0)}replace(t,e,i){let s=[];return this.decompose(0,t,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(e,this.length,s,1),n.from(s,this.length-(e-t)+i.length)}append(t){return this.replace(this.length,this.length,t)}slice(t,e=this.length){let i=[];return this.decompose(t,e,i,0),n.from(i,e-t)}eq(t){if(t==this)return!0;if(t.length!=this.length||t.lines!=this.lines)return!1;let e=this.scanIdentical(t,1),i=this.length-this.scanIdentical(t,-1),n=new o(this),s=new o(t);for(let t=e,r=e;;){if(n.next(t),s.next(t),t=0,n.lineBreak!=s.lineBreak||n.done!=s.done||n.value!=s.value)return!1;if(r+=n.value.length,n.done||r>=i)return!0}}iter(t=1){return new o(this,t)}iterRange(t,e=this.length){return new l(this,t,e)}iterLines(t,e){let i;if(null==t)i=this.iter();else{null==e&&(e=this.lines+1);let n=this.line(t).from;i=this.iterRange(n,Math.max(n,e==this.lines+1?this.length:e<=1?0:this.line(e-1).to))}return new a(i)}toString(){return this.sliceString(0)}toJSON(){let t=[];return this.flatten(t),t}static of(t){if(0==t.length)throw new RangeError(\"A document must have at least one line\");return 1!=t.length||t[0]?t.length<=32?new i(t):n.from(i.split(t,[])):e.empty}}class i extends e{constructor(t,e=function(t){let e=-1;for(let i of t)e+=i.length+1;return e}(t)){super(),this.text=t,this.length=e}get lines(){return this.text.length}get children(){return null}lineInner(t,e,i,n){for(let s=0;;s++){let r=this.text[s],o=n+r.length;if((e?i:o)>=t)return new h(n,o,i,r);n=o+1,i++}}decompose(t,e,n,o){let l=t<=0&&e>=this.length?this:new i(r(this.text,t,e),Math.min(e,this.length)-Math.max(0,t));if(1&o){let t=n.pop(),e=s(l.text,t.text.slice(),0,l.length);if(e.length<=32)n.push(new i(e,t.length+l.length));else{let t=e.length>>1;n.push(new i(e.slice(0,t)),new i(e.slice(t)))}}else n.push(l)}replace(t,e,o){if(!(o instanceof i))return super.replace(t,e,o);let l=s(this.text,s(o.text,r(this.text,0,t)),e),a=this.length+o.length-(e-t);return l.length<=32?new i(l,a):n.from(i.split(l,[]),a)}sliceString(t,e=this.length,i=\"\\n\"){let n=\"\";for(let s=0,r=0;s<=e&&r<this.text.length;r++){let o=this.text[r],l=s+o.length;s>t&&r&&(n+=i),t<l&&e>s&&(n+=o.slice(Math.max(0,t-s),e-s)),s=l+1}return n}flatten(t){for(let e of this.text)t.push(e)}scanIdentical(){return 0}static split(t,e){let n=[],s=-1;for(let r of t)n.push(r),s+=r.length+1,32==n.length&&(e.push(new i(n,s)),n=[],s=-1);return s>-1&&e.push(new i(n,s)),e}}class n extends e{constructor(t,e){super(),this.children=t,this.length=e,this.lines=0;for(let e of t)this.lines+=e.lines}lineInner(t,e,i,n){for(let s=0;;s++){let r=this.children[s],o=n+r.length,l=i+r.lines-1;if((e?l:o)>=t)return r.lineInner(t,e,i,n);n=o+1,i=l+1}}decompose(t,e,i,n){for(let s=0,r=0;r<=e&&s<this.children.length;s++){let o=this.children[s],l=r+o.length;if(t<=l&&e>=r){let s=n&((r<=t?1:0)|(l>=e?2:0));r>=t&&l<=e&&!s?i.push(o):o.decompose(t-r,e-r,i,s)}r=l+1}}replace(t,e,i){if(i.lines<this.lines)for(let s=0,r=0;s<this.children.length;s++){let o=this.children[s],l=r+o.length;if(t>=r&&e<=l){let a=o.replace(t-r,e-r,i),h=this.lines-o.lines+a.lines;if(a.lines<h>>4&&a.lines>h>>6){let r=this.children.slice();return r[s]=a,new n(r,this.length-(e-t)+i.length)}return super.replace(r,l,a)}r=l+1}return super.replace(t,e,i)}sliceString(t,e=this.length,i=\"\\n\"){let n=\"\";for(let s=0,r=0;s<this.children.length&&r<=e;s++){let o=this.children[s],l=r+o.length;r>t&&s&&(n+=i),t<l&&e>r&&(n+=o.sliceString(t-r,e-r,i)),r=l+1}return n}flatten(t){for(let e of this.children)e.flatten(t)}scanIdentical(t,e){if(!(t instanceof n))return 0;let i=0,[s,r,o,l]=e>0?[0,0,this.children.length,t.children.length]:[this.children.length-1,t.children.length-1,-1,-1];for(;;s+=e,r+=e){if(s==o||r==l)return i;let n=this.children[s],a=t.children[r];if(n!=a)return i+n.scanIdentical(a,e);i+=n.length+1}}static from(t,e=t.reduce(((t,e)=>t+e.length+1),-1)){let s=0;for(let e of t)s+=e.lines;if(s<32){let n=[];for(let e of t)e.flatten(n);return new i(n,e)}let r=Math.max(32,s>>5),o=r<<1,l=r>>1,a=[],h=0,c=-1,u=[];function f(t){let e;if(t.lines>o&&t instanceof n)for(let e of t.children)f(e);else t.lines>l&&(h>l||!h)?(d(),a.push(t)):t instanceof i&&h&&(e=u[u.length-1])instanceof i&&t.lines+e.lines<=32?(h+=t.lines,c+=t.length+1,u[u.length-1]=new i(e.text.concat(t.text),e.length+1+t.length)):(h+t.lines>r&&d(),h+=t.lines,c+=t.length+1,u.push(t))}function d(){0!=h&&(a.push(1==u.length?u[0]:n.from(u,c)),c=-1,h=u.length=0)}for(let e of t)f(e);return d(),1==a.length?a[0]:new n(a,e)}}function s(t,e,i=0,n=1e9){for(let s=0,r=0,o=!0;r<t.length&&s<=n;r++){let l=t[r],a=s+l.length;a>=i&&(a>n&&(l=l.slice(0,n-s)),s<i&&(l=l.slice(i-s)),o?(e[e.length-1]+=l,o=!1):e.push(l)),s=a+1}return e}function r(t,e,i){return s(t,[\"\"],e,i)}e.empty=new i([\"\"],0);class o{constructor(t,e=1){this.dir=e,this.done=!1,this.lineBreak=!1,this.value=\"\",this.nodes=[t],this.offsets=[e>0?1:(t instanceof i?t.text.length:t.children.length)<<1]}nextInner(t,e){for(this.done=this.lineBreak=!1;;){let n=this.nodes.length-1,s=this.nodes[n],r=this.offsets[n],o=r>>1,l=s instanceof i?s.text.length:s.children.length;if(o==(e>0?l:0)){if(0==n)return this.done=!0,this.value=\"\",this;e>0&&this.offsets[n-1]++,this.nodes.pop(),this.offsets.pop()}else if((1&r)==(e>0?0:1)){if(this.offsets[n]+=e,0==t)return this.lineBreak=!0,this.value=\"\\n\",this;t--}else if(s instanceof i){let i=s.text[o+(e<0?-1:0)];if(this.offsets[n]+=e,i.length>Math.max(0,t))return this.value=0==t?i:e>0?i.slice(t):i.slice(0,i.length-t),this;t-=i.length}else{let r=s.children[o+(e<0?-1:0)];t>r.length?(t-=r.length,this.offsets[n]+=e):(e<0&&this.offsets[n]--,this.nodes.push(r),this.offsets.push(e>0?1:(r instanceof i?r.text.length:r.children.length)<<1))}}}next(t=0){return t<0&&(this.nextInner(-t,-this.dir),t=this.value.length),this.nextInner(t,this.dir)}}class l{constructor(t,e,i){this.value=\"\",this.done=!1,this.cursor=new o(t,e>i?-1:1),this.pos=e>i?t.length:0,this.from=Math.min(e,i),this.to=Math.max(e,i)}nextInner(t,e){if(e<0?this.pos<=this.from:this.pos>=this.to)return this.value=\"\",this.done=!0,this;t+=Math.max(0,e<0?this.pos-this.to:this.from-this.pos);let i=e<0?this.pos-this.from:this.to-this.pos;t>i&&(t=i),i-=t;let{value:n}=this.cursor.next(t);return this.pos+=(n.length+t)*e,this.value=n.length<=i?n:e<0?n.slice(n.length-i):n.slice(0,i),this.done=!this.value,this}next(t=0){return t<0?t=Math.max(t,this.from-this.pos):t>0&&(t=Math.min(t,this.to-this.pos)),this.nextInner(t,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&\"\"!=this.value}}class a{constructor(t){this.inner=t,this.afterBreak=!0,this.value=\"\",this.done=!1}next(t=0){let{done:e,lineBreak:i,value:n}=this.inner.next(t);return e?(this.done=!0,this.value=\"\"):i?this.afterBreak?this.value=\"\":(this.afterBreak=!0,this.next()):(this.value=n,this.afterBreak=!1),this}get lineBreak(){return!1}}\"undefined\"!=typeof Symbol&&(e.prototype[Symbol.iterator]=function(){return this.iter()},o.prototype[Symbol.iterator]=l.prototype[Symbol.iterator]=a.prototype[Symbol.iterator]=function(){return this});class h{constructor(t,e,i,n){this.from=t,this.to=e,this.number=i,this.text=n}get length(){return this.to-this.from}}let c=\"lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o\".split(\",\").map((t=>t?parseInt(t,36):1));for(let t=1;t<c.length;t++)c[t]+=c[t-1];function u(t){for(let e=1;e<c.length;e+=2)if(c[e]>t)return c[e-1]<=t;return!1}function f(t){return t>=127462&&t<=127487}function d(t,e,i=!0,n=!0){return(i?p:m)(t,e,n)}function p(t,e,i){if(e==t.length)return e;e&&g(t.charCodeAt(e))&&v(t.charCodeAt(e-1))&&e--;let n=w(t,e);for(e+=b(n);e<t.length;){let s=w(t,e);if(8205==n||8205==s||i&&u(s))e+=b(s),n=s;else{if(!f(s))break;{let i=0,n=e-2;for(;n>=0&&f(w(t,n));)i++,n-=2;if(i%2==0)break;e+=2}}}return e}function m(t,e,i){for(;e>0;){let n=p(t,e-2,i);if(n<e)return n;e--}return 0}function g(t){return t>=56320&&t<57344}function v(t){return t>=55296&&t<56320}function w(t,e){let i=t.charCodeAt(e);if(!v(i)||e+1==t.length)return i;let n=t.charCodeAt(e+1);return g(n)?n-56320+(i-55296<<10)+65536:i}function y(t){return t<=65535?String.fromCharCode(t):(t-=65536,String.fromCharCode(55296+(t>>10),56320+(1023&t)))}function b(t){return t<65536?1:2}const x=/\\r\\n?|\\n/;var k=function(t){return t[t.Simple=0]=\"Simple\",t[t.TrackDel=1]=\"TrackDel\",t[t.TrackBefore=2]=\"TrackBefore\",t[t.TrackAfter=3]=\"TrackAfter\",t}(k||(k={}));class S{constructor(t){this.sections=t}get length(){let t=0;for(let e=0;e<this.sections.length;e+=2)t+=this.sections[e];return t}get newLength(){let t=0;for(let e=0;e<this.sections.length;e+=2){let i=this.sections[e+1];t+=i<0?this.sections[e]:i}return t}get empty(){return 0==this.sections.length||2==this.sections.length&&this.sections[1]<0}iterGaps(t){for(let e=0,i=0,n=0;e<this.sections.length;){let s=this.sections[e++],r=this.sections[e++];r<0?(t(i,n,s),n+=s):n+=r,i+=s}}iterChangedRanges(t,e=!1){M(this,t,e)}get invertedDesc(){let t=[];for(let e=0;e<this.sections.length;){let i=this.sections[e++],n=this.sections[e++];n<0?t.push(i,n):t.push(n,i)}return new S(t)}composeDesc(t){return this.empty?t:t.empty?this:T(this,t)}mapDesc(t,e=!1){return t.empty?this:D(this,t,e)}mapPos(t,e=-1,i=k.Simple){let n=0,s=0;for(let r=0;r<this.sections.length;){let o=this.sections[r++],l=this.sections[r++],a=n+o;if(l<0){if(a>t)return s+(t-n);s+=o}else{if(i!=k.Simple&&a>=t&&(i==k.TrackDel&&n<t&&a>t||i==k.TrackBefore&&n<t||i==k.TrackAfter&&a>t))return null;if(a>t||a==t&&e<0&&!o)return t==n||e<0?s:s+l;s+=l}n=a}if(t>n)throw new RangeError(`Position ${t} is out of range for changeset of length ${n}`);return s}touchesRange(t,e=t){for(let i=0,n=0;i<this.sections.length&&n<=e;){let s=n+this.sections[i++];if(this.sections[i++]>=0&&n<=e&&s>=t)return!(n<t&&s>e)||\"cover\";n=s}return!1}toString(){let t=\"\";for(let e=0;e<this.sections.length;){let i=this.sections[e++],n=this.sections[e++];t+=(t?\" \":\"\")+i+(n>=0?\":\"+n:\"\")}return t}toJSON(){return this.sections}static fromJSON(t){if(!Array.isArray(t)||t.length%2||t.some((t=>\"number\"!=typeof t)))throw new RangeError(\"Invalid JSON representation of ChangeDesc\");return new S(t)}static create(t){return new S(t)}}class C extends S{constructor(t,e){super(t),this.inserted=e}apply(t){if(this.length!=t.length)throw new RangeError(\"Applying change set to a document with the wrong length\");return M(this,((e,i,n,s,r)=>t=t.replace(n,n+(i-e),r)),!1),t}mapDesc(t,e=!1){return D(this,t,e,!0)}invert(t){let i=this.sections.slice(),n=[];for(let s=0,r=0;s<i.length;s+=2){let o=i[s],l=i[s+1];if(l>=0){i[s]=l,i[s+1]=o;let a=s>>1;for(;n.length<a;)n.push(e.empty);n.push(o?t.slice(r,r+o):e.empty)}r+=o}return new C(i,n)}compose(t){return this.empty?t:t.empty?this:T(this,t,!0)}map(t,e=!1){return t.empty?this:D(this,t,e,!0)}iterChanges(t,e=!1){M(this,t,e)}get desc(){return S.create(this.sections)}filter(t){let e=[],i=[],n=[],s=new P(this);t:for(let r=0,o=0;;){let l=r==t.length?1e9:t[r++];for(;o<l||o==l&&0==s.len;){if(s.done)break t;let t=Math.min(s.len,l-o);A(n,t,-1);let r=-1==s.ins?-1:0==s.off?s.ins:0;A(e,t,r),r>0&&O(i,e,s.text),s.forward(t),o+=t}let a=t[r++];for(;o<a;){if(s.done)break t;let t=Math.min(s.len,a-o);A(e,t,-1),A(n,t,-1==s.ins?-1:0==s.off?s.ins:0),s.forward(t),o+=t}}return{changes:new C(e,i),filtered:S.create(n)}}toJSON(){let t=[];for(let e=0;e<this.sections.length;e+=2){let i=this.sections[e],n=this.sections[e+1];n<0?t.push(i):0==n?t.push([i]):t.push([i].concat(this.inserted[e>>1].toJSON()))}return t}static of(t,i,n){let s=[],r=[],o=0,l=null;function a(t=!1){if(!t&&!s.length)return;o<i&&A(s,i-o,-1);let e=new C(s,r);l=l?l.compose(e.map(l)):e,s=[],r=[],o=0}return function t(h){if(Array.isArray(h))for(let e of h)t(e);else if(h instanceof C){if(h.length!=i)throw new RangeError(`Mismatched change set length (got ${h.length}, expected ${i})`);a(),l=l?l.compose(h.map(l)):h}else{let{from:t,to:l=t,insert:c}=h;if(t>l||t<0||l>i)throw new RangeError(`Invalid change range ${t} to ${l} (in doc of length ${i})`);let u=c?\"string\"==typeof c?e.of(c.split(n||x)):c:e.empty,f=u.length;if(t==l&&0==f)return;t<o&&a(),t>o&&A(s,t-o,-1),A(s,l-t,f),O(r,s,u),o=l}}(t),a(!l),l}static empty(t){return new C(t?[t,-1]:[],[])}static fromJSON(t){if(!Array.isArray(t))throw new RangeError(\"Invalid JSON representation of ChangeSet\");let i=[],n=[];for(let s=0;s<t.length;s++){let r=t[s];if(\"number\"==typeof r)i.push(r,-1);else{if(!Array.isArray(r)||\"number\"!=typeof r[0]||r.some(((t,e)=>e&&\"string\"!=typeof t)))throw new RangeError(\"Invalid JSON representation of ChangeSet\");if(1==r.length)i.push(r[0],0);else{for(;n.length<s;)n.push(e.empty);n[s]=e.of(r.slice(1)),i.push(r[0],n[s].length)}}}return new C(i,n)}static createSet(t,e){return new C(t,e)}}function A(t,e,i,n=!1){if(0==e&&i<=0)return;let s=t.length-2;s>=0&&i<=0&&i==t[s+1]?t[s]+=e:0==e&&0==t[s]?t[s+1]+=i:n?(t[s]+=e,t[s+1]+=i):t.push(e,i)}function O(t,i,n){if(0==n.length)return;let s=i.length-2>>1;if(s<t.length)t[t.length-1]=t[t.length-1].append(n);else{for(;t.length<s;)t.push(e.empty);t.push(n)}}function M(t,i,n){let s=t.inserted;for(let r=0,o=0,l=0;l<t.sections.length;){let a=t.sections[l++],h=t.sections[l++];if(h<0)r+=a,o+=a;else{let c=r,u=o,f=e.empty;for(;c+=a,u+=h,h&&s&&(f=f.append(s[l-2>>1])),!(n||l==t.sections.length||t.sections[l+1]<0);)a=t.sections[l++],h=t.sections[l++];i(r,c,o,u,f),r=c,o=u}}}function D(t,e,i,n=!1){let s=[],r=n?[]:null,o=new P(t),l=new P(e);for(let t=-1;;)if(-1==o.ins&&-1==l.ins){let t=Math.min(o.len,l.len);A(s,t,-1),o.forward(t),l.forward(t)}else if(l.ins>=0&&(o.ins<0||t==o.i||0==o.off&&(l.len<o.len||l.len==o.len&&!i))){let e=l.len;for(A(s,l.ins,-1);e;){let i=Math.min(o.len,e);o.ins>=0&&t<o.i&&o.len<=i&&(A(s,0,o.ins),r&&O(r,s,o.text),t=o.i),o.forward(i),e-=i}l.next()}else{if(!(o.ins>=0)){if(o.done&&l.done)return r?C.createSet(s,r):S.create(s);throw new Error(\"Mismatched change set lengths\")}{let e=0,i=o.len;for(;i;)if(-1==l.ins){let t=Math.min(i,l.len);e+=t,i-=t,l.forward(t)}else{if(!(0==l.ins&&l.len<i))break;i-=l.len,l.next()}A(s,e,t<o.i?o.ins:0),r&&t<o.i&&O(r,s,o.text),t=o.i,o.forward(o.len-i)}}}function T(t,e,i=!1){let n=[],s=i?[]:null,r=new P(t),o=new P(e);for(let t=!1;;){if(r.done&&o.done)return s?C.createSet(n,s):S.create(n);if(0==r.ins)A(n,r.len,0,t),r.next();else if(0!=o.len||o.done){if(r.done||o.done)throw new Error(\"Mismatched change set lengths\");{let e=Math.min(r.len2,o.len),i=n.length;if(-1==r.ins){let i=-1==o.ins?-1:o.off?0:o.ins;A(n,e,i,t),s&&i&&O(s,n,o.text)}else-1==o.ins?(A(n,r.off?0:r.len,e,t),s&&O(s,n,r.textBit(e))):(A(n,r.off?0:r.len,o.off?0:o.ins,t),s&&!o.off&&O(s,n,o.text));t=(r.ins>e||o.ins>=0&&o.len>e)&&(t||n.length>i),r.forward2(e),o.forward(e)}}else A(n,0,o.ins,t),s&&O(s,n,o.text),o.next()}}class P{constructor(t){this.set=t,this.i=0,this.next()}next(){let{sections:t}=this.set;this.i<t.length?(this.len=t[this.i++],this.ins=t[this.i++]):(this.len=0,this.ins=-2),this.off=0}get done(){return-2==this.ins}get len2(){return this.ins<0?this.len:this.ins}get text(){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length?e.empty:t[i]}textBit(t){let{inserted:i}=this.set,n=this.i-2>>1;return n>=i.length&&!t?e.empty:i[n].slice(this.off,null==t?void 0:this.off+t)}forward(t){t==this.len?this.next():(this.len-=t,this.off+=t)}forward2(t){-1==this.ins?this.forward(t):t==this.ins?this.next():(this.ins-=t,this.off+=t)}}class R{constructor(t,e,i){this.from=t,this.to=e,this.flags=i}get anchor(){return 16&this.flags?this.to:this.from}get head(){return 16&this.flags?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return 4&this.flags?-1:8&this.flags?1:0}get bidiLevel(){let t=3&this.flags;return 3==t?null:t}get goalColumn(){let t=this.flags>>5;return 33554431==t?void 0:t}map(t,e=-1){let i,n;return this.empty?i=n=t.mapPos(this.from,e):(i=t.mapPos(this.from,1),n=t.mapPos(this.to,-1)),i==this.from&&n==this.to?this:new R(i,n,this.flags)}extend(t,e=t){if(t<=this.anchor&&e>=this.anchor)return E.range(t,e);let i=Math.abs(t-this.anchor)>Math.abs(e-this.anchor)?t:e;return E.range(this.anchor,i)}eq(t){return this.anchor==t.anchor&&this.head==t.head}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(t){if(!t||\"number\"!=typeof t.anchor||\"number\"!=typeof t.head)throw new RangeError(\"Invalid JSON representation for SelectionRange\");return E.range(t.anchor,t.head)}static create(t,e,i){return new R(t,e,i)}}class E{constructor(t,e){this.ranges=t,this.mainIndex=e}map(t,e=-1){return t.empty?this:E.create(this.ranges.map((i=>i.map(t,e))),this.mainIndex)}eq(t){if(this.ranges.length!=t.ranges.length||this.mainIndex!=t.mainIndex)return!1;for(let e=0;e<this.ranges.length;e++)if(!this.ranges[e].eq(t.ranges[e]))return!1;return!0}get main(){return this.ranges[this.mainIndex]}asSingle(){return 1==this.ranges.length?this:new E([this.main],0)}addRange(t,e=!0){return E.create([t].concat(this.ranges),e?0:this.mainIndex+1)}replaceRange(t,e=this.mainIndex){let i=this.ranges.slice();return i[e]=t,E.create(i,this.mainIndex)}toJSON(){return{ranges:this.ranges.map((t=>t.toJSON())),main:this.mainIndex}}static fromJSON(t){if(!t||!Array.isArray(t.ranges)||\"number\"!=typeof t.main||t.main>=t.ranges.length)throw new RangeError(\"Invalid JSON representation for EditorSelection\");return new E(t.ranges.map((t=>R.fromJSON(t))),t.main)}static single(t,e=t){return new E([E.range(t,e)],0)}static create(t,e=0){if(0==t.length)throw new RangeError(\"A selection needs at least one range\");for(let i=0,n=0;n<t.length;n++){let s=t[n];if(s.empty?s.from<=i:s.from<i)return E.normalized(t.slice(),e);i=s.to}return new E(t,e)}static cursor(t,e=0,i,n){return R.create(t,t,(0==e?0:e<0?4:8)|(null==i?3:Math.min(2,i))|(null!=n?n:33554431)<<5)}static range(t,e,i){let n=(null!=i?i:33554431)<<5;return e<t?R.create(e,t,24|n):R.create(t,e,n|(e>t?4:0))}static normalized(t,e=0){let i=t[e];t.sort(((t,e)=>t.from-e.from)),e=t.indexOf(i);for(let i=1;i<t.length;i++){let n=t[i],s=t[i-1];if(n.empty?n.from<=s.to:n.from<s.to){let r=s.from,o=Math.max(n.to,s.to);i<=e&&e--,t.splice(--i,2,n.anchor>n.head?E.range(o,r):E.range(r,o))}}return new E(t,e)}}function B(t,e){for(let i of t.ranges)if(i.to>e)throw new RangeError(\"Selection points outside of document\")}let L=0;class N{constructor(t,e,i,n,s){this.combine=t,this.compareInput=e,this.compare=i,this.isStatic=n,this.id=L++,this.default=t([]),this.extensions=\"function\"==typeof s?s(this):s}static define(t={}){return new N(t.combine||(t=>t),t.compareInput||((t,e)=>t===e),t.compare||(t.combine?(t,e)=>t===e:I),!!t.static,t.enables)}of(t){return new V([],this,0,t)}compute(t,e){if(this.isStatic)throw new Error(\"Can't compute a static facet\");return new V(t,this,1,e)}computeN(t,e){if(this.isStatic)throw new Error(\"Can't compute a static facet\");return new V(t,this,2,e)}from(t,e){return e||(e=t=>t),this.compute([t],(i=>e(i.field(t))))}}function I(t,e){return t==e||t.length==e.length&&t.every(((t,i)=>t===e[i]))}class V{constructor(t,e,i,n){this.dependencies=t,this.facet=e,this.type=i,this.value=n,this.id=L++}dynamicSlot(t){var e;let i=this.value,n=this.facet.compareInput,s=this.id,r=t[s]>>1,o=2==this.type,l=!1,a=!1,h=[];for(let i of this.dependencies)\"doc\"==i?l=!0:\"selection\"==i?a=!0:0==(1&(null!==(e=t[i.id])&&void 0!==e?e:1))&&h.push(t[i.id]);return{create:t=>(t.values[r]=i(t),1),update(t,e){if(l&&e.docChanged||a&&(e.docChanged||e.selection)||z(t,h)){let e=i(t);if(o?!W(e,t.values[r],n):!n(e,t.values[r]))return t.values[r]=e,1}return 0},reconfigure:(t,e)=>{let l,a=e.config.address[s];if(null!=a){let s=tt(e,a);if(this.dependencies.every((i=>i instanceof N?e.facet(i)===t.facet(i):!(i instanceof q)||e.field(i,!1)==t.field(i,!1)))||(o?W(l=i(t),s,n):n(l=i(t),s)))return t.values[r]=s,0}else l=i(t);return t.values[r]=l,1}}}}function W(t,e,i){if(t.length!=e.length)return!1;for(let n=0;n<t.length;n++)if(!i(t[n],e[n]))return!1;return!0}function z(t,e){let i=!1;for(let n of e)1&Y(t,n)&&(i=!0);return i}function H(t,e,i){let n=i.map((e=>t[e.id])),s=i.map((t=>t.type)),r=n.filter((t=>!(1&t))),o=t[e.id]>>1;function l(t){let i=[];for(let e=0;e<n.length;e++){let r=tt(t,n[e]);if(2==s[e])for(let t of r)i.push(t);else i.push(r)}return e.combine(i)}return{create(t){for(let e of n)Y(t,e);return t.values[o]=l(t),1},update(t,i){if(!z(t,r))return 0;let n=l(t);return e.compare(n,t.values[o])?0:(t.values[o]=n,1)},reconfigure(t,s){let r=z(t,n),a=s.config.facets[e.id],h=s.facet(e);if(a&&!r&&I(i,a))return t.values[o]=h,0;let c=l(t);return e.compare(c,h)?(t.values[o]=h,0):(t.values[o]=c,1)}}}const F=N.define({static:!0});class q{constructor(t,e,i,n,s){this.id=t,this.createF=e,this.updateF=i,this.compareF=n,this.spec=s,this.provides=void 0}static define(t){let e=new q(L++,t.create,t.update,t.compare||((t,e)=>t===e),t);return t.provide&&(e.provides=t.provide(e)),e}create(t){let e=t.facet(F).find((t=>t.field==this));return((null==e?void 0:e.create)||this.createF)(t)}slot(t){let e=t[this.id]>>1;return{create:t=>(t.values[e]=this.create(t),1),update:(t,i)=>{let n=t.values[e],s=this.updateF(n,i);return this.compareF(n,s)?0:(t.values[e]=s,1)},reconfigure:(t,i)=>null!=i.config.address[this.id]?(t.values[e]=i.field(this),0):(t.values[e]=this.create(t),1)}}init(t){return[this,F.of({field:this,create:t})]}get extension(){return this}}const _=4,j=3,U=2,$=1;function Q(t){return e=>new G(e,t)}const K={highest:Q(0),high:Q($),default:Q(U),low:Q(j),lowest:Q(_)};class G{constructor(t,e){this.inner=t,this.prec=e}}class J{of(t){return new X(this,t)}reconfigure(t){return J.reconfigure.of({compartment:this,extension:t})}get(t){return t.config.compartments.get(this)}}class X{constructor(t,e){this.compartment=t,this.inner=e}}class Z{constructor(t,e,i,n,s,r){for(this.base=t,this.compartments=e,this.dynamicSlots=i,this.address=n,this.staticValues=s,this.facets=r,this.statusTemplate=[];this.statusTemplate.length<i.length;)this.statusTemplate.push(0)}staticFacet(t){let e=this.address[t.id];return null==e?t.default:this.staticValues[e>>1]}static resolve(t,e,i){let n=[],s=Object.create(null),r=new Map;for(let i of function(t,e,i){let n=[[],[],[],[],[]],s=new Map;function r(t,o){let l=s.get(t);if(null!=l){if(l<=o)return;let e=n[l].indexOf(t);e>-1&&n[l].splice(e,1),t instanceof X&&i.delete(t.compartment)}if(s.set(t,o),Array.isArray(t))for(let e of t)r(e,o);else if(t instanceof X){if(i.has(t.compartment))throw new RangeError(\"Duplicate use of compartment in extensions\");let n=e.get(t.compartment)||t.inner;i.set(t.compartment,n),r(n,o)}else if(t instanceof G)r(t.inner,t.prec);else if(t instanceof q)n[o].push(t),t.provides&&r(t.provides,o);else if(t instanceof V)n[o].push(t),t.facet.extensions&&r(t.facet.extensions,U);else{let e=t.extension;if(!e)throw new Error(`Unrecognized extension value in extension set (${t}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);r(e,o)}}return r(t,U),n.reduce(((t,e)=>t.concat(e)))}(t,e,r))i instanceof q?n.push(i):(s[i.facet.id]||(s[i.facet.id]=[])).push(i);let o=Object.create(null),l=[],a=[];for(let t of n)o[t.id]=a.length<<1,a.push((e=>t.slot(e)));let h=null==i?void 0:i.config.facets;for(let t in s){let e=s[t],n=e[0].facet,r=h&&h[t]||[];if(e.every((t=>0==t.type)))if(o[n.id]=l.length<<1|1,I(r,e))l.push(i.facet(n));else{let t=n.combine(e.map((t=>t.value)));l.push(i&&n.compare(t,i.facet(n))?i.facet(n):t)}else{for(let t of e)0==t.type?(o[t.id]=l.length<<1|1,l.push(t.value)):(o[t.id]=a.length<<1,a.push((e=>t.dynamicSlot(e))));o[n.id]=a.length<<1,a.push((t=>H(t,n,e)))}}let c=a.map((t=>t(o)));return new Z(t,r,c,o,l,s)}}function Y(t,e){if(1&e)return 2;let i=e>>1,n=t.status[i];if(4==n)throw new Error(\"Cyclic dependency between fields and/or facets\");if(2&n)return n;t.status[i]=4;let s=t.computeSlot(t,t.config.dynamicSlots[i]);return t.status[i]=2|s}function tt(t,e){return 1&e?t.config.staticValues[e>>1]:t.values[e>>1]}const et=N.define(),it=N.define({combine:t=>t.some((t=>t)),static:!0}),nt=N.define({combine:t=>t.length?t[0]:void 0,static:!0}),st=N.define(),rt=N.define(),ot=N.define(),lt=N.define({combine:t=>!!t.length&&t[0]});class at{constructor(t,e){this.type=t,this.value=e}static define(){return new ht}}class ht{of(t){return new at(this,t)}}class ct{constructor(t){this.map=t}of(t){return new ut(this,t)}}class ut{constructor(t,e){this.type=t,this.value=e}map(t){let e=this.type.map(this.value,t);return void 0===e?void 0:e==this.value?this:new ut(this.type,e)}is(t){return this.type==t}static define(t={}){return new ct(t.map||(t=>t))}static mapEffects(t,e){if(!t.length)return t;let i=[];for(let n of t){let t=n.map(e);t&&i.push(t)}return i}}ut.reconfigure=ut.define(),ut.appendConfig=ut.define();class ft{constructor(t,e,i,n,s,r){this.startState=t,this.changes=e,this.selection=i,this.effects=n,this.annotations=s,this.scrollIntoView=r,this._doc=null,this._state=null,i&&B(i,e.newLength),s.some((t=>t.type==ft.time))||(this.annotations=s.concat(ft.time.of(Date.now())))}static create(t,e,i,n,s,r){return new ft(t,e,i,n,s,r)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(t){for(let e of this.annotations)if(e.type==t)return e.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(t){let e=this.annotation(ft.userEvent);return!(!e||!(e==t||e.length>t.length&&e.slice(0,t.length)==t&&\".\"==e[t.length]))}}function dt(t,e){let i=[];for(let n=0,s=0;;){let r,o;if(n<t.length&&(s==e.length||e[s]>=t[n]))r=t[n++],o=t[n++];else{if(!(s<e.length))return i;r=e[s++],o=e[s++]}!i.length||i[i.length-1]<r?i.push(r,o):i[i.length-1]<o&&(i[i.length-1]=o)}}function pt(t,e,i){var n;let s,r,o;return i?(s=e.changes,r=C.empty(e.changes.length),o=t.changes.compose(e.changes)):(s=e.changes.map(t.changes),r=t.changes.mapDesc(e.changes,!0),o=t.changes.compose(s)),{changes:o,selection:e.selection?e.selection.map(r):null===(n=t.selection)||void 0===n?void 0:n.map(s),effects:ut.mapEffects(t.effects,s).concat(ut.mapEffects(e.effects,r)),annotations:t.annotations.length?t.annotations.concat(e.annotations):e.annotations,scrollIntoView:t.scrollIntoView||e.scrollIntoView}}function mt(t,e,i){let n=e.selection,s=wt(e.annotations);return e.userEvent&&(s=s.concat(ft.userEvent.of(e.userEvent))),{changes:e.changes instanceof C?e.changes:C.of(e.changes||[],i,t.facet(nt)),selection:n&&(n instanceof E?n:E.single(n.anchor,n.head)),effects:wt(e.effects),annotations:s,scrollIntoView:!!e.scrollIntoView}}function gt(t,e,i){let n=mt(t,e.length?e[0]:{},t.doc.length);e.length&&!1===e[0].filter&&(i=!1);for(let s=1;s<e.length;s++){!1===e[s].filter&&(i=!1);let r=!!e[s].sequential;n=pt(n,mt(t,e[s],r?n.changes.newLength:t.doc.length),r)}let s=ft.create(t,n.changes,n.selection,n.effects,n.annotations,n.scrollIntoView);return function(t){let e=t.startState,i=e.facet(ot),n=t;for(let s=i.length-1;s>=0;s--){let r=i[s](t);r&&Object.keys(r).length&&(n=pt(n,mt(e,r,t.changes.newLength),!0))}return n==t?t:ft.create(e,t.changes,t.selection,n.effects,n.annotations,n.scrollIntoView)}(i?function(t){let e=t.startState,i=!0;for(let n of e.facet(st)){let e=n(t);if(!1===e){i=!1;break}Array.isArray(e)&&(i=!0===i?e:dt(i,e))}if(!0!==i){let n,s;if(!1===i)s=t.changes.invertedDesc,n=C.empty(e.doc.length);else{let e=t.changes.filter(i);n=e.changes,s=e.filtered.mapDesc(e.changes).invertedDesc}t=ft.create(e,n,t.selection&&t.selection.map(s),ut.mapEffects(t.effects,s),t.annotations,t.scrollIntoView)}let n=e.facet(rt);for(let i=n.length-1;i>=0;i--){let s=n[i](t);t=s instanceof ft?s:Array.isArray(s)&&1==s.length&&s[0]instanceof ft?s[0]:gt(e,wt(s),!1)}return t}(s):s)}ft.time=at.define(),ft.userEvent=at.define(),ft.addToHistory=at.define(),ft.remote=at.define();const vt=[];function wt(t){return null==t?vt:Array.isArray(t)?t:[t]}var yt=function(t){return t[t.Word=0]=\"Word\",t[t.Space=1]=\"Space\",t[t.Other=2]=\"Other\",t}(yt||(yt={}));const bt=/[\\u00df\\u0587\\u0590-\\u05f4\\u0600-\\u06ff\\u3040-\\u309f\\u30a0-\\u30ff\\u3400-\\u4db5\\u4e00-\\u9fcc\\uac00-\\ud7af]/;let xt;try{xt=new RegExp(\"[\\\\p{Alphabetic}\\\\p{Number}_]\",\"u\")}catch(t){}function kt(t){return e=>{if(!/\\S/.test(e))return yt.Space;if(function(t){if(xt)return xt.test(t);for(let e=0;e<t.length;e++){let i=t[e];if(/\\w/.test(i)||i>\"\"&&(i.toUpperCase()!=i.toLowerCase()||bt.test(i)))return!0}return!1}(e))return yt.Word;for(let i=0;i<t.length;i++)if(e.indexOf(t[i])>-1)return yt.Word;return yt.Other}}class St{constructor(t,e,i,n,s,r){this.config=t,this.doc=e,this.selection=i,this.values=n,this.status=t.statusTemplate.slice(),this.computeSlot=s,r&&(r._state=this);for(let t=0;t<this.config.dynamicSlots.length;t++)Y(this,t<<1);this.computeSlot=null}field(t,e=!0){let i=this.config.address[t.id];if(null!=i)return Y(this,i),tt(this,i);if(e)throw new RangeError(\"Field is not present in this state\")}update(...t){return gt(this,t,!0)}applyTransaction(t){let e,i=this.config,{base:n,compartments:s}=i;for(let e of t.effects)e.is(J.reconfigure)?(i&&(s=new Map,i.compartments.forEach(((t,e)=>s.set(e,t))),i=null),s.set(e.value.compartment,e.value.extension)):e.is(ut.reconfigure)?(i=null,n=e.value):e.is(ut.appendConfig)&&(i=null,n=wt(n).concat(e.value));if(i)e=t.startState.values.slice();else{i=Z.resolve(n,s,this),e=new St(i,this.doc,this.selection,i.dynamicSlots.map((()=>null)),((t,e)=>e.reconfigure(t,this)),null).values}new St(i,t.newDoc,t.newSelection,e,((e,i)=>i.update(e,t)),t)}replaceSelection(t){return\"string\"==typeof t&&(t=this.toText(t)),this.changeByRange((e=>({changes:{from:e.from,to:e.to,insert:t},range:E.cursor(e.from+t.length)})))}changeByRange(t){let e=this.selection,i=t(e.ranges[0]),n=this.changes(i.changes),s=[i.range],r=wt(i.effects);for(let i=1;i<e.ranges.length;i++){let o=t(e.ranges[i]),l=this.changes(o.changes),a=l.map(n);for(let t=0;t<i;t++)s[t]=s[t].map(a);let h=n.mapDesc(l,!0);s.push(o.range.map(h)),n=n.compose(a),r=ut.mapEffects(r,a).concat(ut.mapEffects(wt(o.effects),h))}return{changes:n,selection:E.create(s,e.mainIndex),effects:r}}changes(t=[]){return t instanceof C?t:C.of(t,this.doc.length,this.facet(St.lineSeparator))}toText(t){return e.of(t.split(this.facet(St.lineSeparator)||x))}sliceDoc(t=0,e=this.doc.length){return this.doc.sliceString(t,e,this.lineBreak)}facet(t){let e=this.config.address[t.id];return null==e?t.default:(Y(this,e),tt(this,e))}toJSON(t){let e={doc:this.sliceDoc(),selection:this.selection.toJSON()};if(t)for(let i in t){let n=t[i];n instanceof q&&null!=this.config.address[n.id]&&(e[i]=n.spec.toJSON(this.field(t[i]),this))}return e}static fromJSON(t,e={},i){if(!t||\"string\"!=typeof t.doc)throw new RangeError(\"Invalid JSON representation for EditorState\");let n=[];if(i)for(let e in i)if(Object.prototype.hasOwnProperty.call(t,e)){let s=i[e],r=t[e];n.push(s.init((t=>s.spec.fromJSON(r,t))))}return St.create({doc:t.doc,selection:E.fromJSON(t.selection),extensions:e.extensions?n.concat([e.extensions]):n})}static create(t={}){let i=Z.resolve(t.extensions||[],new Map),n=t.doc instanceof e?t.doc:e.of((t.doc||\"\").split(i.staticFacet(St.lineSeparator)||x)),s=t.selection?t.selection instanceof E?t.selection:E.single(t.selection.anchor,t.selection.head):E.single(0);return B(s,n.length),i.staticFacet(it)||(s=s.asSingle()),new St(i,n,s,i.dynamicSlots.map((()=>null)),((t,e)=>e.create(t)),null)}get tabSize(){return this.facet(St.tabSize)}get lineBreak(){return this.facet(St.lineSeparator)||\"\\n\"}get readOnly(){return this.facet(lt)}phrase(t,...e){for(let e of this.facet(St.phrases))if(Object.prototype.hasOwnProperty.call(e,t)){t=e[t];break}return e.length&&(t=t.replace(/\\$(\\$|\\d*)/g,((t,i)=>{if(\"$\"==i)return\"$\";let n=+(i||1);return!n||n>e.length?t:e[n-1]}))),t}languageDataAt(t,e,i=-1){let n=[];for(let s of this.facet(et))for(let r of s(this,e,i))Object.prototype.hasOwnProperty.call(r,t)&&n.push(r[t]);return n}charCategorizer(t){return kt(this.languageDataAt(\"wordChars\",t).join(\"\"))}wordAt(t){let{text:e,from:i,length:n}=this.doc.lineAt(t),s=this.charCategorizer(t),r=t-i,o=t-i;for(;r>0;){let t=d(e,r,!1);if(s(e.slice(t,r))!=yt.Word)break;r=t}for(;o<n;){let t=d(e,o);if(s(e.slice(o,t))!=yt.Word)break;o=t}return r==o?null:E.range(r+i,o+i)}}function Ct(t,e,i={}){let n={};for(let e of t)for(let t of Object.keys(e)){let s=e[t],r=n[t];if(void 0===r)n[t]=s;else if(r===s||void 0===s);else{if(!Object.hasOwnProperty.call(i,t))throw new Error(\"Config merge conflict for field \"+t);n[t]=i[t](r,s)}}for(let t in e)void 0===n[t]&&(n[t]=e[t]);return n}St.allowMultipleSelections=it,St.tabSize=N.define({combine:t=>t.length?t[0]:4}),St.lineSeparator=nt,St.readOnly=lt,St.phrases=N.define({compare(t,e){let i=Object.keys(t),n=Object.keys(e);return i.length==n.length&&i.every((i=>t[i]==e[i]))}}),St.languageData=et,St.changeFilter=st,St.transactionFilter=rt,St.transactionExtender=ot,J.reconfigure=ut.define();class At{eq(t){return this==t}range(t,e=t){return Ot.create(t,e,this)}}At.prototype.startSide=At.prototype.endSide=0,At.prototype.point=!1,At.prototype.mapMode=k.TrackDel;let Ot=class{constructor(t,e,i){this.from=t,this.to=e,this.value=i}static create(t,e,i){return new Ot(t,e,i)}};function Mt(t,e){return t.from-e.from||t.value.startSide-e.value.startSide}class Dt{constructor(t,e,i,n){this.from=t,this.to=e,this.value=i,this.maxPoint=n}get length(){return this.to[this.to.length-1]}findIndex(t,e,i,n=0){let s=i?this.to:this.from;for(let r=n,o=s.length;;){if(r==o)return r;let n=r+o>>1,l=s[n]-t||(i?this.value[n].endSide:this.value[n].startSide)-e;if(n==r)return l>=0?r:o;l>=0?o=n:r=n+1}}between(t,e,i,n){for(let s=this.findIndex(e,-1e9,!0),r=this.findIndex(i,1e9,!1,s);s<r;s++)if(!1===n(this.from[s]+t,this.to[s]+t,this.value[s]))return!1}map(t,e){let i=[],n=[],s=[],r=-1,o=-1;for(let l=0;l<this.value.length;l++){let a,h,c=this.value[l],u=this.from[l]+t,f=this.to[l]+t;if(u==f){let t=e.mapPos(u,c.startSide,c.mapMode);if(null==t)continue;if(a=h=t,c.startSide!=c.endSide&&(h=e.mapPos(u,c.endSide),h<a))continue}else if(a=e.mapPos(u,c.startSide),h=e.mapPos(f,c.endSide),a>h||a==h&&c.startSide>0&&c.endSide<=0)continue;(h-a||c.endSide-c.startSide)<0||(r<0&&(r=a),c.point&&(o=Math.max(o,h-a)),i.push(c),n.push(a-r),s.push(h-r))}return{mapped:i.length?new Dt(n,s,i,o):null,pos:r}}}class Tt{constructor(t,e,i,n){this.chunkPos=t,this.chunk=e,this.nextLayer=i,this.maxPoint=n}static create(t,e,i,n){return new Tt(t,e,i,n)}get length(){let t=this.chunk.length-1;return t<0?0:Math.max(this.chunkEnd(t),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let t=this.nextLayer.size;for(let e of this.chunk)t+=e.value.length;return t}chunkEnd(t){return this.chunkPos[t]+this.chunk[t].length}update(t){let{add:e=[],sort:i=!1,filterFrom:n=0,filterTo:s=this.length}=t,r=t.filter;if(0==e.length&&!r)return this;if(i&&(e=e.slice().sort(Mt)),this.isEmpty)return e.length?Tt.of(e):this;let o=new Et(this,null,-1).goto(0),l=0,a=[],h=new Pt;for(;o.value||l<e.length;)if(l<e.length&&(o.from-e[l].from||o.startSide-e[l].value.startSide)>=0){let t=e[l++];h.addInner(t.from,t.to,t.value)||a.push(t)}else 1==o.rangeIndex&&o.chunkIndex<this.chunk.length&&(l==e.length||this.chunkEnd(o.chunkIndex)<e[l].from)&&(!r||n>this.chunkEnd(o.chunkIndex)||s<this.chunkPos[o.chunkIndex])&&h.addChunk(this.chunkPos[o.chunkIndex],this.chunk[o.chunkIndex])?o.nextChunk():((!r||n>o.to||s<o.from||r(o.from,o.to,o.value))&&(h.addInner(o.from,o.to,o.value)||a.push(Ot.create(o.from,o.to,o.value))),o.next());return h.finishInner(this.nextLayer.isEmpty&&!a.length?Tt.empty:this.nextLayer.update({add:a,filter:r,filterFrom:n,filterTo:s}))}map(t){if(t.empty||this.isEmpty)return this;let e=[],i=[],n=-1;for(let s=0;s<this.chunk.length;s++){let r=this.chunkPos[s],o=this.chunk[s],l=t.touchesRange(r,r+o.length);if(!1===l)n=Math.max(n,o.maxPoint),e.push(o),i.push(t.mapPos(r));else if(!0===l){let{mapped:s,pos:l}=o.map(r,t);s&&(n=Math.max(n,s.maxPoint),e.push(s),i.push(l))}}let s=this.nextLayer.map(t);return 0==e.length?s:new Tt(i,e,s||Tt.empty,n)}between(t,e,i){if(!this.isEmpty){for(let n=0;n<this.chunk.length;n++){let s=this.chunkPos[n],r=this.chunk[n];if(e>=s&&t<=s+r.length&&!1===r.between(s,t-s,e-s,i))return}this.nextLayer.between(t,e,i)}}iter(t=0){return Bt.from([this]).goto(t)}get isEmpty(){return this.nextLayer==this}static iter(t,e=0){return Bt.from(t).goto(e)}static compare(t,e,i,n,s=-1){let r=t.filter((t=>t.maxPoint>0||!t.isEmpty&&t.maxPoint>=s)),o=e.filter((t=>t.maxPoint>0||!t.isEmpty&&t.maxPoint>=s)),l=Rt(r,o,i),a=new Nt(r,l,s),h=new Nt(o,l,s);i.iterGaps(((t,e,i)=>It(a,t,h,e,i,n))),i.empty&&0==i.length&&It(a,0,h,0,0,n)}static eq(t,e,i=0,n){null==n&&(n=999999999);let s=t.filter((t=>!t.isEmpty&&e.indexOf(t)<0)),r=e.filter((e=>!e.isEmpty&&t.indexOf(e)<0));if(s.length!=r.length)return!1;if(!s.length)return!0;let o=Rt(s,r),l=new Nt(s,o,0).goto(i),a=new Nt(r,o,0).goto(i);for(;;){if(l.to!=a.to||!Vt(l.active,a.active)||l.point&&(!a.point||!l.point.eq(a.point)))return!1;if(l.to>n)return!0;l.next(),a.next()}}static spans(t,e,i,n,s=-1){let r=new Nt(t,null,s).goto(e),o=e,l=r.openStart;for(;;){let t=Math.min(r.to,i);if(r.point){let i=r.activeForPoint(r.to),s=r.pointFrom<e?i.length+1:Math.min(i.length,l);n.point(o,t,r.point,i,s,r.pointRank),l=Math.min(r.openEnd(t),i.length)}else t>o&&(n.span(o,t,r.active,l),l=r.openEnd(t));if(r.to>i)return l+(r.point&&r.to>i?1:0);o=r.to,r.next()}}static of(t,e=!1){let i=new Pt;for(let n of t instanceof Ot?[t]:e?function(t){if(t.length>1)for(let e=t[0],i=1;i<t.length;i++){let n=t[i];if(Mt(e,n)>0)return t.slice().sort(Mt);e=n}return t}(t):t)i.add(n.from,n.to,n.value);return i.finish()}}Tt.empty=new Tt([],[],null,-1),Tt.empty.nextLayer=Tt.empty;class Pt{constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}finishChunk(t){this.chunks.push(new Dt(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,t&&(this.from=[],this.to=[],this.value=[])}add(t,e,i){this.addInner(t,e,i)||(this.nextLayer||(this.nextLayer=new Pt)).add(t,e,i)}addInner(t,e,i){let n=t-this.lastTo||i.startSide-this.last.endSide;if(n<=0&&(t-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error(\"Ranges must be added sorted by `from` position and `startSide`\");return!(n<0)&&(250==this.from.length&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=t),this.from.push(t-this.chunkStart),this.to.push(e-this.chunkStart),this.last=i,this.lastFrom=t,this.lastTo=e,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,e-t)),!0)}addChunk(t,e){if((t-this.lastTo||e.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,e.maxPoint),this.chunks.push(e),this.chunkPos.push(t);let i=e.value.length-1;return this.last=e.value[i],this.lastFrom=e.from[i]+t,this.lastTo=e.to[i]+t,!0}finish(){return this.finishInner(Tt.empty)}finishInner(t){if(this.from.length&&this.finishChunk(!1),0==this.chunks.length)return t;let e=Tt.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(t):t,this.setMaxPoint);return this.from=null,e}}function Rt(t,e,i){let n=new Map;for(let e of t)for(let t=0;t<e.chunk.length;t++)e.chunk[t].maxPoint<=0&&n.set(e.chunk[t],e.chunkPos[t]);let s=new Set;for(let t of e)for(let e=0;e<t.chunk.length;e++){let r=n.get(t.chunk[e]);null==r||(i?i.mapPos(r):r)!=t.chunkPos[e]||(null==i?void 0:i.touchesRange(r,r+t.chunk[e].length))||s.add(t.chunk[e])}return s}class Et{constructor(t,e,i,n=0){this.layer=t,this.skip=e,this.minPoint=i,this.rank=n}get startSide(){return this.value?this.value.startSide:0}get endSide(){return this.value?this.value.endSide:0}goto(t,e=-1e9){return this.chunkIndex=this.rangeIndex=0,this.gotoInner(t,e,!1),this}gotoInner(t,e,i){for(;this.chunkIndex<this.layer.chunk.length;){let e=this.layer.chunk[this.chunkIndex];if(!(this.skip&&this.skip.has(e)||this.layer.chunkEnd(this.chunkIndex)<t||e.maxPoint<this.minPoint))break;this.chunkIndex++,i=!1}if(this.chunkIndex<this.layer.chunk.length){let n=this.layer.chunk[this.chunkIndex].findIndex(t-this.layer.chunkPos[this.chunkIndex],e,!0);(!i||this.rangeIndex<n)&&this.setRangeIndex(n)}this.next()}forward(t,e){(this.to-t||this.endSide-e)<0&&this.gotoInner(t,e,!0)}next(){for(;;){if(this.chunkIndex==this.layer.chunk.length){this.from=this.to=1e9,this.value=null;break}{let t=this.layer.chunkPos[this.chunkIndex],e=this.layer.chunk[this.chunkIndex],i=t+e.from[this.rangeIndex];if(this.from=i,this.to=t+e.to[this.rangeIndex],this.value=e.value[this.rangeIndex],this.setRangeIndex(this.rangeIndex+1),this.minPoint<0||this.value.point&&this.to-this.from>=this.minPoint)break}}}setRangeIndex(t){if(t==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex<this.layer.chunk.length&&this.skip.has(this.layer.chunk[this.chunkIndex]);)this.chunkIndex++;this.rangeIndex=0}else this.rangeIndex=t}nextChunk(){this.chunkIndex++,this.rangeIndex=0,this.next()}compare(t){return this.from-t.from||this.startSide-t.startSide||this.rank-t.rank||this.to-t.to||this.endSide-t.endSide}}class Bt{constructor(t){this.heap=t}static from(t,e=null,i=-1){let n=[];for(let s=0;s<t.length;s++)for(let r=t[s];!r.isEmpty;r=r.nextLayer)r.maxPoint>=i&&n.push(new Et(r,e,i,s));return 1==n.length?n[0]:new Bt(n)}get startSide(){return this.value?this.value.startSide:0}goto(t,e=-1e9){for(let i of this.heap)i.goto(t,e);for(let t=this.heap.length>>1;t>=0;t--)Lt(this.heap,t);return this.next(),this}forward(t,e){for(let i of this.heap)i.forward(t,e);for(let t=this.heap.length>>1;t>=0;t--)Lt(this.heap,t);(this.to-t||this.value.endSide-e)<0&&this.next()}next(){if(0==this.heap.length)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let t=this.heap[0];this.from=t.from,this.to=t.to,this.value=t.value,this.rank=t.rank,t.value&&t.next(),Lt(this.heap,0)}}}function Lt(t,e){for(let i=t[e];;){let n=1+(e<<1);if(n>=t.length)break;let s=t[n];if(n+1<t.length&&s.compare(t[n+1])>=0&&(s=t[n+1],n++),i.compare(s)<0)break;t[n]=i,t[e]=s,e=n}}class Nt{constructor(t,e,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=Bt.from(t,e,i)}goto(t,e=-1e9){return this.cursor.goto(t,e),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=t,this.endSide=e,this.openStart=-1,this.next(),this}forward(t,e){for(;this.minActive>-1&&(this.activeTo[this.minActive]-t||this.active[this.minActive].endSide-e)<0;)this.removeActive(this.minActive);this.cursor.forward(t,e)}removeActive(t){Wt(this.active,t),Wt(this.activeTo,t),Wt(this.activeRank,t),this.minActive=Ht(this.active,this.activeTo)}addActive(t){let e=0,{value:i,to:n,rank:s}=this.cursor;for(;e<this.activeRank.length&&this.activeRank[e]<=s;)e++;zt(this.active,e,i),zt(this.activeTo,e,n),zt(this.activeRank,e,s),t&&zt(t,e,this.cursor.from),this.minActive=Ht(this.active,this.activeTo)}next(){let t=this.to,e=this.point;this.point=null;let i=this.openStart<0?[]:null;for(;;){let n=this.minActive;if(n>-1&&(this.activeTo[n]-this.cursor.from||this.active[n].endSide-this.cursor.startSide)<0){if(this.activeTo[n]>t){this.to=this.activeTo[n],this.endSide=this.active[n].endSide;break}this.removeActive(n),i&&Wt(i,n)}else{if(!this.cursor.value){this.to=this.endSide=1e9;break}if(this.cursor.from>t){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}{let t=this.cursor.value;if(t.point){if(!(e&&this.cursor.to==this.to&&this.cursor.from<this.cursor.to)){this.point=t,this.pointFrom=this.cursor.from,this.pointRank=this.cursor.rank,this.to=this.cursor.to,this.endSide=t.endSide,this.cursor.next(),this.forward(this.to,this.endSide);break}this.cursor.next()}else this.addActive(i),this.cursor.next()}}}if(i){this.openStart=0;for(let e=i.length-1;e>=0&&i[e]<t;e--)this.openStart++}}activeForPoint(t){if(!this.active.length)return this.active;let e=[];for(let i=this.active.length-1;i>=0&&!(this.activeRank[i]<this.pointRank);i--)(this.activeTo[i]>t||this.activeTo[i]==t&&this.active[i].endSide>=this.point.endSide)&&e.push(this.active[i]);return e.reverse()}openEnd(t){let e=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>t;i--)e++;return e}}function It(t,e,i,n,s,r){t.goto(e),i.goto(n);let o=n+s,l=n,a=n-e;for(;;){let e=t.to+a-i.to||t.endSide-i.endSide,n=e<0?t.to+a:i.to,s=Math.min(n,o);if(t.point||i.point?t.point&&i.point&&(t.point==i.point||t.point.eq(i.point))&&Vt(t.activeForPoint(t.to+a),i.activeForPoint(i.to))||r.comparePoint(l,s,t.point,i.point):s>l&&!Vt(t.active,i.active)&&r.compareRange(l,s,t.active,i.active),n>o)break;l=n,e<=0&&t.next(),e>=0&&i.next()}}function Vt(t,e){if(t.length!=e.length)return!1;for(let i=0;i<t.length;i++)if(t[i]!=e[i]&&!t[i].eq(e[i]))return!1;return!0}function Wt(t,e){for(let i=e,n=t.length-1;i<n;i++)t[i]=t[i+1];t.pop()}function zt(t,e,i){for(let i=t.length-1;i>=e;i--)t[i+1]=t[i];t[e]=i}function Ht(t,e){let i=-1,n=1e9;for(let s=0;s<e.length;s++)(e[s]-n||t[s].endSide-t[i].endSide)<0&&(i=s,n=e[s]);return i}function Ft(t,e,i=t.length){let n=0;for(let s=0;s<i;)9==t.charCodeAt(s)?(n+=e-n%e,s++):(n++,s=d(t,s));return n}function qt(t,e,i,n){for(let n=0,s=0;;){if(s>=e)return n;if(n==t.length)break;s+=9==t.charCodeAt(n)?i-s%i:1,n=d(t,n)}return!0===n?-1:t.length}const _t=\"undefined\"==typeof Symbol?\"__ͼ\":Symbol.for(\"ͼ\"),jt=\"undefined\"==typeof Symbol?\"__styleSet\"+Math.floor(1e8*Math.random()):Symbol(\"styleSet\"),Ut=\"undefined\"!=typeof globalThis?globalThis:\"undefined\"!=typeof window?window:{};class $t{constructor(t,e){this.rules=[];let{finish:i}=e||{};function n(t){return/^@/.test(t)?[t]:t.split(/,\\s*/)}function s(t,e,r,o){let l=[],a=/^@(\\w+)\\b/.exec(t[0]),h=a&&\"keyframes\"==a[1];if(a&&null==e)return r.push(t[0]+\";\");for(let i in e){let o=e[i];if(/&/.test(i))s(i.split(/,\\s*/).map((e=>t.map((t=>e.replace(/&/,t))))).reduce(((t,e)=>t.concat(e))),o,r);else if(o&&\"object\"==typeof o){if(!a)throw new RangeError(\"The value of a property (\"+i+\") should be a primitive value.\");s(n(i),o,l,h)}else null!=o&&l.push(i.replace(/_.*/,\"\").replace(/[A-Z]/g,(t=>\"-\"+t.toLowerCase()))+\": \"+o+\";\")}(l.length||h)&&r.push((!i||a||o?t:t.map(i)).join(\", \")+\" {\"+l.join(\" \")+\"}\")}for(let e in t)s(n(e),t[e],this.rules)}getRules(){return this.rules.join(\"\\n\")}static newName(){let t=Ut[_t]||1;return Ut[_t]=t+1,\"ͼ\"+t.toString(36)}static mount(t,e){(t[jt]||new Kt(t)).mount(Array.isArray(e)?e:[e])}}let Qt=null;class Kt{constructor(t){if(!t.head&&t.adoptedStyleSheets&&\"undefined\"!=typeof CSSStyleSheet){if(Qt)return t.adoptedStyleSheets=[Qt.sheet].concat(t.adoptedStyleSheets),t[jt]=Qt;this.sheet=new CSSStyleSheet,t.adoptedStyleSheets=[this.sheet].concat(t.adoptedStyleSheets),Qt=this}else{this.styleTag=(t.ownerDocument||t).createElement(\"style\");let e=t.head||t;e.insertBefore(this.styleTag,e.firstChild)}this.modules=[],t[jt]=this}mount(t){let e=this.sheet,i=0,n=0;for(let s=0;s<t.length;s++){let r=t[s],o=this.modules.indexOf(r);if(o<n&&o>-1&&(this.modules.splice(o,1),n--,o=-1),-1==o){if(this.modules.splice(n++,0,r),e)for(let t=0;t<r.rules.length;t++)e.insertRule(r.rules[t],i++)}else{for(;n<o;)i+=this.modules[n++].rules.length;i+=r.rules.length,n++}}if(!e){let t=\"\";for(let e=0;e<this.modules.length;e++)t+=this.modules[e].getRules()+\"\\n\";this.styleTag.textContent=t}}}var Gt={8:\"Backspace\",9:\"Tab\",10:\"Enter\",12:\"NumLock\",13:\"Enter\",16:\"Shift\",17:\"Control\",18:\"Alt\",20:\"CapsLock\",27:\"Escape\",32:\" \",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"ArrowLeft\",38:\"ArrowUp\",39:\"ArrowRight\",40:\"ArrowDown\",44:\"PrintScreen\",45:\"Insert\",46:\"Delete\",59:\";\",61:\"=\",91:\"Meta\",92:\"Meta\",106:\"*\",107:\"+\",108:\",\",109:\"-\",110:\".\",111:\"/\",144:\"NumLock\",145:\"ScrollLock\",160:\"Shift\",161:\"Shift\",162:\"Control\",163:\"Control\",164:\"Alt\",165:\"Alt\",173:\"-\",186:\";\",187:\"=\",188:\",\",189:\"-\",190:\".\",191:\"/\",192:\"`\",219:\"[\",220:\"\\\\\",221:\"]\",222:\"'\"},Jt={48:\")\",49:\"!\",50:\"@\",51:\"#\",52:\"$\",53:\"%\",54:\"^\",55:\"&\",56:\"*\",57:\"(\",59:\":\",61:\"+\",173:\"_\",186:\":\",187:\"+\",188:\"<\",189:\"_\",190:\">\",191:\"?\",192:\"~\",219:\"{\",220:\"|\",221:\"}\",222:'\"'},Xt=\"undefined\"!=typeof navigator&&/Chrome\\/(\\d+)/.exec(navigator.userAgent);\"undefined\"!=typeof navigator&&/Gecko\\/\\d+/.test(navigator.userAgent);for(var Zt=\"undefined\"!=typeof navigator&&/Mac/.test(navigator.platform),Yt=\"undefined\"!=typeof navigator&&/MSIE \\d|Trident\\/(?:[7-9]|\\d{2,})\\..*rv:(\\d+)/.exec(navigator.userAgent),te=Zt||Xt&&+Xt[1]<57,ee=0;ee<10;ee++)Gt[48+ee]=Gt[96+ee]=String(ee);for(ee=1;ee<=24;ee++)Gt[ee+111]=\"F\"+ee;for(ee=65;ee<=90;ee++)Gt[ee]=String.fromCharCode(ee+32),Jt[ee]=String.fromCharCode(ee);for(var ie in Gt)Jt.hasOwnProperty(ie)||(Jt[ie]=Gt[ie]);function ne(t){let e;return e=11==t.nodeType?t.getSelection?t:t.ownerDocument:t,e.getSelection()}function se(t,e){return!!e&&(t==e||t.contains(1!=e.nodeType?e.parentNode:e))}function re(t,e){if(!e.anchorNode)return!1;try{return se(t,e.anchorNode)}catch(t){return!1}}function oe(t){return 3==t.nodeType?we(t,0,t.nodeValue.length).getClientRects():1==t.nodeType?t.getClientRects():[]}function le(t,e,i,n){return!!i&&(he(t,e,i,n,-1)||he(t,e,i,n,1))}function ae(t){for(var e=0;;e++)if(!(t=t.previousSibling))return e}function he(t,e,i,n,s){for(;;){if(t==i&&e==n)return!0;if(e==(s<0?0:ce(t))){if(\"DIV\"==t.nodeName)return!1;let i=t.parentNode;if(!i||1!=i.nodeType)return!1;e=ae(t)+(s<0?0:1),t=i}else{if(1!=t.nodeType)return!1;if(1==(t=t.childNodes[e+(s<0?-1:0)]).nodeType&&\"false\"==t.contentEditable)return!1;e=s<0?ce(t):0}}}function ce(t){return 3==t.nodeType?t.nodeValue.length:t.childNodes.length}const ue={left:0,right:0,top:0,bottom:0};function fe(t,e){let i=e?t.left:t.right;return{left:i,right:i,top:t.top,bottom:t.bottom}}function de(t){return{left:0,right:t.innerWidth,top:0,bottom:t.innerHeight}}class pe{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(t){return this.anchorNode==t.anchorNode&&this.anchorOffset==t.anchorOffset&&this.focusNode==t.focusNode&&this.focusOffset==t.focusOffset}setRange(t){this.set(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset)}set(t,e,i,n){this.anchorNode=t,this.anchorOffset=e,this.focusNode=i,this.focusOffset=n}}let me,ge=null;function ve(t){if(t.setActive)return t.setActive();if(ge)return t.focus(ge);let e=[];for(let i=t;i&&(e.push(i,i.scrollTop,i.scrollLeft),i!=i.ownerDocument);i=i.parentNode);if(t.focus(null==ge?{get preventScroll(){return ge={preventScroll:!0},!0}}:void 0),!ge){ge=!1;for(let t=0;t<e.length;){let i=e[t++],n=e[t++],s=e[t++];i.scrollTop!=n&&(i.scrollTop=n),i.scrollLeft!=s&&(i.scrollLeft=s)}}}function we(t,e,i=e){let n=me||(me=document.createRange());return n.setEnd(t,i),n.setStart(t,e),n}function ye(t,e,i){let n={key:e,code:e,keyCode:i,which:i,cancelable:!0},s=new KeyboardEvent(\"keydown\",n);s.synthetic=!0,t.dispatchEvent(s);let r=new KeyboardEvent(\"keyup\",n);return r.synthetic=!0,t.dispatchEvent(r),s.defaultPrevented||r.defaultPrevented}function be(t){for(;t.attributes.length;)t.removeAttributeNode(t.attributes[0])}class xe{constructor(t,e,i=!0){this.node=t,this.offset=e,this.precise=i}static before(t,e){return new xe(t.parentNode,ae(t),e)}static after(t,e){return new xe(t.parentNode,ae(t)+1,e)}}const ke=[];class Se{constructor(){this.parent=null,this.dom=null,this.dirty=2}get editorView(){if(!this.parent)throw new Error(\"Accessing view in orphan content view\");return this.parent.editorView}get overrideDOMText(){return null}get posAtStart(){return this.parent?this.parent.posBefore(this):0}get posAtEnd(){return this.posAtStart+this.length}posBefore(t){let e=this.posAtStart;for(let i of this.children){if(i==t)return e;e+=i.length+i.breakAfter}throw new RangeError(\"Invalid child in posBefore\")}posAfter(t){return this.posBefore(t)+t.length}coordsAt(t,e){return null}sync(t){if(2&this.dirty){let e,i=this.dom,n=null;for(let s of this.children){if(s.dirty){if(!s.dom&&(e=n?n.nextSibling:i.firstChild)){let t=Se.get(e);(!t||!t.parent&&t.canReuseDOM(s))&&s.reuseDOM(e)}s.sync(t),s.dirty=0}if(e=n?n.nextSibling:i.firstChild,t&&!t.written&&t.node==i&&e!=s.dom&&(t.written=!0),s.dom.parentNode==i)for(;e&&e!=s.dom;)e=Ce(e);else i.insertBefore(s.dom,e);n=s.dom}for(e=n?n.nextSibling:i.firstChild,e&&t&&t.node==i&&(t.written=!0);e;)e=Ce(e)}else if(1&this.dirty)for(let e of this.children)e.dirty&&(e.sync(t),e.dirty=0)}reuseDOM(t){}localPosFromDOM(t,e){let i;if(t==this.dom)i=this.dom.childNodes[e];else{let n=0==ce(t)?0:0==e?-1:1;for(;;){let e=t.parentNode;if(e==this.dom)break;0==n&&e.firstChild!=e.lastChild&&(n=t==e.firstChild?-1:1),t=e}i=n<0?t:t.nextSibling}if(i==this.dom.firstChild)return 0;for(;i&&!Se.get(i);)i=i.nextSibling;if(!i)return this.length;for(let t=0,e=0;;t++){let n=this.children[t];if(n.dom==i)return e;e+=n.length+n.breakAfter}}domBoundsAround(t,e,i=0){let n=-1,s=-1,r=-1,o=-1;for(let l=0,a=i,h=i;l<this.children.length;l++){let i=this.children[l],c=a+i.length;if(a<t&&c>e)return i.domBoundsAround(t,e,a);if(c>=t&&-1==n&&(n=l,s=a),a>e&&i.dom.parentNode==this.dom){r=l,o=h;break}h=c,a=c+i.breakAfter}return{from:s,to:o<0?i+this.length:o,startDOM:(n?this.children[n-1].dom.nextSibling:null)||this.dom.firstChild,endDOM:r<this.children.length&&r>=0?this.children[r].dom:null}}markDirty(t=!1){this.dirty|=2,this.markParentsDirty(t)}markParentsDirty(t){for(let e=this.parent;e;e=e.parent){if(t&&(e.dirty|=2),1&e.dirty)return;e.dirty|=1,t=!1}}setParent(t){this.parent!=t&&(this.parent=t,this.dirty&&this.markParentsDirty(!0))}setDOM(t){this.dom&&(this.dom.cmView=null),this.dom=t,t.cmView=this}get rootView(){for(let t=this;;){let e=t.parent;if(!e)return t;t=e}}replaceChildren(t,e,i=ke){this.markDirty();for(let i=t;i<e;i++){let t=this.children[i];t.parent==this&&t.destroy()}this.children.splice(t,e-t,...i);for(let t=0;t<i.length;t++)i[t].setParent(this)}ignoreMutation(t){return!1}ignoreEvent(t){return!1}childCursor(t=this.length){return new Ae(this.children,t,this.children.length)}childPos(t,e=1){return this.childCursor().findPos(t,e)}toString(){let t=this.constructor.name.replace(\"View\",\"\");return t+(this.children.length?\"(\"+this.children.join()+\")\":this.length?\"[\"+(\"Text\"==t?this.text:this.length)+\"]\":\"\")+(this.breakAfter?\"#\":\"\")}static get(t){return t.cmView}get isEditable(){return!0}merge(t,e,i,n,s,r){return!1}become(t){return!1}canReuseDOM(t){return t.constructor==this.constructor}getSide(){return 0}destroy(){this.parent=null}}function Ce(t){let e=t.nextSibling;return t.parentNode.removeChild(t),e}Se.prototype.breakAfter=0;class Ae{constructor(t,e,i){this.children=t,this.pos=e,this.i=i,this.off=0}findPos(t,e=1){for(;;){if(t>this.pos||t==this.pos&&(e>0||0==this.i||this.children[this.i-1].breakAfter))return this.off=t-this.pos,this;let i=this.children[--this.i];this.pos-=i.length+i.breakAfter}}}function Oe(t,e,i,n,s,r,o,l,a){let{children:h}=t,c=h.length?h[e]:null,u=r.length?r[r.length-1]:null,f=u?u.breakAfter:o;if(!(e==n&&c&&!o&&!f&&r.length<2&&c.merge(i,s,r.length?u:null,0==i,l,a))){if(n<h.length){let t=h[n];t&&s<t.length?(e==n&&(t=t.split(s),s=0),!f&&u&&t.merge(0,s,u,!0,0,a)?r[r.length-1]=t:(s&&t.merge(0,s,null,!1,0,a),r.push(t))):(null==t?void 0:t.breakAfter)&&(u?u.breakAfter=1:o=1),n++}for(c&&(c.breakAfter=o,i>0&&(!o&&r.length&&c.merge(i,c.length,r[0],!1,l,0)?c.breakAfter=r.shift().breakAfter:(i<c.length||c.children.length&&0==c.children[c.children.length-1].length)&&c.merge(i,c.length,null,!1,l,0),e++));e<n&&r.length;)if(h[n-1].become(r[r.length-1]))n--,r.pop(),a=r.length?0:l;else{if(!h[e].become(r[0]))break;e++,r.shift(),l=r.length?0:a}!r.length&&e&&n<h.length&&!h[e-1].breakAfter&&h[n].merge(0,0,h[e-1],!1,l,a)&&e--,(e<n||r.length)&&t.replaceChildren(e,n,r)}}function Me(t,e,i,n,s,r){let o=t.childCursor(),{i:l,off:a}=o.findPos(i,1),{i:h,off:c}=o.findPos(e,-1),u=e-i;for(let t of n)u+=t.length;t.length+=u,Oe(t,h,c,l,a,n,0,s,r)}let De=\"undefined\"!=typeof navigator?navigator:{userAgent:\"\",vendor:\"\",platform:\"\"},Te=\"undefined\"!=typeof document?document:{documentElement:{style:{}}};const Pe=/Edge\\/(\\d+)/.exec(De.userAgent),Re=/MSIE \\d/.test(De.userAgent),Ee=/Trident\\/(?:[7-9]|\\d{2,})\\..*rv:(\\d+)/.exec(De.userAgent),Be=!!(Re||Ee||Pe),Le=!Be&&/gecko\\/(\\d+)/i.test(De.userAgent),Ne=!Be&&/Chrome\\/(\\d+)/.exec(De.userAgent),Ie=\"webkitFontSmoothing\"in Te.documentElement.style,Ve=!Be&&/Apple Computer/.test(De.vendor),We=Ve&&(/Mobile\\/\\w+/.test(De.userAgent)||De.maxTouchPoints>2);var ze={mac:We||/Mac/.test(De.platform),windows:/Win/.test(De.platform),linux:/Linux|X11/.test(De.platform),ie:Be,ie_version:Re?Te.documentMode||6:Ee?+Ee[1]:Pe?+Pe[1]:0,gecko:Le,gecko_version:Le?+(/Firefox\\/(\\d+)/.exec(De.userAgent)||[0,0])[1]:0,chrome:!!Ne,chrome_version:Ne?+Ne[1]:0,ios:We,android:/Android\\b/.test(De.userAgent),webkit:Ie,safari:Ve,webkit_version:Ie?+(/\\bAppleWebKit\\/(\\d+)/.exec(navigator.userAgent)||[0,0])[1]:0,tabSize:null!=Te.documentElement.style.tabSize?\"tab-size\":\"-moz-tab-size\"};class He extends Se{constructor(t){super(),this.text=t}get length(){return this.text.length}createDOM(t){this.setDOM(t||document.createTextNode(this.text))}sync(t){this.dom||this.createDOM(),this.dom.nodeValue!=this.text&&(t&&t.node==this.dom&&(t.written=!0),this.dom.nodeValue=this.text)}reuseDOM(t){3==t.nodeType&&this.createDOM(t)}merge(t,e,i){return(!i||i instanceof He&&!(this.length-(e-t)+i.length>256))&&(this.text=this.text.slice(0,t)+(i?i.text:\"\")+this.text.slice(e),this.markDirty(),!0)}split(t){let e=new He(this.text.slice(t));return this.text=this.text.slice(0,t),this.markDirty(),e}localPosFromDOM(t,e){return t==this.dom?e:e?this.text.length:0}domAtPos(t){return new xe(this.dom,t)}domBoundsAround(t,e,i){return{from:i,to:i+this.length,startDOM:this.dom,endDOM:this.dom.nextSibling}}coordsAt(t,e){return qe(this.dom,t,e)}}class Fe extends Se{constructor(t,e=[],i=0){super(),this.mark=t,this.children=e,this.length=i;for(let t of e)t.setParent(this)}setAttrs(t){if(be(t),this.mark.class&&(t.className=this.mark.class),this.mark.attrs)for(let e in this.mark.attrs)t.setAttribute(e,this.mark.attrs[e]);return t}reuseDOM(t){t.nodeName==this.mark.tagName.toUpperCase()&&(this.setDOM(t),this.dirty|=6)}sync(t){this.dom?4&this.dirty&&this.setAttrs(this.dom):this.setDOM(this.setAttrs(document.createElement(this.mark.tagName))),super.sync(t)}merge(t,e,i,n,s,r){return(!i||!(!(i instanceof Fe&&i.mark.eq(this.mark))||t&&s<=0||e<this.length&&r<=0))&&(Me(this,t,e,i?i.children:[],s-1,r-1),this.markDirty(),!0)}split(t){let e=[],i=0,n=-1,s=0;for(let r of this.children){let o=i+r.length;o>t&&e.push(i<t?r.split(t-i):r),n<0&&i>=t&&(n=s),i=o,s++}let r=this.length-t;return this.length=t,n>-1&&(this.children.length=n,this.markDirty()),new Fe(this.mark,e,r)}domAtPos(t){return Ke(this,t)}coordsAt(t,e){return Je(this,t,e)}}function qe(t,e,i){let n=t.nodeValue.length;e>n&&(e=n);let s=e,r=e,o=0;0==e&&i<0||e==n&&i>=0?ze.chrome||ze.gecko||(e?(s--,o=1):r<n&&(r++,o=-1)):i<0?s--:r<n&&r++;let l=we(t,s,r).getClientRects();if(!l.length)return ue;let a=l[(o?o<0:i>=0)?0:l.length-1];return ze.safari&&!o&&0==a.width&&(a=Array.prototype.find.call(l,(t=>t.width))||a),o?fe(a,o<0):a||null}class _e extends Se{constructor(t,e,i){super(),this.widget=t,this.length=e,this.side=i,this.prevWidget=null}static create(t,e,i){return new(t.customView||_e)(t,e,i)}split(t){let e=_e.create(this.widget,this.length-t,this.side);return this.length-=t,e}sync(){this.dom&&this.widget.updateDOM(this.dom)||(this.dom&&this.prevWidget&&this.prevWidget.destroy(this.dom),this.prevWidget=null,this.setDOM(this.widget.toDOM(this.editorView)),this.dom.contentEditable=\"false\")}getSide(){return this.side}merge(t,e,i,n,s,r){return!(i&&(!(i instanceof _e&&this.widget.compare(i.widget))||t>0&&s<=0||e<this.length&&r<=0))&&(this.length=t+(i?i.length:0)+(this.length-e),!0)}become(t){return t.length==this.length&&t instanceof _e&&t.side==this.side&&this.widget.constructor==t.widget.constructor&&(this.widget.eq(t.widget)||this.markDirty(!0),this.dom&&!this.prevWidget&&(this.prevWidget=this.widget),this.widget=t.widget,!0)}ignoreMutation(){return!0}ignoreEvent(t){return this.widget.ignoreEvent(t)}get overrideDOMText(){if(0==this.length)return e.empty;let t=this;for(;t.parent;)t=t.parent;let i=t.editorView,n=i&&i.state.doc,s=this.posAtStart;return n?n.slice(s,s+this.length):e.empty}domAtPos(t){return 0==t?xe.before(this.dom):xe.after(this.dom,t==this.length)}domBoundsAround(){return null}coordsAt(t,e){let i=this.dom.getClientRects(),n=null;if(!i.length)return ue;for(let e=t>0?i.length-1:0;n=i[e],!(t>0?0==e:e==i.length-1||n.top<n.bottom);e+=t>0?-1:1);return this.length?n:fe(n,this.side>0)}get isEditable(){return!1}destroy(){super.destroy(),this.dom&&this.widget.destroy(this.dom)}}class je extends _e{domAtPos(t){let{topView:e,text:i}=this.widget;return e?Ue(t,0,e,i,((t,e)=>t.domAtPos(e)),(t=>new xe(i,Math.min(t,i.nodeValue.length)))):new xe(i,Math.min(t,i.nodeValue.length))}sync(){this.setDOM(this.widget.toDOM())}localPosFromDOM(t,e){let{topView:i,text:n}=this.widget;return i?$e(t,e,i,n):Math.min(e,this.length)}ignoreMutation(){return!1}get overrideDOMText(){return null}coordsAt(t,e){let{topView:i,text:n}=this.widget;return i?Ue(t,e,i,n,((t,e,i)=>t.coordsAt(e,i)),((t,e)=>qe(n,t,e))):qe(n,t,e)}destroy(){var t;super.destroy(),null===(t=this.widget.topView)||void 0===t||t.destroy()}get isEditable(){return!0}canReuseDOM(){return!0}}function Ue(t,e,i,n,s,r){if(i instanceof Fe){for(let o=i.dom.firstChild;o;o=o.nextSibling){let i=Se.get(o);if(!i)return r(t,e);let l=se(o,n),a=i.length+(l?n.nodeValue.length:0);if(t<a||t==a&&i.getSide()<=0)return l?Ue(t,e,i,n,s,r):s(i,t,e);t-=a}return s(i,i.length,-1)}return i.dom==n?r(t,e):s(i,t,e)}function $e(t,e,i,n){if(i instanceof Fe)for(let s of i.children){let i=0,r=se(s.dom,n);if(se(s.dom,t))return i+(r?$e(t,e,s,n):s.localPosFromDOM(t,e));i+=r?n.nodeValue.length:s.length}else if(i.dom==n)return Math.min(e,n.nodeValue.length);return i.localPosFromDOM(t,e)}class Qe extends Se{constructor(t){super(),this.side=t}get length(){return 0}merge(){return!1}become(t){return t instanceof Qe&&t.side==this.side}split(){return new Qe(this.side)}sync(){if(!this.dom){let t=document.createElement(\"img\");t.className=\"cm-widgetBuffer\",t.setAttribute(\"aria-hidden\",\"true\"),this.setDOM(t)}}getSide(){return this.side}domAtPos(t){return xe.before(this.dom)}localPosFromDOM(){return 0}domBoundsAround(){return null}coordsAt(t){let e=this.dom.getBoundingClientRect(),i=function(t,e){let i=t.parent,n=i?i.children.indexOf(t):-1;for(;i&&n>=0;)if(e<0?n>0:n<i.children.length){let t=i.children[n+e];if(t instanceof He){let i=t.coordsAt(e<0?t.length:0,e);if(i)return i}n+=e}else{if(!(i instanceof Fe&&i.parent)){let t=i.dom.lastChild;if(t&&\"BR\"==t.nodeName)return t.getClientRects()[0];break}n=i.parent.children.indexOf(i)+(e<0?0:1),i=i.parent}return}(this,this.side>0?-1:1);return i&&i.top<e.bottom&&i.bottom>e.top?{left:e.left,right:e.right,top:i.top,bottom:i.bottom}:e}get overrideDOMText(){return e.empty}}function Ke(t,e){let i=t.dom,{children:n}=t,s=0;for(let t=0;s<n.length;s++){let r=n[s],o=t+r.length;if(!(o==t&&r.getSide()<=0)){if(e>t&&e<o&&r.dom.parentNode==i)return r.domAtPos(e-t);if(e<=t)break;t=o}}for(let t=s;t>0;t--){let e=n[t-1];if(e.dom.parentNode==i)return e.domAtPos(e.length)}for(let t=s;t<n.length;t++){let e=n[t];if(e.dom.parentNode==i)return e.domAtPos(0)}return new xe(i,0)}function Ge(t,e,i){let n,{children:s}=t;i>0&&e instanceof Fe&&s.length&&(n=s[s.length-1])instanceof Fe&&n.mark.eq(e.mark)?Ge(n,e.children[0],i-1):(s.push(e),e.setParent(t)),t.length+=e.length}function Je(t,e,i){let n=null,s=-1,r=null,o=-1;!function t(e,i){for(let l=0,a=0;l<e.children.length&&a<=i;l++){let h=e.children[l],c=a+h.length;c>=i&&(h.children.length?t(h,i-a):!r&&(c>i||a==c&&h.getSide()>0)?(r=h,o=i-a):(a<i||a==c&&h.getSide()<0)&&(n=h,s=i-a)),a=c}}(t,e);let l=(i<0?n:r)||n||r;return l?l.coordsAt(Math.max(0,l==n?s:o),i):function(t){let e=t.dom.lastChild;if(!e)return t.dom.getBoundingClientRect();let i=oe(e);return i[i.length-1]||null}(t)}function Xe(t,e){for(let i in t)\"class\"==i&&e.class?e.class+=\" \"+t.class:\"style\"==i&&e.style?e.style+=\";\"+t.style:e[i]=t[i];return e}function Ze(t,e){if(t==e)return!0;if(!t||!e)return!1;let i=Object.keys(t),n=Object.keys(e);if(i.length!=n.length)return!1;for(let s of i)if(-1==n.indexOf(s)||t[s]!==e[s])return!1;return!0}function Ye(t,e,i){let n=null;if(e)for(let s in e)i&&s in i||t.removeAttribute(n=s);if(i)for(let s in i)e&&e[s]==i[s]||t.setAttribute(n=s,i[s]);return!!n}He.prototype.children=_e.prototype.children=Qe.prototype.children=ke;class ti{eq(t){return!1}updateDOM(t){return!1}compare(t){return this==t||this.constructor==t.constructor&&this.eq(t)}get estimatedHeight(){return-1}ignoreEvent(t){return!0}get customView(){return null}destroy(t){}}var ei=function(t){return t[t.Text=0]=\"Text\",t[t.WidgetBefore=1]=\"WidgetBefore\",t[t.WidgetAfter=2]=\"WidgetAfter\",t[t.WidgetRange=3]=\"WidgetRange\",t}(ei||(ei={}));class ii extends At{constructor(t,e,i,n){super(),this.startSide=t,this.endSide=e,this.widget=i,this.spec=n}get heightRelevant(){return!1}static mark(t){return new ni(t)}static widget(t){let e=t.side||0,i=!!t.block;return e+=i?e>0?3e8:-4e8:e>0?1e8:-1e8,new ri(t,e,e,i,t.widget||null,!1)}static replace(t){let e,i,n=!!t.block;if(t.isBlockGap)e=-5e8,i=4e8;else{let{start:s,end:r}=oi(t,n);e=(s?n?-3e8:-1:5e8)-1,i=1+(r?n?2e8:1:-6e8)}return new ri(t,e,i,n,t.widget||null,!0)}static line(t){return new si(t)}static set(t,e=!1){return Tt.of(t,e)}hasHeight(){return!!this.widget&&this.widget.estimatedHeight>-1}}ii.none=Tt.empty;class ni extends ii{constructor(t){let{start:e,end:i}=oi(t);super(e?-1:5e8,i?1:-6e8,null,t),this.tagName=t.tagName||\"span\",this.class=t.class||\"\",this.attrs=t.attributes||null}eq(t){return this==t||t instanceof ni&&this.tagName==t.tagName&&this.class==t.class&&Ze(this.attrs,t.attrs)}range(t,e=t){if(t>=e)throw new RangeError(\"Mark decorations may not be empty\");return super.range(t,e)}}ni.prototype.point=!1;class si extends ii{constructor(t){super(-2e8,-2e8,null,t)}eq(t){return t instanceof si&&Ze(this.spec.attributes,t.spec.attributes)}range(t,e=t){if(e!=t)throw new RangeError(\"Line decoration ranges must be zero-length\");return super.range(t,e)}}si.prototype.mapMode=k.TrackBefore,si.prototype.point=!0;class ri extends ii{constructor(t,e,i,n,s,r){super(e,i,s,t),this.block=n,this.isReplace=r,this.mapMode=n?e<=0?k.TrackBefore:k.TrackAfter:k.TrackDel}get type(){return this.startSide<this.endSide?ei.WidgetRange:this.startSide<=0?ei.WidgetBefore:ei.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&this.widget.estimatedHeight>=5}eq(t){return t instanceof ri&&(e=this.widget,i=t.widget,e==i||!!(e&&i&&e.compare(i)))&&this.block==t.block&&this.startSide==t.startSide&&this.endSide==t.endSide;var e,i}range(t,e=t){if(this.isReplace&&(t>e||t==e&&this.startSide>0&&this.endSide<=0))throw new RangeError(\"Invalid range for replacement decoration\");if(!this.isReplace&&e!=t)throw new RangeError(\"Widget decorations can only have zero-length ranges\");return super.range(t,e)}}function oi(t,e=!1){let{inclusiveStart:i,inclusiveEnd:n}=t;return null==i&&(i=t.inclusive),null==n&&(n=t.inclusive),{start:null!=i?i:e,end:null!=n?n:e}}function li(t,e,i,n=0){let s=i.length-1;s>=0&&i[s]+n>=t?i[s]=Math.max(i[s],e):i.push(t,e)}ri.prototype.point=!0;class ai extends Se{constructor(){super(...arguments),this.children=[],this.length=0,this.prevAttrs=void 0,this.attrs=null,this.breakAfter=0}merge(t,e,i,n,s,r){if(i){if(!(i instanceof ai))return!1;this.dom||i.transferDOM(this)}return n&&this.setDeco(i?i.attrs:null),Me(this,t,e,i?i.children:[],s,r),!0}split(t){let e=new ai;if(e.breakAfter=this.breakAfter,0==this.length)return e;let{i:i,off:n}=this.childPos(t);n&&(e.append(this.children[i].split(n),0),this.children[i].merge(n,this.children[i].length,null,!1,0,0),i++);for(let t=i;t<this.children.length;t++)e.append(this.children[t],0);for(;i>0&&0==this.children[i-1].length;)this.children[--i].destroy();return this.children.length=i,this.markDirty(),this.length=t,e}transferDOM(t){this.dom&&(this.markDirty(),t.setDOM(this.dom),t.prevAttrs=void 0===this.prevAttrs?this.attrs:this.prevAttrs,this.prevAttrs=void 0,this.dom=null)}setDeco(t){Ze(this.attrs,t)||(this.dom&&(this.prevAttrs=this.attrs,this.markDirty()),this.attrs=t)}append(t,e){Ge(this,t,e)}addLineDeco(t){let e=t.spec.attributes,i=t.spec.class;e&&(this.attrs=Xe(e,this.attrs||{})),i&&(this.attrs=Xe({class:i},this.attrs||{}))}domAtPos(t){return Ke(this,t)}reuseDOM(t){\"DIV\"==t.nodeName&&(this.setDOM(t),this.dirty|=6)}sync(t){var e;this.dom?4&this.dirty&&(be(this.dom),this.dom.className=\"cm-line\",this.prevAttrs=this.attrs?null:void 0):(this.setDOM(document.createElement(\"div\")),this.dom.className=\"cm-line\",this.prevAttrs=this.attrs?null:void 0),void 0!==this.prevAttrs&&(Ye(this.dom,this.prevAttrs,this.attrs),this.dom.classList.add(\"cm-line\"),this.prevAttrs=void 0),super.sync(t);let i=this.dom.lastChild;for(;i&&Se.get(i)instanceof Fe;)i=i.lastChild;if(!(i&&this.length&&(\"BR\"==i.nodeName||0!=(null===(e=Se.get(i))||void 0===e?void 0:e.isEditable)||ze.ios&&this.children.some((t=>t instanceof He))))){let t=document.createElement(\"BR\");t.cmIgnore=!0,this.dom.appendChild(t)}}measureTextSize(){if(0==this.children.length||this.length>20)return null;let t=0;for(let e of this.children){if(!(e instanceof He)||/[^ -~]/.test(e.text))return null;let i=oe(e.dom);if(1!=i.length)return null;t+=i[0].width}return t?{lineHeight:this.dom.getBoundingClientRect().height,charWidth:t/this.length}:null}coordsAt(t,e){return Je(this,t,e)}become(t){return!1}get type(){return ei.Text}static find(t,e){for(let i=0,n=0;i<t.children.length;i++){let s=t.children[i],r=n+s.length;if(r>=e){if(s instanceof ai)return s;if(r>e)break}n=r+s.breakAfter}return null}}class hi extends Se{constructor(t,e,i){super(),this.widget=t,this.length=e,this.type=i,this.breakAfter=0,this.prevWidget=null}merge(t,e,i,n,s,r){return!(i&&(!(i instanceof hi&&this.widget.compare(i.widget))||t>0&&s<=0||e<this.length&&r<=0))&&(this.length=t+(i?i.length:0)+(this.length-e),!0)}domAtPos(t){return 0==t?xe.before(this.dom):xe.after(this.dom,t==this.length)}split(t){let e=this.length-t;this.length=t;let i=new hi(this.widget,e,this.type);return i.breakAfter=this.breakAfter,i}get children(){return ke}sync(){this.dom&&this.widget.updateDOM(this.dom)||(this.dom&&this.prevWidget&&this.prevWidget.destroy(this.dom),this.prevWidget=null,this.setDOM(this.widget.toDOM(this.editorView)),this.dom.contentEditable=\"false\")}get overrideDOMText(){return this.parent?this.parent.view.state.doc.slice(this.posAtStart,this.posAtEnd):e.empty}domBoundsAround(){return null}become(t){return t instanceof hi&&t.type==this.type&&t.widget.constructor==this.widget.constructor&&(t.widget.eq(this.widget)||this.markDirty(!0),this.dom&&!this.prevWidget&&(this.prevWidget=this.widget),this.widget=t.widget,this.length=t.length,this.breakAfter=t.breakAfter,!0)}ignoreMutation(){return!0}ignoreEvent(t){return this.widget.ignoreEvent(t)}destroy(){super.destroy(),this.dom&&this.widget.destroy(this.dom)}}class ci{constructor(t,e,i,n){this.doc=t,this.pos=e,this.end=i,this.disallowBlockEffectsFor=n,this.content=[],this.curLine=null,this.breakAtStart=0,this.pendingBuffer=0,this.atCursorPos=!0,this.openStart=-1,this.openEnd=-1,this.text=\"\",this.textOff=0,this.cursor=t.iter(),this.skip=e}posCovered(){if(0==this.content.length)return!this.breakAtStart&&this.doc.lineAt(this.pos).from!=this.pos;let t=this.content[this.content.length-1];return!(t.breakAfter||t instanceof hi&&t.type==ei.WidgetBefore)}getLine(){return this.curLine||(this.content.push(this.curLine=new ai),this.atCursorPos=!0),this.curLine}flushBuffer(t){this.pendingBuffer&&(this.curLine.append(ui(new Qe(-1),t),t.length),this.pendingBuffer=0)}addBlockWidget(t){this.flushBuffer([]),this.curLine=null,this.content.push(t)}finish(t){t?this.pendingBuffer=0:this.flushBuffer([]),this.posCovered()||this.getLine()}buildText(t,e,i){for(;t>0;){if(this.textOff==this.text.length){let{value:e,lineBreak:i,done:n}=this.cursor.next(this.skip);if(this.skip=0,n)throw new Error(\"Ran out of text content when drawing inline views\");if(i){this.posCovered()||this.getLine(),this.content.length?this.content[this.content.length-1].breakAfter=1:this.breakAtStart=1,this.flushBuffer([]),this.curLine=null,t--;continue}this.text=e,this.textOff=0}let n=Math.min(this.text.length-this.textOff,t,512);this.flushBuffer(e.slice(e.length-i)),this.getLine().append(ui(new He(this.text.slice(this.textOff,this.textOff+n)),e),i),this.atCursorPos=!0,this.textOff+=n,t-=n,i=0}}span(t,e,i,n){this.buildText(e-t,i,n),this.pos=e,this.openStart<0&&(this.openStart=n)}point(t,e,i,n,s,r){if(this.disallowBlockEffectsFor[r]&&i instanceof ri){if(i.block)throw new RangeError(\"Block decorations may not be specified via plugins\");if(e>this.doc.lineAt(this.pos).to)throw new RangeError(\"Decorations that replace line breaks may not be specified via plugins\")}let o=e-t;if(i instanceof ri)if(i.block){let{type:t}=i;t!=ei.WidgetAfter||this.posCovered()||this.getLine(),this.addBlockWidget(new hi(i.widget||new fi(\"div\"),o,t))}else{let r=_e.create(i.widget||new fi(\"span\"),o,o?0:i.startSide),l=this.atCursorPos&&!r.isEditable&&s<=n.length&&(t<e||i.startSide>0),a=!r.isEditable&&(t<e||i.startSide<=0),h=this.getLine();2!=this.pendingBuffer||l||(this.pendingBuffer=0),this.flushBuffer(n),l&&(h.append(ui(new Qe(1),n),s),s=n.length+Math.max(0,s-n.length)),h.append(ui(r,n),s),this.atCursorPos=a,this.pendingBuffer=a?t<e?1:2:0}else this.doc.lineAt(this.pos).from==this.pos&&this.getLine().addLineDeco(i);o&&(this.textOff+o<=this.text.length?this.textOff+=o:(this.skip+=o-(this.text.length-this.textOff),this.text=\"\",this.textOff=0),this.pos=e),this.openStart<0&&(this.openStart=s)}static build(t,e,i,n,s){let r=new ci(t,e,i,s);return r.openEnd=Tt.spans(n,e,i,r),r.openStart<0&&(r.openStart=r.openEnd),r.finish(r.openEnd),r}}function ui(t,e){for(let i of e)t=new Fe(i,[t],t.length);return t}class fi extends ti{constructor(t){super(),this.tag=t}eq(t){return t.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(t){return t.nodeName.toLowerCase()==this.tag}}const di=N.define(),pi=N.define(),mi=N.define(),gi=N.define(),vi=N.define(),wi=N.define(),yi=N.define({combine:t=>t.some((t=>t))}),bi=N.define({combine:t=>t.some((t=>t))});class xi{constructor(t,e=\"nearest\",i=\"nearest\",n=5,s=5){this.range=t,this.y=e,this.x=i,this.yMargin=n,this.xMargin=s}map(t){return t.empty?this:new xi(this.range.map(t),this.y,this.x,this.yMargin,this.xMargin)}}const ki=ut.define({map:(t,e)=>t.map(e)});function Si(t,e,i){let n=t.facet(gi);n.length?n[0](e):window.onerror?window.onerror(String(e),i,void 0,void 0,e):i?console.error(i+\":\",e):console.error(e)}const Ci=N.define({combine:t=>!t.length||t[0]});let Ai=0;const Oi=N.define();class Mi{constructor(t,e,i,n){this.id=t,this.create=e,this.domEventHandlers=i,this.extension=n(this)}static define(t,e){const{eventHandlers:i,provide:n,decorations:s}=e||{};return new Mi(Ai++,t,i,(t=>{let e=[Oi.of(t)];return s&&e.push(Ri.of((e=>{let i=e.plugin(t);return i?s(i):ii.none}))),n&&e.push(n(t)),e}))}static fromClass(t,e){return Mi.define((e=>new t(e)),e)}}class Di{constructor(t){this.spec=t,this.mustUpdate=null,this.value=null}update(t){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(e){if(Si(t.state,e,\"CodeMirror plugin crashed\"),this.value.destroy)try{this.value.destroy()}catch(t){}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.create(t)}catch(e){Si(t.state,e,\"CodeMirror plugin crashed\"),this.deactivate()}return this}destroy(t){var e;if(null===(e=this.value)||void 0===e?void 0:e.destroy)try{this.value.destroy()}catch(e){Si(t.state,e,\"CodeMirror plugin crashed\")}}deactivate(){this.spec=this.value=null}}const Ti=N.define(),Pi=N.define(),Ri=N.define(),Ei=N.define(),Bi=N.define(),Li=N.define();class Ni{constructor(t,e,i,n){this.fromA=t,this.toA=e,this.fromB=i,this.toB=n}join(t){return new Ni(Math.min(this.fromA,t.fromA),Math.max(this.toA,t.toA),Math.min(this.fromB,t.fromB),Math.max(this.toB,t.toB))}addToSet(t){let e=t.length,i=this;for(;e>0;e--){let n=t[e-1];if(!(n.fromA>i.toA)){if(n.toA<i.fromA)break;i=i.join(n),t.splice(e-1,1)}}return t.splice(e,0,i),t}static extendWithRanges(t,e){if(0==e.length)return t;let i=[];for(let n=0,s=0,r=0,o=0;;n++){let l=n==t.length?null:t[n],a=r-o,h=l?l.fromB:1e9;for(;s<e.length&&e[s]<h;){let t=e[s],n=e[s+1],r=Math.max(o,t),l=Math.min(h,n);if(r<=l&&new Ni(r+a,l+a,r,l).addToSet(i),n>h)break;s+=2}if(!l)return i;new Ni(l.fromA,l.toA,l.fromB,l.toB).addToSet(i),r=l.toA,o=l.toB}}}class Ii{constructor(t,e,i){this.view=t,this.state=e,this.transactions=i,this.flags=0,this.startState=t.state,this.changes=C.empty(this.startState.doc.length);for(let t of i)this.changes=this.changes.compose(t.changes);let n=[];this.changes.iterChangedRanges(((t,e,i,s)=>n.push(new Ni(t,e,i,s)))),this.changedRanges=n;let s=t.hasFocus;s!=t.inputState.notifiedFocused&&(t.inputState.notifiedFocused=s,this.flags|=1)}static create(t,e,i){return new Ii(t,e,i)}get viewportChanged(){return(4&this.flags)>0}get heightChanged(){return(2&this.flags)>0}get geometryChanged(){return this.docChanged||(10&this.flags)>0}get focusChanged(){return(1&this.flags)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some((t=>t.selection))}get empty(){return 0==this.flags&&0==this.transactions.length}}var Vi=function(t){return t[t.LTR=0]=\"LTR\",t[t.RTL=1]=\"RTL\",t}(Vi||(Vi={}));const Wi=Vi.LTR,zi=Vi.RTL;function Hi(t){let e=[];for(let i=0;i<t.length;i++)e.push(1<<+t[i]);return e}const Fi=Hi(\"88888888888888888888888888888888888666888888787833333333337888888000000000000000000000000008888880000000000000000000000000088888888888888888888888888888888888887866668888088888663380888308888800000000000000000000000800000000000000000000000000000008\"),qi=Hi(\"4444448826627288999999999992222222222222222222222222222222222222222222222229999999999999999999994444444444644222822222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222999999949999999229989999223333333333\"),_i=Object.create(null),ji=[];for(let t of[\"()\",\"[]\",\"{}\"]){let e=t.charCodeAt(0),i=t.charCodeAt(1);_i[e]=i,_i[i]=-e}const Ui=/[\\u0590-\\u05f4\\u0600-\\u06ff\\u0700-\\u08ac\\ufb50-\\ufdff]/;class $i{constructor(t,e,i){this.from=t,this.to=e,this.level=i}get dir(){return this.level%2?zi:Wi}side(t,e){return this.dir==e==t?this.to:this.from}static find(t,e,i,n){let s=-1;for(let r=0;r<t.length;r++){let o=t[r];if(o.from<=e&&o.to>=e){if(o.level==i)return r;(s<0||(0!=n?n<0?o.from<e:o.to>e:t[s].level>o.level))&&(s=r)}}if(s<0)throw new RangeError(\"Index out of range\");return s}}const Qi=[];function Ki(t){return[new $i(0,t,0)]}let Gi=\"\";function Ji(t,e,i,n,s){var r;let o=n.head-t.from,l=-1;if(0==o){if(!s||!t.length)return null;e[0].level!=i&&(o=e[0].side(!1,i),l=0)}else if(o==t.length){if(s)return null;let t=e[e.length-1];t.level!=i&&(o=t.side(!0,i),l=e.length-1)}l<0&&(l=$i.find(e,o,null!==(r=n.bidiLevel)&&void 0!==r?r:-1,n.assoc));let a=e[l];o==a.side(s,i)&&(a=e[l+=s?1:-1],o=a.side(!s,i));let h=s==(a.dir==i),c=d(t.text,o,h);if(Gi=t.text.slice(Math.min(o,c),Math.max(o,c)),c!=a.side(s,i))return E.cursor(c+t.from,h?-1:1,a.level);let u=l==(s?e.length-1:0)?null:e[l+(s?1:-1)];return u||a.level==i?u&&u.level<a.level?E.cursor(u.side(!s,i)+t.from,s?1:-1,u.level):E.cursor(c+t.from,s?-1:1,a.level):E.cursor(s?t.to:t.from,s?-1:1,i)}const Xi=\"￿\";class Zi{constructor(t,e){this.points=t,this.text=\"\",this.lineSeparator=e.facet(St.lineSeparator)}append(t){this.text+=t}lineBreak(){this.text+=Xi}readRange(t,e){if(!t)return this;let i=t.parentNode;for(let n=t;;){this.findPointBefore(i,n),this.readNode(n);let t=n.nextSibling;if(t==e)break;let s=Se.get(n),r=Se.get(t);(s&&r?s.breakAfter:(s?s.breakAfter:Yi(n))||Yi(t)&&(\"BR\"!=n.nodeName||n.cmIgnore))&&this.lineBreak(),n=t}return this.findPointBefore(i,e),this}readTextNode(t){let e=t.nodeValue;for(let i of this.points)i.node==t&&(i.pos=this.text.length+Math.min(i.offset,e.length));for(let i=0,n=this.lineSeparator?null:/\\r\\n?|\\n/g;;){let s,r=-1,o=1;if(this.lineSeparator?(r=e.indexOf(this.lineSeparator,i),o=this.lineSeparator.length):(s=n.exec(e))&&(r=s.index,o=s[0].length),this.append(e.slice(i,r<0?e.length:r)),r<0)break;if(this.lineBreak(),o>1)for(let e of this.points)e.node==t&&e.pos>this.text.length&&(e.pos-=o-1);i=r+o}}readNode(t){if(t.cmIgnore)return;let e=Se.get(t),i=e&&e.overrideDOMText;if(null!=i){this.findPointInside(t,i.length);for(let t=i.iter();!t.next().done;)t.lineBreak?this.lineBreak():this.append(t.value)}else 3==t.nodeType?this.readTextNode(t):\"BR\"==t.nodeName?t.nextSibling&&this.lineBreak():1==t.nodeType&&this.readRange(t.firstChild,null)}findPointBefore(t,e){for(let i of this.points)i.node==t&&t.childNodes[i.offset]==e&&(i.pos=this.text.length)}findPointInside(t,e){for(let i of this.points)(3==t.nodeType?i.node==t:t.contains(i.node))&&(i.pos=this.text.length+Math.min(e,i.offset))}}function Yi(t){return 1==t.nodeType&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\\d|SECTION|PRE)$/.test(t.nodeName)}class tn{constructor(t,e){this.node=t,this.offset=e,this.pos=-1}}class en extends Se{constructor(t){super(),this.view=t,this.compositionDeco=ii.none,this.decorations=[],this.dynamicDecorationMap=[],this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.setDOM(t.contentDOM),this.children=[new ai],this.children[0].setParent(this),this.updateDeco(),this.updateInner([new Ni(0,0,0,t.state.doc.length)],0)}get editorView(){return this.view}get length(){return this.view.state.doc.length}update(t){let e=t.changedRanges;this.minWidth>0&&e.length&&(e.every((({fromA:t,toA:e})=>e<this.minWidthFrom||t>this.minWidthTo))?(this.minWidthFrom=t.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=t.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.view.inputState.composing<0?this.compositionDeco=ii.none:(t.transactions.length||this.dirty)&&(this.compositionDeco=function(t,e){let i=sn(t);if(!i)return ii.none;let{from:n,to:s,node:r,text:o}=i,l=e.mapPos(n,1),a=Math.max(l,e.mapPos(s,-1)),{state:h}=t,c=3==r.nodeType?r.nodeValue:new Zi([],h).readRange(r.firstChild,null).text;if(a-l<c.length)if(h.doc.sliceString(l,Math.min(h.doc.length,l+c.length),Xi)==c)a=l+c.length;else{if(h.doc.sliceString(Math.max(0,a-c.length),a,Xi)!=c)return ii.none;l=a-c.length}else if(h.doc.sliceString(l,a,Xi)!=c)return ii.none;let u=Se.get(r);u instanceof je?u=u.widget.topView:u&&(u.parent=null);return ii.set(ii.replace({widget:new rn(r,o,u),inclusive:!0}).range(l,a))}(this.view,t.changes)),(ze.ie||ze.chrome)&&!this.compositionDeco.size&&t&&t.state.doc.lines!=t.startState.doc.lines&&(this.forceSelection=!0);let i=function(t,e,i){let n=new ln;return Tt.compare(t,e,i,n),n.changes}(this.decorations,this.updateDeco(),t.changes);return e=Ni.extendWithRanges(e,i),(0!=this.dirty||0!=e.length)&&(this.updateInner(e,t.startState.doc.length),t.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(t,e){this.view.viewState.mustMeasureContent=!0,this.updateChildren(t,e);let{observer:i}=this.view;i.ignore((()=>{this.dom.style.height=this.view.viewState.contentHeight+\"px\",this.dom.style.flexBasis=this.minWidth?this.minWidth+\"px\":\"\";let t=ze.chrome||ze.ios?{node:i.selectionRange.focusNode,written:!1}:void 0;this.sync(t),this.dirty=0,t&&(t.written||i.selectionRange.focusNode!=t.node)&&(this.forceSelection=!0),this.dom.style.height=\"\"}));let n=[];if(this.view.viewport.from||this.view.viewport.to<this.view.state.doc.length)for(let t of this.children)t instanceof hi&&t.widget instanceof nn&&n.push(t.dom);i.updateGaps(n)}updateChildren(t,e){let i=this.childCursor(e);for(let e=t.length-1;;e--){let n=e>=0?t[e]:null;if(!n)break;let{fromA:s,toA:r,fromB:o,toB:l}=n,{content:a,breakAtStart:h,openStart:c,openEnd:u}=ci.build(this.view.state.doc,o,l,this.decorations,this.dynamicDecorationMap),{i:f,off:d}=i.findPos(r,1),{i:p,off:m}=i.findPos(s,-1);Oe(this,p,m,f,d,a,h,c,u)}}updateSelection(t=!1,e=!1){if(!t&&this.view.observer.selectionRange.focusNode||this.view.observer.readSelectionRange(),!e&&!this.mayControlSelection())return;let i=this.forceSelection;this.forceSelection=!1;let n=this.view.state.selection.main,s=this.domAtPos(n.anchor),r=n.empty?s:this.domAtPos(n.head);if(ze.gecko&&n.empty&&(1==(o=s).node.nodeType&&o.node.firstChild&&(0==o.offset||\"false\"==o.node.childNodes[o.offset-1].contentEditable)&&(o.offset==o.node.childNodes.length||\"false\"==o.node.childNodes[o.offset].contentEditable))){let t=document.createTextNode(\"\");this.view.observer.ignore((()=>s.node.insertBefore(t,s.node.childNodes[s.offset]||null))),s=r=new xe(t,0),i=!0}var o;let l=this.view.observer.selectionRange;!i&&l.focusNode&&le(s.node,s.offset,l.anchorNode,l.anchorOffset)&&le(r.node,r.offset,l.focusNode,l.focusOffset)||(this.view.observer.ignore((()=>{ze.android&&ze.chrome&&this.dom.contains(l.focusNode)&&function(t,e){for(let i=t;i&&i!=e;i=i.assignedSlot||i.parentNode)if(1==i.nodeType&&\"false\"==i.contentEditable)return!0;return!1}(l.focusNode,this.dom)&&(this.dom.blur(),this.dom.focus({preventScroll:!0}));let t=ne(this.view.root);if(t)if(n.empty){if(ze.gecko){let t=(e=s.node,i=s.offset,1!=e.nodeType?0:(i&&\"false\"==e.childNodes[i-1].contentEditable?1:0)|(i<e.childNodes.length&&\"false\"==e.childNodes[i].contentEditable?2:0));if(t&&3!=t){let e=on(s.node,s.offset,1==t?1:-1);e&&(s=new xe(e,1==t?0:e.nodeValue.length))}}t.collapse(s.node,s.offset),null!=n.bidiLevel&&null!=l.cursorBidiLevel&&(l.cursorBidiLevel=n.bidiLevel)}else if(t.extend){t.collapse(s.node,s.offset);try{t.extend(r.node,r.offset)}catch(t){}}else{let e=document.createRange();n.anchor>n.head&&([s,r]=[r,s]),e.setEnd(r.node,r.offset),e.setStart(s.node,s.offset),t.removeAllRanges(),t.addRange(e)}else;var e,i})),this.view.observer.setSelectionRange(s,r)),this.impreciseAnchor=s.precise?null:new xe(l.anchorNode,l.anchorOffset),this.impreciseHead=r.precise?null:new xe(l.focusNode,l.focusOffset)}enforceCursorAssoc(){if(this.compositionDeco.size)return;let{view:t}=this,e=t.state.selection.main,i=ne(t.root),{anchorNode:n,anchorOffset:s}=t.observer.selectionRange;if(!(i&&e.empty&&e.assoc&&i.modify))return;let r=ai.find(this,e.head);if(!r)return;let o=r.posAtStart;if(e.head==o||e.head==o+r.length)return;let l=this.coordsAt(e.head,-1),a=this.coordsAt(e.head,1);if(!l||!a||l.bottom>a.top)return;let h=this.domAtPos(e.head+e.assoc);i.collapse(h.node,h.offset),i.modify(\"move\",e.assoc<0?\"forward\":\"backward\",\"lineboundary\"),t.observer.readSelectionRange();let c=t.observer.selectionRange;t.docView.posFromDOM(c.anchorNode,c.anchorOffset)!=e.from&&i.collapse(n,s)}mayControlSelection(){let t=this.view.root.activeElement;return t==this.dom||re(this.dom,this.view.observer.selectionRange)&&!(t&&this.dom.contains(t))}nearest(t){for(let e=t;e;){let t=Se.get(e);if(t&&t.rootView==this)return t;e=e.parentNode}return null}posFromDOM(t,e){let i=this.nearest(t);if(!i)throw new RangeError(\"Trying to find position for a DOM position outside of the document\");return i.localPosFromDOM(t,e)+i.posAtStart}domAtPos(t){let{i:e,off:i}=this.childCursor().findPos(t,-1);for(;e<this.children.length-1;){let t=this.children[e];if(i<t.length||t instanceof ai)break;e++,i=0}return this.children[e].domAtPos(i)}coordsAt(t,e){for(let i=this.length,n=this.children.length-1;;n--){let s=this.children[n],r=i-s.breakAfter-s.length;if(t>r||t==r&&s.type!=ei.WidgetBefore&&s.type!=ei.WidgetAfter&&(!n||2==e||this.children[n-1].breakAfter||this.children[n-1].type==ei.WidgetBefore&&e>-2))return s.coordsAt(t-r,e);i=r}}measureVisibleLineHeights(t){let e=[],{from:i,to:n}=t,s=this.view.contentDOM.clientWidth,r=s>Math.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,o=-1,l=this.view.textDirection==Vi.LTR;for(let t=0,a=0;a<this.children.length;a++){let h=this.children[a],c=t+h.length;if(c>n)break;if(t>=i){let i=h.dom.getBoundingClientRect();if(e.push(i.height),r){let e=h.dom.lastChild,n=e?oe(e):[];if(n.length){let e=n[n.length-1],r=l?e.right-i.left:i.right-e.left;r>o&&(o=r,this.minWidth=s,this.minWidthFrom=t,this.minWidthTo=c)}}}t=c+h.breakAfter}return e}textDirectionAt(t){let{i:e}=this.childPos(t,1);return\"rtl\"==getComputedStyle(this.children[e].dom).direction?Vi.RTL:Vi.LTR}measureTextSize(){for(let t of this.children)if(t instanceof ai){let e=t.measureTextSize();if(e)return e}let t,e,i=document.createElement(\"div\");return i.className=\"cm-line\",i.style.width=\"99999px\",i.textContent=\"abc def ghi jkl mno pqr stu\",this.view.observer.ignore((()=>{this.dom.appendChild(i);let n=oe(i.firstChild)[0];t=i.getBoundingClientRect().height,e=n?n.width/27:7,i.remove()})),{lineHeight:t,charWidth:e}}childCursor(t=this.length){let e=this.children.length;return e&&(t-=this.children[--e].length),new Ae(this.children,t,e)}computeBlockGapDeco(){let t=[],e=this.view.viewState;for(let i=0,n=0;;n++){let s=n==e.viewports.length?null:e.viewports[n],r=s?s.from-1:this.length;if(r>i){let n=e.lineBlockAt(r).bottom-e.lineBlockAt(i).top;t.push(ii.replace({widget:new nn(n),block:!0,inclusive:!0,isBlockGap:!0}).range(i,r))}if(!s)break;i=s.to+1}return ii.set(t)}updateDeco(){let t=this.view.state.facet(Ri).map(((t,e)=>(this.dynamicDecorationMap[e]=\"function\"==typeof t)?t(this.view):t));for(let e=t.length;e<t.length+3;e++)this.dynamicDecorationMap[e]=!1;return this.decorations=[...t,this.compositionDeco,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco]}scrollIntoView(t){let e,{range:i}=t,n=this.coordsAt(i.head,i.empty?i.assoc:i.head>i.anchor?-1:1);if(!n)return;!i.empty&&(e=this.coordsAt(i.anchor,i.anchor>i.head?-1:1))&&(n={left:Math.min(n.left,e.left),top:Math.min(n.top,e.top),right:Math.max(n.right,e.right),bottom:Math.max(n.bottom,e.bottom)});let s=0,r=0,o=0,l=0;for(let t of this.view.state.facet(Bi).map((t=>t(this.view))))if(t){let{left:e,right:i,top:n,bottom:a}=t;null!=e&&(s=Math.max(s,e)),null!=i&&(r=Math.max(r,i)),null!=n&&(o=Math.max(o,n)),null!=a&&(l=Math.max(l,a))}let a={left:n.left-s,top:n.top-o,right:n.right+r,bottom:n.bottom+l};!function(t,e,i,n,s,r,o,l){let a=t.ownerDocument,h=a.defaultView||window;for(let c=t;c;)if(1==c.nodeType){let t,u=c==a.body;if(u)t=de(h);else{if(c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.assignedSlot||c.parentNode;continue}let e=c.getBoundingClientRect();t={left:e.left,right:e.left+c.clientWidth,top:e.top,bottom:e.top+c.clientHeight}}let f=0,d=0;if(\"nearest\"==s)e.top<t.top?(d=-(t.top-e.top+o),i>0&&e.bottom>t.bottom+d&&(d=e.bottom-t.bottom+d+o)):e.bottom>t.bottom&&(d=e.bottom-t.bottom+o,i<0&&e.top-d<t.top&&(d=-(t.top+d-e.top+o)));else{let n=e.bottom-e.top,r=t.bottom-t.top;d=(\"center\"==s&&n<=r?e.top+n/2-r/2:\"start\"==s||\"center\"==s&&i<0?e.top-o:e.bottom-r+o)-t.top}if(\"nearest\"==n?e.left<t.left?(f=-(t.left-e.left+r),i>0&&e.right>t.right+f&&(f=e.right-t.right+f+r)):e.right>t.right&&(f=e.right-t.right+r,i<0&&e.left<t.left+f&&(f=-(t.left+f-e.left+r))):f=(\"center\"==n?e.left+(e.right-e.left)/2-(t.right-t.left)/2:\"start\"==n==l?e.left-r:e.right-(t.right-t.left)+r)-t.left,f||d)if(u)h.scrollBy(f,d);else{let t=0,i=0;if(d){let t=c.scrollTop;c.scrollTop+=d,i=c.scrollTop-t}if(f){let e=c.scrollLeft;c.scrollLeft+=f,t=c.scrollLeft-e}e={left:e.left-t,top:e.top-i,right:e.right-t,bottom:e.bottom-i},t&&Math.abs(t-f)<1&&(n=\"nearest\"),i&&Math.abs(i-d)<1&&(s=\"nearest\")}if(u)break;c=c.assignedSlot||c.parentNode}else{if(11!=c.nodeType)break;c=c.host}}(this.view.scrollDOM,a,i.head<i.anchor?-1:1,t.x,t.y,t.xMargin,t.yMargin,this.view.textDirection==Vi.LTR)}}class nn extends ti{constructor(t){super(),this.height=t}toDOM(){let t=document.createElement(\"div\");return this.updateDOM(t),t}eq(t){return t.height==this.height}updateDOM(t){return t.style.height=this.height+\"px\",!0}get estimatedHeight(){return this.height}}function sn(t){let e=t.observer.selectionRange,i=e.focusNode&&on(e.focusNode,e.focusOffset,0);if(!i)return null;let n=t.docView.nearest(i);if(!n)return null;if(n instanceof ai){let t=i;for(;t.parentNode!=n.dom;)t=t.parentNode;let e=t.previousSibling;for(;e&&!Se.get(e);)e=e.previousSibling;let s=e?Se.get(e).posAtEnd:n.posAtStart;return{from:s,to:s,node:t,text:i}}{for(;;){let{parent:t}=n;if(!t)return null;if(t instanceof ai)break;n=t}let t=n.posAtStart;return{from:t,to:t+n.length,node:n.dom,text:i}}}class rn extends ti{constructor(t,e,i){super(),this.top=t,this.text=e,this.topView=i}eq(t){return this.top==t.top&&this.text==t.text}toDOM(){return this.top}ignoreEvent(){return!1}get customView(){return je}}function on(t,e,i){for(;;){if(3==t.nodeType)return t;if(1==t.nodeType&&e>0&&i<=0)e=ce(t=t.childNodes[e-1]);else{if(!(1==t.nodeType&&e<t.childNodes.length&&i>=0))return null;t=t.childNodes[e],e=0}}}class ln{constructor(){this.changes=[]}compareRange(t,e){li(t,e,this.changes)}comparePoint(t,e){li(t,e,this.changes)}}function an(t,e){return e.left>t?e.left-t:Math.max(0,t-e.right)}function hn(t,e){return e.top>t?e.top-t:Math.max(0,t-e.bottom)}function cn(t,e){return t.top<e.bottom-1&&t.bottom>e.top+1}function un(t,e){return e<t.top?{top:e,left:t.left,right:t.right,bottom:t.bottom}:t}function fn(t,e){return e>t.bottom?{top:t.top,left:t.left,right:t.right,bottom:e}:t}function dn(t,e,i){let n,s,r,o,l,a,h,c,u=!1;for(let f=t.firstChild;f;f=f.nextSibling){let t=oe(f);for(let d=0;d<t.length;d++){let p=t[d];s&&cn(s,p)&&(p=un(fn(p,s.bottom),s.top));let m=an(e,p),g=hn(i,p);if(0==m&&0==g)return 3==f.nodeType?pn(f,e,i):dn(f,e,i);(!n||o>g||o==g&&r>m)&&(n=f,s=p,r=m,o=g,u=!m||(m>0?d<t.length-1:d>0)),0==m?i>p.bottom&&(!h||h.bottom<p.bottom)?(l=f,h=p):i<p.top&&(!c||c.top>p.top)&&(a=f,c=p):h&&cn(h,p)?h=fn(h,p.bottom):c&&cn(c,p)&&(c=un(c,p.top))}}if(h&&h.bottom>=i?(n=l,s=h):c&&c.top<=i&&(n=a,s=c),!n)return{node:t,offset:0};let f=Math.max(s.left,Math.min(s.right,e));return 3==n.nodeType?pn(n,f,i):u&&\"false\"!=n.contentEditable?dn(n,f,i):{node:t,offset:Array.prototype.indexOf.call(t.childNodes,n)+(e>=(s.left+s.right)/2?1:0)}}function pn(t,e,i){let n=t.nodeValue.length,s=-1,r=1e9,o=0;for(let l=0;l<n;l++){let n=we(t,l,l+1).getClientRects();for(let a=0;a<n.length;a++){let h=n[a];if(h.top==h.bottom)continue;o||(o=e-h.left);let c=(h.top>i?h.top-i:i-h.bottom)-1;if(h.left-1<=e&&h.right+1>=e&&c<r){let i=e>=(h.left+h.right)/2,n=i;if(ze.chrome||ze.gecko){we(t,l).getBoundingClientRect().left==h.right&&(n=!i)}if(c<=0)return{node:t,offset:l+(n?1:0)};s=l+(n?1:0),r=c}}}return{node:t,offset:s>-1?s:o>0?t.nodeValue.length:0}}function mn(t,{x:e,y:i},n,s=-1){var r;let o,l=t.contentDOM.getBoundingClientRect(),a=l.top+t.viewState.paddingTop,{docHeight:h}=t.viewState,c=i-a;if(c<0)return 0;if(c>h)return t.state.doc.length;for(let e=t.defaultLineHeight/2,i=!1;o=t.elementAtHeight(c),o.type!=ei.Text;)for(;c=s>0?o.bottom+e:o.top-e,!(c>=0&&c<=h);){if(i)return n?null:0;i=!0,s=-s}i=a+c;let u=o.from;if(u<t.viewport.from)return 0==t.viewport.from?0:n?null:gn(t,l,o,e,i);if(u>t.viewport.to)return t.viewport.to==t.state.doc.length?t.state.doc.length:n?null:gn(t,l,o,e,i);let f=t.dom.ownerDocument,d=t.root.elementFromPoint?t.root:f,p=d.elementFromPoint(e,i);p&&!t.contentDOM.contains(p)&&(p=null),p||(e=Math.max(l.left+1,Math.min(l.right-1,e)),p=d.elementFromPoint(e,i),p&&!t.contentDOM.contains(p)&&(p=null));let m,g=-1;if(p&&0!=(null===(r=t.docView.nearest(p))||void 0===r?void 0:r.isEditable))if(f.caretPositionFromPoint){let t=f.caretPositionFromPoint(e,i);t&&({offsetNode:m,offset:g}=t)}else if(f.caretRangeFromPoint){let n=f.caretRangeFromPoint(e,i);n&&(({startContainer:m,startOffset:g}=n),(!t.contentDOM.contains(m)||ze.safari&&function(t,e,i){let n;if(3!=t.nodeType||e!=(n=t.nodeValue.length))return!1;for(let e=t.nextSibling;e;e=e.nextSibling)if(1!=e.nodeType||\"BR\"!=e.nodeName)return!1;return we(t,n-1,n).getBoundingClientRect().left>i}(m,g,e)||ze.chrome&&function(t,e,i){if(0!=e)return!1;for(let e=t;;){let t=e.parentNode;if(!t||1!=t.nodeType||t.firstChild!=e)return!1;if(t.classList.contains(\"cm-line\"))break;e=t}let n=1==t.nodeType?t.getBoundingClientRect():we(t,0,Math.max(t.nodeValue.length,1)).getBoundingClientRect();return i-n.left>5}(m,g,e))&&(m=void 0))}if(!m||!t.docView.dom.contains(m)){let n=ai.find(t.docView,u);if(!n)return c>o.top+o.height/2?o.to:o.from;({node:m,offset:g}=dn(n.dom,e,i))}return t.docView.posFromDOM(m,g)}function gn(t,e,i,n,s){let r=Math.round((n-e.left)*t.defaultCharacterWidth);if(t.lineWrapping&&i.height>1.5*t.defaultLineHeight){r+=Math.floor((s-i.top)/t.defaultLineHeight)*t.viewState.heightOracle.lineLength}let o=t.state.sliceDoc(i.from,i.to);return i.from+qt(o,r,t.state.tabSize)}function vn(t,e,i,n){let s=t.state.doc.lineAt(e.head),r=t.bidiSpans(s),o=t.textDirectionAt(s.from);for(let l=e,a=null;;){let e=Ji(s,r,o,l,i),h=Gi;if(!e){if(s.number==(i?t.state.doc.lines:1))return l;h=\"\\n\",s=t.state.doc.line(s.number+(i?1:-1)),r=t.bidiSpans(s),e=E.cursor(i?s.from:s.to)}if(a){if(!a(h))return l}else{if(!n)return e;a=n(h)}l=e}}function wn(t,e,i){let n=t.state.facet(Ei).map((e=>e(t)));for(;;){let t=!1;for(let s of n)s.between(i.from-1,i.from+1,((n,s,r)=>{i.from>n&&i.from<s&&(i=e.head>i.from?E.cursor(n,1):E.cursor(s,-1),t=!0)}));if(!t)return i}}class yn{constructor(t){this.lastKeyCode=0,this.lastKeyTime=0,this.lastTouchTime=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.chromeScrollHack=-1,this.pendingIOSKey=void 0,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastEscPress=0,this.lastContextMenu=0,this.scrollHandlers=[],this.registeredEvents=[],this.customHandlers=[],this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.mouseSelection=null;for(let e in An){let i=An[e];t.contentDOM.addEventListener(e,(n=>{Cn(t,n)&&!this.ignoreDuringComposition(n)&&(\"keydown\"==e&&this.keydown(t,n)||(this.mustFlushObserver(n)&&t.observer.forceFlush(),this.runCustomHandlers(e,t,n)?n.preventDefault():i(t,n)))}),On[e]),this.registeredEvents.push(e)}ze.chrome&&102==ze.chrome_version&&t.scrollDOM.addEventListener(\"wheel\",(()=>{this.chromeScrollHack<0?t.contentDOM.style.pointerEvents=\"none\":window.clearTimeout(this.chromeScrollHack),this.chromeScrollHack=setTimeout((()=>{this.chromeScrollHack=-1,t.contentDOM.style.pointerEvents=\"\"}),100)}),{passive:!0}),this.notifiedFocused=t.hasFocus,ze.safari&&t.contentDOM.addEventListener(\"input\",(()=>null))}setSelectionOrigin(t){this.lastSelectionOrigin=t,this.lastSelectionTime=Date.now()}ensureHandlers(t,e){var i;let n;this.customHandlers=[];for(let s of e)if(n=null===(i=s.update(t).spec)||void 0===i?void 0:i.domEventHandlers){this.customHandlers.push({plugin:s.value,handlers:n});for(let e in n)this.registeredEvents.indexOf(e)<0&&\"scroll\"!=e&&(this.registeredEvents.push(e),t.contentDOM.addEventListener(e,(i=>{Cn(t,i)&&this.runCustomHandlers(e,t,i)&&i.preventDefault()})))}}runCustomHandlers(t,e,i){for(let n of this.customHandlers){let s=n.handlers[t];if(s)try{if(s.call(n.plugin,i,e)||i.defaultPrevented)return!0}catch(t){Si(e.state,t)}}return!1}runScrollHandlers(t,e){this.lastScrollTop=t.scrollDOM.scrollTop,this.lastScrollLeft=t.scrollDOM.scrollLeft;for(let i of this.customHandlers){let n=i.handlers.scroll;if(n)try{n.call(i.plugin,e,t)}catch(e){Si(t.state,e)}}}keydown(t,e){if(this.lastKeyCode=e.keyCode,this.lastKeyTime=Date.now(),9==e.keyCode&&Date.now()<this.lastEscPress+2e3)return!0;if(ze.android&&ze.chrome&&!e.synthetic&&(13==e.keyCode||8==e.keyCode))return t.observer.delayAndroidKey(e.key,e.keyCode),!0;let i;return!(!ze.ios||e.synthetic||e.altKey||e.metaKey||!((i=bn.find((t=>t.keyCode==e.keyCode)))&&!e.ctrlKey||xn.indexOf(e.key)>-1&&e.ctrlKey&&!e.shiftKey))&&(this.pendingIOSKey=i||e,setTimeout((()=>this.flushIOSKey(t)),250),!0)}flushIOSKey(t){let e=this.pendingIOSKey;return!!e&&(this.pendingIOSKey=void 0,ye(t.contentDOM,e.key,e.keyCode))}ignoreDuringComposition(t){return!!/^key/.test(t.type)&&(this.composing>0||!!(ze.safari&&!ze.ios&&Date.now()-this.compositionEndedAt<100)&&(this.compositionEndedAt=0,!0))}mustFlushObserver(t){return\"keydown\"==t.type&&229!=t.keyCode}startMouseSelection(t){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=t}update(t){this.mouseSelection&&this.mouseSelection.update(t),t.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}const bn=[{key:\"Backspace\",keyCode:8,inputType:\"deleteContentBackward\"},{key:\"Enter\",keyCode:13,inputType:\"insertParagraph\"},{key:\"Delete\",keyCode:46,inputType:\"deleteContentForward\"}],xn=\"dthko\",kn=[16,17,18,20,91,92,224,225];class Sn{constructor(t,e,i,n){this.view=t,this.style=i,this.mustSelect=n,this.lastEvent=e;let s=t.contentDOM.ownerDocument;s.addEventListener(\"mousemove\",this.move=this.move.bind(this)),s.addEventListener(\"mouseup\",this.up=this.up.bind(this)),this.extend=e.shiftKey,this.multiple=t.state.facet(St.allowMultipleSelections)&&function(t,e){let i=t.state.facet(di);return i.length?i[0](e):ze.mac?e.metaKey:e.ctrlKey}(t,e),this.dragMove=function(t,e){let i=t.state.facet(pi);return i.length?i[0](e):ze.mac?!e.altKey:!e.ctrlKey}(t,e),this.dragging=!(!function(t,e){let{main:i}=t.state.selection;if(i.empty)return!1;let n=ne(t.root);if(!n||0==n.rangeCount)return!0;let s=n.getRangeAt(0).getClientRects();for(let t=0;t<s.length;t++){let i=s[t];if(i.left<=e.clientX&&i.right>=e.clientX&&i.top<=e.clientY&&i.bottom>=e.clientY)return!0}return!1}(t,e)||1!=Wn(e))&&null,!1===this.dragging&&(e.preventDefault(),this.select(e))}move(t){if(0==t.buttons)return this.destroy();!1===this.dragging&&this.select(this.lastEvent=t)}up(t){null==this.dragging&&this.select(this.lastEvent),this.dragging||t.preventDefault(),this.destroy()}destroy(){let t=this.view.contentDOM.ownerDocument;t.removeEventListener(\"mousemove\",this.move),t.removeEventListener(\"mouseup\",this.up),this.view.inputState.mouseSelection=null}select(t){let e=this.style.get(t,this.extend,this.multiple);!this.mustSelect&&e.eq(this.view.state.selection)&&e.main.assoc==this.view.state.selection.main.assoc||this.view.dispatch({selection:e,userEvent:\"select.pointer\",scrollIntoView:!0}),this.mustSelect=!1}update(t){t.docChanged&&this.dragging&&(this.dragging=this.dragging.map(t.changes)),this.style.update(t)&&setTimeout((()=>this.select(this.lastEvent)),20)}}function Cn(t,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let i,n=e.target;n!=t.contentDOM;n=n.parentNode)if(!n||11==n.nodeType||(i=Se.get(n))&&i.ignoreEvent(e))return!1;return!0}const An=Object.create(null),On=Object.create(null),Mn=ze.ie&&ze.ie_version<15||ze.ios&&ze.webkit_version<604;function Dn(t,e){let i,{state:n}=t,s=1,r=n.toText(e),o=r.lines==n.selection.ranges.length;if(null!=Hn&&n.selection.ranges.every((t=>t.empty))&&Hn==r.toString()){let t=-1;i=n.changeByRange((i=>{let l=n.doc.lineAt(i.from);if(l.from==t)return{range:i};t=l.from;let a=n.toText((o?r.line(s++).text:e)+n.lineBreak);return{changes:{from:l.from,insert:a},range:E.cursor(i.from+a.length)}}))}else i=o?n.changeByRange((t=>{let e=r.line(s++);return{changes:{from:t.from,to:t.to,insert:e.text},range:E.cursor(t.from+e.length)}})):n.replaceSelection(r);t.dispatch(i,{userEvent:\"input.paste\",scrollIntoView:!0})}function Tn(t,e,i,n){if(1==n)return E.cursor(e,i);if(2==n)return function(t,e,i=1){let n=t.charCategorizer(e),s=t.doc.lineAt(e),r=e-s.from;if(0==s.length)return E.cursor(e);0==r?i=1:r==s.length&&(i=-1);let o=r,l=r;i<0?o=d(s.text,r,!1):l=d(s.text,r);let a=n(s.text.slice(o,l));for(;o>0;){let t=d(s.text,o,!1);if(n(s.text.slice(t,o))!=a)break;o=t}for(;l<s.length;){let t=d(s.text,l);if(n(s.text.slice(l,t))!=a)break;l=t}return E.range(o+s.from,l+s.from)}(t.state,e,i);{let i=ai.find(t.docView,e),n=t.state.doc.lineAt(i?i.posAtEnd:e),s=i?i.posAtStart:n.from,r=i?i.posAtEnd:n.to;return r<t.state.doc.length&&r==n.to&&r++,E.range(s,r)}}An.keydown=(t,e)=>{t.inputState.setSelectionOrigin(\"select\"),27==e.keyCode?t.inputState.lastEscPress=Date.now():kn.indexOf(e.keyCode)<0&&(t.inputState.lastEscPress=0)},An.touchstart=(t,e)=>{t.inputState.lastTouchTime=Date.now(),t.inputState.setSelectionOrigin(\"select.pointer\")},An.touchmove=t=>{t.inputState.setSelectionOrigin(\"select.pointer\")},On.touchstart=On.touchmove={passive:!0},An.mousedown=(t,e)=>{if(t.observer.flush(),t.inputState.lastTouchTime>Date.now()-2e3)return;let i=null;for(let n of t.state.facet(mi))if(i=n(t,e),i)break;if(i||0!=e.button||(i=function(t,e){let i=Bn(t,e),n=Wn(e),s=t.state.selection,r=i,o=e;return{update(t){t.docChanged&&(i.pos=t.changes.mapPos(i.pos),s=s.map(t.changes),o=null)},get(e,l,a){let h;o&&e.clientX==o.clientX&&e.clientY==o.clientY?h=r:(h=r=Bn(t,e),o=e);let c=Tn(t,h.pos,h.bias,n);if(i.pos!=h.pos&&!l){let e=Tn(t,i.pos,i.bias,n),s=Math.min(e.from,c.from),r=Math.max(e.to,c.to);c=s<c.from?E.range(s,r):E.range(r,s)}return l?s.replaceRange(s.main.extend(c.from,c.to)):a&&s.ranges.length>1&&s.ranges.some((t=>t.eq(c)))?function(t,e){for(let i=0;;i++)if(t.ranges[i].eq(e))return E.create(t.ranges.slice(0,i).concat(t.ranges.slice(i+1)),t.mainIndex==i?0:t.mainIndex-(t.mainIndex>i?1:0))}(s,c):a?s.addRange(c):E.create([c])}}}(t,e)),i){let n=t.root.activeElement!=t.contentDOM;n&&t.observer.ignore((()=>ve(t.contentDOM))),t.inputState.startMouseSelection(new Sn(t,e,i,n))}};let Pn=(t,e)=>t>=e.top&&t<=e.bottom,Rn=(t,e,i)=>Pn(e,i)&&t>=i.left&&t<=i.right;function En(t,e,i,n){let s=ai.find(t.docView,e);if(!s)return 1;let r=e-s.posAtStart;if(0==r)return 1;if(r==s.length)return-1;let o=s.coordsAt(r,-1);if(o&&Rn(i,n,o))return-1;let l=s.coordsAt(r,1);return l&&Rn(i,n,l)?1:o&&Pn(n,o)?-1:1}function Bn(t,e){let i=t.posAtCoords({x:e.clientX,y:e.clientY},!1);return{pos:i,bias:En(t,i,e.clientX,e.clientY)}}const Ln=ze.ie&&ze.ie_version<=11;let Nn=null,In=0,Vn=0;function Wn(t){if(!Ln)return t.detail;let e=Nn,i=Vn;return Nn=t,Vn=Date.now(),In=!e||i>Date.now()-400&&Math.abs(e.clientX-t.clientX)<2&&Math.abs(e.clientY-t.clientY)<2?(In+1)%3:1}function zn(t,e,i,n){if(!i)return;let s=t.posAtCoords({x:e.clientX,y:e.clientY},!1);e.preventDefault();let{mouseSelection:r}=t.inputState,o=n&&r&&r.dragging&&r.dragMove?{from:r.dragging.from,to:r.dragging.to}:null,l={from:s,insert:i},a=t.state.changes(o?[o,l]:l);t.focus(),t.dispatch({changes:a,selection:{anchor:a.mapPos(s,-1),head:a.mapPos(s,1)},userEvent:o?\"move.drop\":\"input.drop\"})}An.dragstart=(t,e)=>{let{selection:{main:i}}=t.state,{mouseSelection:n}=t.inputState;n&&(n.dragging=i),e.dataTransfer&&(e.dataTransfer.setData(\"Text\",t.state.sliceDoc(i.from,i.to)),e.dataTransfer.effectAllowed=\"copyMove\")},An.drop=(t,e)=>{if(!e.dataTransfer)return;if(t.state.readOnly)return e.preventDefault();let i=e.dataTransfer.files;if(i&&i.length){e.preventDefault();let n=Array(i.length),s=0,r=()=>{++s==i.length&&zn(t,e,n.filter((t=>null!=t)).join(t.state.lineBreak),!1)};for(let t=0;t<i.length;t++){let e=new FileReader;e.onerror=r,e.onload=()=>{/[\\x00-\\x08\\x0e-\\x1f]{2}/.test(e.result)||(n[t]=e.result),r()},e.readAsText(i[t])}}else zn(t,e,e.dataTransfer.getData(\"Text\"),!0)},An.paste=(t,e)=>{if(t.state.readOnly)return e.preventDefault();t.observer.flush();let i=Mn?null:e.clipboardData;i?(Dn(t,i.getData(\"text/plain\")),e.preventDefault()):function(t){let e=t.dom.parentNode;if(!e)return;let i=e.appendChild(document.createElement(\"textarea\"));i.style.cssText=\"position: fixed; left: -10000px; top: 10px\",i.focus(),setTimeout((()=>{t.focus(),i.remove(),Dn(t,i.value)}),50)}(t)};let Hn=null;function Fn(t){setTimeout((()=>{t.hasFocus!=t.inputState.notifiedFocused&&t.update([])}),10)}An.copy=An.cut=(t,e)=>{let{text:i,ranges:n,linewise:s}=function(t){let e=[],i=[],n=!1;for(let n of t.selection.ranges)n.empty||(e.push(t.sliceDoc(n.from,n.to)),i.push(n));if(!e.length){let s=-1;for(let{from:n}of t.selection.ranges){let r=t.doc.lineAt(n);r.number>s&&(e.push(r.text),i.push({from:r.from,to:Math.min(t.doc.length,r.to+1)})),s=r.number}n=!0}return{text:e.join(t.lineBreak),ranges:i,linewise:n}}(t.state);if(!i&&!s)return;Hn=s?i:null;let r=Mn?null:e.clipboardData;r?(e.preventDefault(),r.clearData(),r.setData(\"text/plain\",i)):function(t,e){let i=t.dom.parentNode;if(!i)return;let n=i.appendChild(document.createElement(\"textarea\"));n.style.cssText=\"position: fixed; left: -10000px; top: 10px\",n.value=e,n.focus(),n.selectionEnd=e.length,n.selectionStart=0,setTimeout((()=>{n.remove(),t.focus()}),50)}(t,i),\"cut\"!=e.type||t.state.readOnly||t.dispatch({changes:n,scrollIntoView:!0,userEvent:\"delete.cut\"})},An.focus=t=>{t.inputState.lastFocusTime=Date.now(),t.scrollDOM.scrollTop||!t.inputState.lastScrollTop&&!t.inputState.lastScrollLeft||(t.scrollDOM.scrollTop=t.inputState.lastScrollTop,t.scrollDOM.scrollLeft=t.inputState.lastScrollLeft),Fn(t)},An.blur=t=>{t.observer.clearSelectionRange(),Fn(t)},An.compositionstart=An.compositionupdate=t=>{null==t.inputState.compositionFirstChange&&(t.inputState.compositionFirstChange=!0),t.inputState.composing<0&&(t.inputState.composing=0)},An.compositionend=t=>{t.inputState.composing=-1,t.inputState.compositionEndedAt=Date.now(),t.inputState.compositionFirstChange=null,ze.chrome&&ze.android&&t.observer.flushSoon(),setTimeout((()=>{t.inputState.composing<0&&t.docView.compositionDeco.size&&t.update([])}),50)},An.contextmenu=t=>{t.inputState.lastContextMenu=Date.now()},An.beforeinput=(t,e)=>{var i;let n;if(ze.chrome&&ze.android&&(n=bn.find((t=>t.inputType==e.inputType)))&&(t.observer.delayAndroidKey(n.key,n.keyCode),\"Backspace\"==n.key||\"Delete\"==n.key)){let e=(null===(i=window.visualViewport)||void 0===i?void 0:i.height)||0;setTimeout((()=>{var i;((null===(i=window.visualViewport)||void 0===i?void 0:i.height)||0)>e+10&&t.hasFocus&&(t.contentDOM.blur(),t.focus())}),100)}};const qn=[\"pre-wrap\",\"normal\",\"pre-line\",\"break-spaces\"];class _n{constructor(t){this.lineWrapping=t,this.doc=e.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.lineLength=30,this.heightChanged=!1}heightForGap(t,e){let i=this.doc.lineAt(e).number-this.doc.lineAt(t).number+1;return this.lineWrapping&&(i+=Math.ceil((e-t-i*this.lineLength*.5)/this.lineLength)),this.lineHeight*i}heightForLine(t){if(!this.lineWrapping)return this.lineHeight;return(1+Math.max(0,Math.ceil((t-this.lineLength)/(this.lineLength-5))))*this.lineHeight}setDoc(t){return this.doc=t,this}mustRefreshForWrapping(t){return qn.indexOf(t)>-1!=this.lineWrapping}mustRefreshForHeights(t){let e=!1;for(let i=0;i<t.length;i++){let n=t[i];n<0?i++:this.heightSamples[Math.floor(10*n)]||(e=!0,this.heightSamples[Math.floor(10*n)]=!0)}return e}refresh(t,e,i,n,s){let r=qn.indexOf(t)>-1,o=Math.round(e)!=Math.round(this.lineHeight)||this.lineWrapping!=r;if(this.lineWrapping=r,this.lineHeight=e,this.charWidth=i,this.lineLength=n,o){this.heightSamples={};for(let t=0;t<s.length;t++){let e=s[t];e<0?t++:this.heightSamples[Math.floor(10*e)]=!0}}return o}}class jn{constructor(t,e){this.from=t,this.heights=e,this.index=0}get more(){return this.index<this.heights.length}}class Un{constructor(t,e,i,n,s){this.from=t,this.length=e,this.top=i,this.height=n,this.type=s}get to(){return this.from+this.length}get bottom(){return this.top+this.height}join(t){let e=(Array.isArray(this.type)?this.type:[this]).concat(Array.isArray(t.type)?t.type:[t]);return new Un(this.from,this.length+t.length,this.top,this.height+t.height,e)}}var $n=function(t){return t[t.ByPos=0]=\"ByPos\",t[t.ByHeight=1]=\"ByHeight\",t[t.ByPosNoHeight=2]=\"ByPosNoHeight\",t}($n||($n={}));const Qn=.001;class Kn{constructor(t,e,i=2){this.length=t,this.height=e,this.flags=i}get outdated(){return(2&this.flags)>0}set outdated(t){this.flags=(t?2:0)|-3&this.flags}setHeight(t,e){this.height!=e&&(Math.abs(this.height-e)>Qn&&(t.heightChanged=!0),this.height=e)}replace(t,e,i){return Kn.of(i)}decomposeLeft(t,e){e.push(this)}decomposeRight(t,e){e.push(this)}applyChanges(t,e,i,n){let s=this;for(let r=n.length-1;r>=0;r--){let{fromA:o,toA:l,fromB:a,toB:h}=n[r],c=s.lineAt(o,$n.ByPosNoHeight,e,0,0),u=c.to>=l?c:s.lineAt(l,$n.ByPosNoHeight,e,0,0);for(h+=u.to-l,l=u.to;r>0&&c.from<=n[r-1].toA;)o=n[r-1].fromA,a=n[r-1].fromB,r--,o<c.from&&(c=s.lineAt(o,$n.ByPosNoHeight,e,0,0));a+=c.from-o,o=c.from;let f=ts.build(i,t,a,h);s=s.replace(o,l,f)}return s.updateHeight(i,0)}static empty(){return new Jn(0,0)}static of(t){if(1==t.length)return t[0];let e=0,i=t.length,n=0,s=0;for(;;)if(e==i)if(n>2*s){let s=t[e-1];s.break?t.splice(--e,1,s.left,null,s.right):t.splice(--e,1,s.left,s.right),i+=1+s.break,n-=s.size}else{if(!(s>2*n))break;{let e=t[i];e.break?t.splice(i,1,e.left,null,e.right):t.splice(i,1,e.left,e.right),i+=2+e.break,s-=e.size}}else if(n<s){let i=t[e++];i&&(n+=i.size)}else{let e=t[--i];e&&(s+=e.size)}let r=0;return null==t[e-1]?(r=1,e--):null==t[e]&&(r=1,i++),new Zn(Kn.of(t.slice(0,e)),r,Kn.of(t.slice(i)))}}Kn.prototype.size=1;class Gn extends Kn{constructor(t,e,i){super(t,e),this.type=i}blockAt(t,e,i,n){return new Un(n,this.length,i,this.height,this.type)}lineAt(t,e,i,n,s){return this.blockAt(0,i,n,s)}forEachLine(t,e,i,n,s,r){t<=s+this.length&&e>=s&&r(this.blockAt(0,i,n,s))}updateHeight(t,e=0,i=!1,n){return n&&n.from<=e&&n.more&&this.setHeight(t,n.heights[n.index++]),this.outdated=!1,this}toString(){return`block(${this.length})`}}class Jn extends Gn{constructor(t,e){super(t,e,ei.Text),this.collapsed=0,this.widgetHeight=0}replace(t,e,i){let n=i[0];return 1==i.length&&(n instanceof Jn||n instanceof Xn&&4&n.flags)&&Math.abs(this.length-n.length)<10?(n instanceof Xn?n=new Jn(n.length,this.height):n.height=this.height,this.outdated||(n.outdated=!1),n):Kn.of(i)}updateHeight(t,e=0,i=!1,n){return n&&n.from<=e&&n.more?this.setHeight(t,n.heights[n.index++]):(i||this.outdated)&&this.setHeight(t,Math.max(this.widgetHeight,t.heightForLine(this.length-this.collapsed))),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:\"\"}${this.widgetHeight?\":\"+this.widgetHeight:\"\"})`}}class Xn extends Kn{constructor(t){super(t,0)}lines(t,e){let i=t.lineAt(e).number,n=t.lineAt(e+this.length).number;return{firstLine:i,lastLine:n,lineHeight:this.height/(n-i+1)}}blockAt(t,e,i,n){let{firstLine:s,lastLine:r,lineHeight:o}=this.lines(e,n),l=Math.max(0,Math.min(r-s,Math.floor((t-i)/o))),{from:a,length:h}=e.line(s+l);return new Un(a,h,i+o*l,o,ei.Text)}lineAt(t,e,i,n,s){if(e==$n.ByHeight)return this.blockAt(t,i,n,s);if(e==$n.ByPosNoHeight){let{from:e,to:n}=i.lineAt(t);return new Un(e,n-e,0,0,ei.Text)}let{firstLine:r,lineHeight:o}=this.lines(i,s),{from:l,length:a,number:h}=i.lineAt(t);return new Un(l,a,n+o*(h-r),o,ei.Text)}forEachLine(t,e,i,n,s,r){let{firstLine:o,lineHeight:l}=this.lines(i,s);for(let a=Math.max(t,s),h=Math.min(s+this.length,e);a<=h;){let e=i.lineAt(a);a==t&&(n+=l*(e.number-o)),r(new Un(e.from,e.length,n,l,ei.Text)),n+=l,a=e.to+1}}replace(t,e,i){let n=this.length-e;if(n>0){let t=i[i.length-1];t instanceof Xn?i[i.length-1]=new Xn(t.length+n):i.push(null,new Xn(n-1))}if(t>0){let e=i[0];e instanceof Xn?i[0]=new Xn(t+e.length):i.unshift(new Xn(t-1),null)}return Kn.of(i)}decomposeLeft(t,e){e.push(new Xn(t-1),null)}decomposeRight(t,e){e.push(null,new Xn(this.length-t-1))}updateHeight(t,e=0,i=!1,n){let s=e+this.length;if(n&&n.from<=e+this.length&&n.more){let i=[],r=Math.max(e,n.from),o=-1,l=t.heightChanged;for(n.from>e&&i.push(new Xn(n.from-e-1).updateHeight(t,e));r<=s&&n.more;){let e=t.doc.lineAt(r).length;i.length&&i.push(null);let s=n.heights[n.index++];-1==o?o=s:Math.abs(s-o)>=Qn&&(o=-2);let l=new Jn(e,s);l.outdated=!1,i.push(l),r+=e+1}r<=s&&i.push(null,new Xn(s-r).updateHeight(t,r));let a=Kn.of(i);return t.heightChanged=l||o<0||Math.abs(a.height-this.height)>=Qn||Math.abs(o-this.lines(t.doc,e).lineHeight)>=Qn,a}return(i||this.outdated)&&(this.setHeight(t,t.heightForGap(e,e+this.length)),this.outdated=!1),this}toString(){return`gap(${this.length})`}}class Zn extends Kn{constructor(t,e,i){super(t.length+e+i.length,t.height+i.height,e|(t.outdated||i.outdated?2:0)),this.left=t,this.right=i,this.size=t.size+i.size}get break(){return 1&this.flags}blockAt(t,e,i,n){let s=i+this.left.height;return t<s?this.left.blockAt(t,e,i,n):this.right.blockAt(t,e,s,n+this.left.length+this.break)}lineAt(t,e,i,n,s){let r=n+this.left.height,o=s+this.left.length+this.break,l=e==$n.ByHeight?t<r:t<o,a=l?this.left.lineAt(t,e,i,n,s):this.right.lineAt(t,e,i,r,o);if(this.break||(l?a.to<o:a.from>o))return a;let h=e==$n.ByPosNoHeight?$n.ByPosNoHeight:$n.ByPos;return l?a.join(this.right.lineAt(o,h,i,r,o)):this.left.lineAt(o,h,i,n,s).join(a)}forEachLine(t,e,i,n,s,r){let o=n+this.left.height,l=s+this.left.length+this.break;if(this.break)t<l&&this.left.forEachLine(t,e,i,n,s,r),e>=l&&this.right.forEachLine(t,e,i,o,l,r);else{let a=this.lineAt(l,$n.ByPos,i,n,s);t<a.from&&this.left.forEachLine(t,a.from-1,i,n,s,r),a.to>=t&&a.from<=e&&r(a),e>a.to&&this.right.forEachLine(a.to+1,e,i,o,l,r)}}replace(t,e,i){let n=this.left.length+this.break;if(e<n)return this.balanced(this.left.replace(t,e,i),this.right);if(t>this.left.length)return this.balanced(this.left,this.right.replace(t-n,e-n,i));let s=[];t>0&&this.decomposeLeft(t,s);let r=s.length;for(let t of i)s.push(t);if(t>0&&Yn(s,r-1),e<this.length){let t=s.length;this.decomposeRight(e,s),Yn(s,t)}return Kn.of(s)}decomposeLeft(t,e){let i=this.left.length;if(t<=i)return this.left.decomposeLeft(t,e);e.push(this.left),this.break&&(i++,t>=i&&e.push(null)),t>i&&this.right.decomposeLeft(t-i,e)}decomposeRight(t,e){let i=this.left.length,n=i+this.break;if(t>=n)return this.right.decomposeRight(t-n,e);t<i&&this.left.decomposeRight(t,e),this.break&&t<n&&e.push(null),e.push(this.right)}balanced(t,e){return t.size>2*e.size||e.size>2*t.size?Kn.of(this.break?[t,null,e]:[t,e]):(this.left=t,this.right=e,this.height=t.height+e.height,this.outdated=t.outdated||e.outdated,this.size=t.size+e.size,this.length=t.length+this.break+e.length,this)}updateHeight(t,e=0,i=!1,n){let{left:s,right:r}=this,o=e+s.length+this.break,l=null;return n&&n.from<=e+s.length&&n.more?l=s=s.updateHeight(t,e,i,n):s.updateHeight(t,e,i),n&&n.from<=o+r.length&&n.more?l=r=r.updateHeight(t,o,i,n):r.updateHeight(t,o,i),l?this.balanced(s,r):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?\" \":\"-\")+this.right}}function Yn(t,e){let i,n;null==t[e]&&(i=t[e-1])instanceof Xn&&(n=t[e+1])instanceof Xn&&t.splice(e-1,3,new Xn(i.length+1+n.length))}class ts{constructor(t,e){this.pos=t,this.oracle=e,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=t}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(t,e){if(this.lineStart>-1){let t=Math.min(e,this.lineEnd),i=this.nodes[this.nodes.length-1];i instanceof Jn?i.length+=t-this.pos:(t>this.pos||!this.isCovered)&&this.nodes.push(new Jn(t-this.pos,-1)),this.writtenTo=t,e>t&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=e}point(t,e,i){if(t<e||i.heightRelevant){let n=i.widget?i.widget.estimatedHeight:0;n<0&&(n=this.oracle.lineHeight);let s=e-t;i.block?this.addBlock(new Gn(s,n,i.type)):(s||n>=5)&&this.addLineDeco(n,s)}else e>t&&this.span(t,e);this.lineEnd>-1&&this.lineEnd<this.pos&&(this.lineEnd=this.oracle.doc.lineAt(this.pos).to)}enterLine(){if(this.lineStart>-1)return;let{from:t,to:e}=this.oracle.doc.lineAt(this.pos);this.lineStart=t,this.lineEnd=e,this.writtenTo<t&&((this.writtenTo<t-1||null==this.nodes[this.nodes.length-1])&&this.nodes.push(this.blankContent(this.writtenTo,t-1)),this.nodes.push(null)),this.pos>t&&this.nodes.push(new Jn(this.pos-t,-1)),this.writtenTo=this.pos}blankContent(t,e){let i=new Xn(e-t);return this.oracle.doc.lineAt(t).to==e&&(i.flags|=4),i}ensureLine(){this.enterLine();let t=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(t instanceof Jn)return t;let e=new Jn(0,-1);return this.nodes.push(e),e}addBlock(t){this.enterLine(),t.type!=ei.WidgetAfter||this.isCovered||this.ensureLine(),this.nodes.push(t),this.writtenTo=this.pos=this.pos+t.length,t.type!=ei.WidgetBefore&&(this.covering=t)}addLineDeco(t,e){let i=this.ensureLine();i.length+=e,i.collapsed+=e,i.widgetHeight=Math.max(i.widgetHeight,t),this.writtenTo=this.pos=this.pos+e}finish(t){let e=0==this.nodes.length?null:this.nodes[this.nodes.length-1];!(this.lineStart>-1)||e instanceof Jn||this.isCovered?(this.writtenTo<this.pos||null==e)&&this.nodes.push(this.blankContent(this.writtenTo,this.pos)):this.nodes.push(new Jn(0,-1));let i=t;for(let t of this.nodes)t instanceof Jn&&t.updateHeight(this.oracle,i),i+=t?t.length:1;return this.nodes}static build(t,e,i,n){let s=new ts(i,t);return Tt.spans(e,i,n,s,0),s.finish(i)}}class es{constructor(){this.changes=[]}compareRange(){}comparePoint(t,e,i,n){(t<e||i&&i.heightRelevant||n&&n.heightRelevant)&&li(t,e,this.changes,5)}}function is(t,e){let i=t.getBoundingClientRect(),n=t.ownerDocument,s=n.defaultView||window,r=Math.max(0,i.left),o=Math.min(s.innerWidth,i.right),l=Math.max(0,i.top),a=Math.min(s.innerHeight,i.bottom);for(let e=t.parentNode;e&&e!=n.body;)if(1==e.nodeType){let i=e,n=window.getComputedStyle(i);if((i.scrollHeight>i.clientHeight||i.scrollWidth>i.clientWidth)&&\"visible\"!=n.overflow){let n=i.getBoundingClientRect();r=Math.max(r,n.left),o=Math.min(o,n.right),l=Math.max(l,n.top),a=e==t.parentNode?n.bottom:Math.min(a,n.bottom)}e=\"absolute\"==n.position||\"fixed\"==n.position?i.offsetParent:i.parentNode}else{if(11!=e.nodeType)break;e=e.host}return{left:r-i.left,right:Math.max(r,o)-i.left,top:l-(i.top+e),bottom:Math.max(l,a)-(i.top+e)}}function ns(t,e){let i=t.getBoundingClientRect();return{left:0,right:i.right-i.left,top:e,bottom:i.bottom-(i.top+e)}}class ss{constructor(t,e,i){this.from=t,this.to=e,this.size=i}static same(t,e){if(t.length!=e.length)return!1;for(let i=0;i<t.length;i++){let n=t[i],s=e[i];if(n.from!=s.from||n.to!=s.to||n.size!=s.size)return!1}return!0}draw(t){return ii.replace({widget:new rs(this.size,t)}).range(this.from,this.to)}}class rs extends ti{constructor(t,e){super(),this.size=t,this.vertical=e}eq(t){return t.size==this.size&&t.vertical==this.vertical}toDOM(){let t=document.createElement(\"div\");return this.vertical?t.style.height=this.size+\"px\":(t.style.width=this.size+\"px\",t.style.height=\"2px\",t.style.display=\"inline-block\"),t}get estimatedHeight(){return this.vertical?this.size:-1}}class os{constructor(t){this.state=t,this.pixelViewport={left:0,right:window.innerWidth,top:0,bottom:0},this.inView=!0,this.paddingTop=0,this.paddingBottom=0,this.contentDOMWidth=0,this.contentDOMHeight=0,this.editorHeight=0,this.editorWidth=0,this.scaler=us,this.scrollTarget=null,this.printing=!1,this.mustMeasureContent=!0,this.defaultTextDirection=Vi.LTR,this.visibleRanges=[],this.mustEnforceCursorAssoc=!1;let i=t.facet(Pi).some((t=>\"function\"!=typeof t&&\"cm-lineWrapping\"==t.class));this.heightOracle=new _n(i),this.stateDeco=t.facet(Ri).filter((t=>\"function\"!=typeof t)),this.heightMap=Kn.empty().applyChanges(this.stateDeco,e.empty,this.heightOracle.setDoc(t.doc),[new Ni(0,0,0,t.doc.length)]),this.viewport=this.getViewport(0,null),this.updateViewportLines(),this.updateForViewport(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=ii.set(this.lineGaps.map((t=>t.draw(!1)))),this.computeVisibleRanges()}updateForViewport(){let t=[this.viewport],{main:e}=this.state.selection;for(let i=0;i<=1;i++){let n=i?e.head:e.anchor;if(!t.some((({from:t,to:e})=>n>=t&&n<=e))){let{from:e,to:i}=this.lineBlockAt(n);t.push(new ls(e,i))}}this.viewports=t.sort(((t,e)=>t.from-e.from)),this.scaler=this.heightMap.height<=7e6?us:new fs(this.heightOracle.doc,this.heightMap,this.viewports)}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.state.doc,0,0,(t=>{this.viewportLines.push(1==this.scaler.scale?t:ds(t,this.scaler))}))}update(t,e=null){this.state=t.state;let i=this.stateDeco;this.stateDeco=this.state.facet(Ri).filter((t=>\"function\"!=typeof t));let n=t.changedRanges,s=Ni.extendWithRanges(n,function(t,e,i){let n=new es;return Tt.compare(t,e,i,n,0),n.changes}(i,this.stateDeco,t?t.changes:C.empty(this.state.doc.length))),r=this.heightMap.height;this.heightMap=this.heightMap.applyChanges(this.stateDeco,t.startState.doc,this.heightOracle.setDoc(this.state.doc),s),this.heightMap.height!=r&&(t.flags|=2);let o=s.length?this.mapViewport(this.viewport,t.changes):this.viewport;(e&&(e.range.head<o.from||e.range.head>o.to)||!this.viewportIsAppropriate(o))&&(o=this.getViewport(0,e));let l=!t.changes.empty||2&t.flags||o.from!=this.viewport.from||o.to!=this.viewport.to;this.viewport=o,this.updateForViewport(),l&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,t.changes))),t.flags|=this.computeVisibleRanges(),e&&(this.scrollTarget=e),!this.mustEnforceCursorAssoc&&t.selectionSet&&t.view.lineWrapping&&t.state.selection.main.empty&&t.state.selection.main.assoc&&!t.state.facet(bi)&&(this.mustEnforceCursorAssoc=!0)}measure(t){let i=t.contentDOM,n=window.getComputedStyle(i),s=this.heightOracle,r=n.whiteSpace;this.defaultTextDirection=\"rtl\"==n.direction?Vi.RTL:Vi.LTR;let o=this.heightOracle.mustRefreshForWrapping(r),l=o||this.mustMeasureContent||this.contentDOMHeight!=i.clientHeight;this.contentDOMHeight=i.clientHeight,this.mustMeasureContent=!1;let a=0,h=0,c=parseInt(n.paddingTop)||0,u=parseInt(n.paddingBottom)||0;this.paddingTop==c&&this.paddingBottom==u||(this.paddingTop=c,this.paddingBottom=u,a|=10),this.editorWidth!=t.scrollDOM.clientWidth&&(s.lineWrapping&&(l=!0),this.editorWidth=t.scrollDOM.clientWidth,a|=8);let f=(this.printing?ns:is)(i,this.paddingTop),d=f.top-this.pixelViewport.top,p=f.bottom-this.pixelViewport.bottom;this.pixelViewport=f;let m=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(m!=this.inView&&(this.inView=m,m&&(l=!0)),!this.inView&&!this.scrollTarget)return 0;let g=i.clientWidth;if(this.contentDOMWidth==g&&this.editorHeight==t.scrollDOM.clientHeight||(this.contentDOMWidth=g,this.editorHeight=t.scrollDOM.clientHeight,a|=8),l){let i=t.docView.measureVisibleLineHeights(this.viewport);if(s.mustRefreshForHeights(i)&&(o=!0),o||s.lineWrapping&&Math.abs(g-this.contentDOMWidth)>s.charWidth){let{lineHeight:e,charWidth:n}=t.docView.measureTextSize();o=e>0&&s.refresh(r,e,n,g/n,i),o&&(t.docView.minWidth=0,a|=8)}d>0&&p>0?h=Math.max(d,p):d<0&&p<0&&(h=Math.min(d,p)),s.heightChanged=!1;for(let n of this.viewports){let r=n.from==this.viewport.from?i:t.docView.measureVisibleLineHeights(n);this.heightMap=(o?Kn.empty().applyChanges(this.stateDeco,e.empty,this.heightOracle,[new Ni(0,0,0,t.state.doc.length)]):this.heightMap).updateHeight(s,0,o,new jn(n.from,r))}s.heightChanged&&(a|=2)}let v=!this.viewportIsAppropriate(this.viewport,h)||this.scrollTarget&&(this.scrollTarget.range.head<this.viewport.from||this.scrollTarget.range.head>this.viewport.to);return v&&(this.viewport=this.getViewport(h,this.scrollTarget)),this.updateForViewport(),(2&a||v)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,t)),a|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,t.docView.enforceCursorAssoc()),a}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(t,e){let i=.5-Math.max(-.5,Math.min(.5,t/1e3/2)),n=this.heightMap,s=this.state.doc,{visibleTop:r,visibleBottom:o}=this,l=new ls(n.lineAt(r-1e3*i,$n.ByHeight,s,0,0).from,n.lineAt(o+1e3*(1-i),$n.ByHeight,s,0,0).to);if(e){let{head:t}=e.range;if(t<l.from||t>l.to){let i,r=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),o=n.lineAt(t,$n.ByPos,s,0,0);i=\"center\"==e.y?(o.top+o.bottom)/2-r/2:\"start\"==e.y||\"nearest\"==e.y&&t<l.from?o.top:o.bottom-r,l=new ls(n.lineAt(i-500,$n.ByHeight,s,0,0).from,n.lineAt(i+r+500,$n.ByHeight,s,0,0).to)}}return l}mapViewport(t,e){let i=e.mapPos(t.from,-1),n=e.mapPos(t.to,1);return new ls(this.heightMap.lineAt(i,$n.ByPos,this.state.doc,0,0).from,this.heightMap.lineAt(n,$n.ByPos,this.state.doc,0,0).to)}viewportIsAppropriate({from:t,to:e},i=0){if(!this.inView)return!0;let{top:n}=this.heightMap.lineAt(t,$n.ByPos,this.state.doc,0,0),{bottom:s}=this.heightMap.lineAt(e,$n.ByPos,this.state.doc,0,0),{visibleTop:r,visibleBottom:o}=this;return(0==t||n<=r-Math.max(10,Math.min(-i,250)))&&(e==this.state.doc.length||s>=o+Math.max(10,Math.min(i,250)))&&n>r-2e3&&s<o+2e3}mapLineGaps(t,e){if(!t.length||e.empty)return t;let i=[];for(let n of t)e.touchesRange(n.from,n.to)||i.push(new ss(e.mapPos(n.from),e.mapPos(n.to),n.size));return i}ensureLineGaps(t,e){let i=this.heightOracle.lineWrapping,n=i?1e4:2e3,s=n>>1,r=n<<1;if(this.defaultTextDirection!=Vi.LTR&&!i)return[];let o=[],l=(n,r,a,h)=>{if(r-n<s)return;let c=this.state.selection.main,u=[c.from];c.empty||u.push(c.to);for(let t of u)if(t>n&&t<r)return l(n,t-10,a,h),void l(t+10,r,a,h);let f=function(t,e){for(let i of t)if(e(i))return i;return}(t,(t=>t.from>=a.from&&t.to<=a.to&&Math.abs(t.from-n)<s&&Math.abs(t.to-r)<s&&!u.some((e=>t.from<e&&t.to>e))));if(!f){if(r<a.to&&e&&i&&e.visibleRanges.some((t=>t.from<=r&&t.to>=r))){let t=e.moveToLineBoundary(E.cursor(r),!1,!0).head;t>n&&(r=t)}f=new ss(n,r,this.gapSize(a,n,r,h))}o.push(f)};for(let t of this.viewportLines){if(t.length<r)continue;let e=as(t.from,t.to,this.stateDeco);if(e.total<r)continue;let s,o,a=this.scrollTarget?this.scrollTarget.range.head:null;if(i){let i,r,l=n/this.heightOracle.lineLength*this.heightOracle.lineHeight;if(null!=a){let n=cs(e,a),s=((this.visibleBottom-this.visibleTop)/2+l)/t.height;i=n-s,r=n+s}else i=(this.visibleTop-t.top-l)/t.height,r=(this.visibleBottom-t.top+l)/t.height;s=hs(e,i),o=hs(e,r)}else{let t,i,r=e.total*this.heightOracle.charWidth,l=n*this.heightOracle.charWidth;if(null!=a){let n=cs(e,a),s=((this.pixelViewport.right-this.pixelViewport.left)/2+l)/r;t=n-s,i=n+s}else t=(this.pixelViewport.left-l)/r,i=(this.pixelViewport.right+l)/r;s=hs(e,t),o=hs(e,i)}s>t.from&&l(t.from,s,t,e),o<t.to&&l(o,t.to,t,e)}return o}gapSize(t,e,i,n){let s=cs(n,i)-cs(n,e);return this.heightOracle.lineWrapping?t.height*s:n.total*this.heightOracle.charWidth*s}updateLineGaps(t){ss.same(t,this.lineGaps)||(this.lineGaps=t,this.lineGapDeco=ii.set(t.map((t=>t.draw(this.heightOracle.lineWrapping)))))}computeVisibleRanges(){let t=this.stateDeco;this.lineGaps.length&&(t=t.concat(this.lineGapDeco));let e=[];Tt.spans(t,this.viewport.from,this.viewport.to,{span(t,i){e.push({from:t,to:i})},point(){}},20);let i=e.length!=this.visibleRanges.length||this.visibleRanges.some(((t,i)=>t.from!=e[i].from||t.to!=e[i].to));return this.visibleRanges=e,i?4:0}lineBlockAt(t){return t>=this.viewport.from&&t<=this.viewport.to&&this.viewportLines.find((e=>e.from<=t&&e.to>=t))||ds(this.heightMap.lineAt(t,$n.ByPos,this.state.doc,0,0),this.scaler)}lineBlockAtHeight(t){return ds(this.heightMap.lineAt(this.scaler.fromDOM(t),$n.ByHeight,this.state.doc,0,0),this.scaler)}elementAtHeight(t){return ds(this.heightMap.blockAt(this.scaler.fromDOM(t),this.state.doc,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}}class ls{constructor(t,e){this.from=t,this.to=e}}function as(t,e,i){let n=[],s=t,r=0;return Tt.spans(i,t,e,{span(){},point(t,e){t>s&&(n.push({from:s,to:t}),r+=t-s),s=e}},20),s<e&&(n.push({from:s,to:e}),r+=e-s),{total:r,ranges:n}}function hs({total:t,ranges:e},i){if(i<=0)return e[0].from;if(i>=1)return e[e.length-1].to;let n=Math.floor(t*i);for(let t=0;;t++){let{from:i,to:s}=e[t],r=s-i;if(n<=r)return i+n;n-=r}}function cs(t,e){let i=0;for(let{from:n,to:s}of t.ranges){if(e<=s){i+=e-n;break}i+=s-n}return i/t.total}const us={toDOM:t=>t,fromDOM:t=>t,scale:1};class fs{constructor(t,e,i){let n=0,s=0,r=0;this.viewports=i.map((({from:i,to:s})=>{let r=e.lineAt(i,$n.ByPos,t,0,0).top,o=e.lineAt(s,$n.ByPos,t,0,0).bottom;return n+=o-r,{from:i,to:s,top:r,bottom:o,domTop:0,domBottom:0}})),this.scale=(7e6-n)/(e.height-n);for(let t of this.viewports)t.domTop=r+(t.top-s)*this.scale,r=t.domBottom=t.domTop+(t.bottom-t.top),s=t.bottom}toDOM(t){for(let e=0,i=0,n=0;;e++){let s=e<this.viewports.length?this.viewports[e]:null;if(!s||t<s.top)return n+(t-i)*this.scale;if(t<=s.bottom)return s.domTop+(t-s.top);i=s.bottom,n=s.domBottom}}fromDOM(t){for(let e=0,i=0,n=0;;e++){let s=e<this.viewports.length?this.viewports[e]:null;if(!s||t<s.domTop)return i+(t-n)/this.scale;if(t<=s.domBottom)return s.top+(t-s.domTop);i=s.bottom,n=s.domBottom}}}function ds(t,e){if(1==e.scale)return t;let i=e.toDOM(t.top),n=e.toDOM(t.bottom);return new Un(t.from,t.length,i,n-i,Array.isArray(t.type)?t.type.map((t=>ds(t,e))):t.type)}const ps=N.define({combine:t=>t.join(\" \")}),ms=N.define({combine:t=>t.indexOf(!0)>-1}),gs=$t.newName(),vs=$t.newName(),ws=$t.newName(),ys={\"&light\":\".\"+vs,\"&dark\":\".\"+ws};function bs(t,e,i){return new $t(e,{finish:e=>/&/.test(e)?e.replace(/&\\w*/,(e=>{if(\"&\"==e)return t;if(!i||!i[e])throw new RangeError(`Unsupported selector: ${e}`);return i[e]})):t+\" \"+e})}const xs=bs(\".\"+gs,{\"&.cm-editor\":{position:\"relative !important\",boxSizing:\"border-box\",\"&.cm-focused\":{outline:\"1px dotted #212121\"},display:\"flex !important\",flexDirection:\"column\"},\".cm-scroller\":{display:\"flex !important\",alignItems:\"flex-start !important\",fontFamily:\"monospace\",lineHeight:1.4,height:\"100%\",overflowX:\"auto\",position:\"relative\",zIndex:0},\".cm-content\":{margin:0,flexGrow:2,flexShrink:0,minHeight:\"100%\",display:\"block\",whiteSpace:\"pre\",wordWrap:\"normal\",boxSizing:\"border-box\",padding:\"4px 0\",outline:\"none\",\"&[contenteditable=true]\":{WebkitUserModify:\"read-write-plaintext-only\"}},\".cm-lineWrapping\":{whiteSpace_fallback:\"pre-wrap\",whiteSpace:\"break-spaces\",wordBreak:\"break-word\",overflowWrap:\"anywhere\",flexShrink:1},\"&light .cm-content\":{caretColor:\"black\"},\"&dark .cm-content\":{caretColor:\"white\"},\".cm-line\":{display:\"block\",padding:\"0 2px 0 4px\"},\".cm-selectionLayer\":{zIndex:-1,contain:\"size style\"},\".cm-selectionBackground\":{position:\"absolute\"},\"&light .cm-selectionBackground\":{background:\"#d9d9d9\"},\"&dark .cm-selectionBackground\":{background:\"#222\"},\"&light.cm-focused .cm-selectionBackground\":{background:\"#d7d4f0\"},\"&dark.cm-focused .cm-selectionBackground\":{background:\"#233\"},\".cm-cursorLayer\":{zIndex:100,contain:\"size style\",pointerEvents:\"none\"},\"&.cm-focused .cm-cursorLayer\":{animation:\"steps(1) cm-blink 1.2s infinite\"},\"@keyframes cm-blink\":{\"0%\":{},\"50%\":{opacity:0},\"100%\":{}},\"@keyframes cm-blink2\":{\"0%\":{},\"50%\":{opacity:0},\"100%\":{}},\".cm-cursor, .cm-dropCursor\":{position:\"absolute\",borderLeft:\"1.2px solid black\",marginLeft:\"-0.6px\",pointerEvents:\"none\"},\".cm-cursor\":{display:\"none\"},\"&dark .cm-cursor\":{borderLeftColor:\"#444\"},\"&.cm-focused .cm-cursor\":{display:\"block\"},\"&light .cm-activeLine\":{backgroundColor:\"#cceeff44\"},\"&dark .cm-activeLine\":{backgroundColor:\"#99eeff33\"},\"&light .cm-specialChar\":{color:\"red\"},\"&dark .cm-specialChar\":{color:\"#f78\"},\".cm-gutters\":{flexShrink:0,display:\"flex\",height:\"100%\",boxSizing:\"border-box\",left:0,zIndex:200},\"&light .cm-gutters\":{backgroundColor:\"#f5f5f5\",color:\"#6c6c6c\",borderRight:\"1px solid #ddd\"},\"&dark .cm-gutters\":{backgroundColor:\"#333338\",color:\"#ccc\"},\".cm-gutter\":{display:\"flex !important\",flexDirection:\"column\",flexShrink:0,boxSizing:\"border-box\",minHeight:\"100%\",overflow:\"hidden\"},\".cm-gutterElement\":{boxSizing:\"border-box\"},\".cm-lineNumbers .cm-gutterElement\":{padding:\"0 3px 0 5px\",minWidth:\"20px\",textAlign:\"right\",whiteSpace:\"nowrap\"},\"&light .cm-activeLineGutter\":{backgroundColor:\"#e2f2ff\"},\"&dark .cm-activeLineGutter\":{backgroundColor:\"#222227\"},\".cm-panels\":{boxSizing:\"border-box\",position:\"sticky\",left:0,right:0},\"&light .cm-panels\":{backgroundColor:\"#f5f5f5\",color:\"black\"},\"&light .cm-panels-top\":{borderBottom:\"1px solid #ddd\"},\"&light .cm-panels-bottom\":{borderTop:\"1px solid #ddd\"},\"&dark .cm-panels\":{backgroundColor:\"#333338\",color:\"white\"},\".cm-tab\":{display:\"inline-block\",overflow:\"hidden\",verticalAlign:\"bottom\"},\".cm-widgetBuffer\":{verticalAlign:\"text-top\",height:\"1em\",width:0,display:\"inline\"},\".cm-placeholder\":{color:\"#888\",display:\"inline-block\",verticalAlign:\"top\"},\".cm-button\":{verticalAlign:\"middle\",color:\"inherit\",fontSize:\"70%\",padding:\".2em 1em\",borderRadius:\"1px\"},\"&light .cm-button\":{backgroundImage:\"linear-gradient(#eff1f5, #d9d9df)\",border:\"1px solid #888\",\"&:active\":{backgroundImage:\"linear-gradient(#b4b4b4, #d0d3d6)\"}},\"&dark .cm-button\":{backgroundImage:\"linear-gradient(#393939, #111)\",border:\"1px solid #888\",\"&:active\":{backgroundImage:\"linear-gradient(#111, #333)\"}},\".cm-textfield\":{verticalAlign:\"middle\",color:\"inherit\",fontSize:\"70%\",border:\"1px solid silver\",padding:\".2em .5em\"},\"&light .cm-textfield\":{backgroundColor:\"white\"},\"&dark .cm-textfield\":{border:\"1px solid #555\",backgroundColor:\"inherit\"}},ys);class ks{constructor(t,e,i,n){this.typeOver=n,this.bounds=null,this.text=\"\";let{impreciseHead:s,impreciseAnchor:r}=t.docView;if(t.state.readOnly&&e>-1)this.newSel=null;else if(e>-1&&(this.bounds=t.docView.domBoundsAround(e,i,0))){let e=s||r?[]:function(t){let e=[];if(t.root.activeElement!=t.contentDOM)return e;let{anchorNode:i,anchorOffset:n,focusNode:s,focusOffset:r}=t.observer.selectionRange;i&&(e.push(new tn(i,n)),s==i&&r==n||e.push(new tn(s,r)));return e}(t),i=new Zi(e,t.state);i.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=i.text,this.newSel=function(t,e){if(0==t.length)return null;let i=t[0].pos,n=2==t.length?t[1].pos:i;return i>-1&&n>-1?E.single(i+e,n+e):null}(e,this.bounds.from)}else{let e=t.observer.selectionRange,i=s&&s.node==e.focusNode&&s.offset==e.focusOffset||!se(t.contentDOM,e.focusNode)?t.state.selection.main.head:t.docView.posFromDOM(e.focusNode,e.focusOffset),n=r&&r.node==e.anchorNode&&r.offset==e.anchorOffset||!se(t.contentDOM,e.anchorNode)?t.state.selection.main.anchor:t.docView.posFromDOM(e.anchorNode,e.anchorOffset);this.newSel=E.single(n,i)}}}function Ss(t,i){let n,{newSel:s}=i,r=t.state.selection.main;if(i.bounds){let{from:s,to:o}=i.bounds,l=r.from,a=null;(8===t.inputState.lastKeyCode&&t.inputState.lastKeyTime>Date.now()-100||ze.android&&i.text.length<o-s)&&(l=r.to,a=\"end\");let h=function(t,e,i,n){let s=Math.min(t.length,e.length),r=0;for(;r<s&&t.charCodeAt(r)==e.charCodeAt(r);)r++;if(r==s&&t.length==e.length)return null;let o=t.length,l=e.length;for(;o>0&&l>0&&t.charCodeAt(o-1)==e.charCodeAt(l-1);)o--,l--;if(\"end\"==n){i-=o+Math.max(0,r-Math.min(o,l))-r}if(o<r&&t.length<e.length){r-=i<=r&&i>=o?r-i:0,l=r+(l-o),o=r}else if(l<r){r-=i<=r&&i>=l?r-i:0,o=r+(o-l),l=r}return{from:r,toA:o,toB:l}}(t.state.doc.sliceString(s,o,Xi),i.text,l-s,a);h&&(ze.chrome&&13==t.inputState.lastKeyCode&&h.toB==h.from+2&&\"￿￿\"==i.text.slice(h.from,h.toB)&&h.toB--,n={from:s+h.from,to:s+h.toA,insert:e.of(i.text.slice(h.from,h.toB).split(Xi))})}else!s||t.hasFocus&&t.state.facet(Ci)&&!s.main.eq(r)||(s=null);if(!n&&!s)return!1;if(!n&&i.typeOver&&!r.empty&&s&&s.main.empty?n={from:r.from,to:r.to,insert:t.state.doc.slice(r.from,r.to)}:n&&n.from>=r.from&&n.to<=r.to&&(n.from!=r.from||n.to!=r.to)&&r.to-r.from-(n.to-n.from)<=4?n={from:r.from,to:r.to,insert:t.state.doc.slice(r.from,n.from).append(n.insert).append(t.state.doc.slice(n.to,r.to))}:(ze.mac||ze.android)&&n&&n.from==n.to&&n.from==r.head-1&&/^\\. ?$/.test(n.insert.toString())?(s&&2==n.insert.length&&(s=E.single(s.main.anchor-1,s.main.head-1)),n={from:r.from,to:r.to,insert:e.of([\" \"])}):ze.chrome&&n&&n.from==n.to&&n.from==r.head&&\"\\n \"==n.insert.toString()&&t.lineWrapping&&(s&&(s=E.single(s.main.anchor-1,s.main.head-1)),n={from:r.from,to:r.to,insert:e.of([\" \"])}),n){let e=t.state;if(ze.ios&&t.inputState.flushIOSKey(t))return!0;if(ze.android&&(n.from==r.from&&n.to==r.to&&1==n.insert.length&&2==n.insert.lines&&ye(t.contentDOM,\"Enter\",13)||n.from==r.from-1&&n.to==r.to&&0==n.insert.length&&ye(t.contentDOM,\"Backspace\",8)||n.from==r.from&&n.to==r.to+1&&0==n.insert.length&&ye(t.contentDOM,\"Delete\",46)))return!0;let i,o=n.insert.toString();if(t.state.facet(wi).some((e=>e(t,n.from,n.to,o))))return!0;if(t.inputState.composing>=0&&t.inputState.composing++,n.from>=r.from&&n.to<=r.to&&n.to-n.from>=(r.to-r.from)/3&&(!s||s.main.empty&&s.main.from==n.from+n.insert.length)&&t.inputState.composing<0){let s=r.from<n.from?e.sliceDoc(r.from,n.from):\"\",o=r.to>n.to?e.sliceDoc(n.to,r.to):\"\";i=e.replaceSelection(t.state.toText(s+n.insert.sliceString(0,void 0,t.state.lineBreak)+o))}else{let o=e.changes(n),l=s&&!e.selection.main.eq(s.main)&&s.main.to<=o.newLength?s.main:void 0;if(e.selection.ranges.length>1&&t.inputState.composing>=0&&n.to<=r.to&&n.to>=r.to-10){let s=t.state.sliceDoc(n.from,n.to),a=sn(t)||t.state.doc.lineAt(r.head),h=r.to-n.to,c=r.to-r.from;i=e.changeByRange((i=>{if(i.from==r.from&&i.to==r.to)return{changes:o,range:l||i.map(o)};let u=i.to-h,f=u-s.length;if(i.to-i.from!=c||t.state.sliceDoc(f,u)!=s||a&&i.to>=a.from&&i.from<=a.to)return{range:i};let d=e.changes({from:f,to:u,insert:n.insert}),p=i.to-r.to;return{changes:d,range:l?E.range(Math.max(0,l.anchor+p),Math.max(0,l.head+p)):i.map(d)}}))}else i={changes:o,selection:l&&e.selection.replaceRange(l)}}let l=\"input.type\";return t.composing&&(l+=\".compose\",t.inputState.compositionFirstChange&&(l+=\".start\",t.inputState.compositionFirstChange=!1)),t.dispatch(i,{scrollIntoView:!0,userEvent:l}),!0}if(s&&!s.main.eq(r)){let e=!1,i=\"select\";return t.inputState.lastSelectionTime>Date.now()-50&&(\"select\"==t.inputState.lastSelectionOrigin&&(e=!0),i=t.inputState.lastSelectionOrigin),t.dispatch({selection:s,scrollIntoView:e,userEvent:i}),!0}return!1}const Cs={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},As=ze.ie&&ze.ie_version<=11;class Os{constructor(t){this.view=t,this.active=!1,this.selectionRange=new pe,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resize=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.parentCheck=-1,this.dom=t.contentDOM,this.observer=new MutationObserver((e=>{for(let t of e)this.queue.push(t);(ze.ie&&ze.ie_version<=11||ze.ios&&t.composing)&&e.some((t=>\"childList\"==t.type&&t.removedNodes.length||\"characterData\"==t.type&&t.oldValue.length>t.target.nodeValue.length))?this.flushSoon():this.flush()})),As&&(this.onCharData=t=>{this.queue.push({target:t.target,type:\"characterData\",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),\"function\"==typeof ResizeObserver&&(this.resize=new ResizeObserver((()=>{var t;(null===(t=this.view.docView)||void 0===t?void 0:t.lastUpdate)<Date.now()-75&&this.onResize()})),this.resize.observe(t.scrollDOM)),this.addWindowListeners(this.win=t.win),this.start(),\"function\"==typeof IntersectionObserver&&(this.intersection=new IntersectionObserver((t=>{this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent(\"Event\")))}),{}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver((t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent(\"Event\"))}),{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(t){this.view.inputState.runScrollHandlers(this.view,t),this.intersecting&&this.view.measure()}onScroll(t){this.intersecting&&this.flush(!1),this.onScrollChanged(t)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout((()=>{this.resizeTimeout=-1,this.view.requestMeasure()}),50))}onPrint(){this.view.viewState.printing=!0,this.view.measure(),setTimeout((()=>{this.view.viewState.printing=!1,this.view.requestMeasure()}),500)}updateGaps(t){if(this.gapIntersection&&(t.length!=this.gaps.length||this.gaps.some(((e,i)=>e!=t[i])))){this.gapIntersection.disconnect();for(let e of t)this.gapIntersection.observe(e);this.gaps=t}}onSelectionChange(t){let e=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:i}=this,n=this.selectionRange;if(i.state.facet(Ci)?i.root.activeElement!=this.dom:!re(i.dom,n))return;let s=n.anchorNode&&i.docView.nearest(n.anchorNode);s&&s.ignoreEvent(t)?e||(this.selectionChanged=!1):(ze.ie&&ze.ie_version<=11||ze.android&&ze.chrome)&&!i.state.selection.main.empty&&n.focusNode&&le(n.focusNode,n.focusOffset,n.anchorNode,n.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:t}=this,e=ze.safari&&11==t.root.nodeType&&function(t){let e=t.activeElement;for(;e&&e.shadowRoot;)e=e.shadowRoot.activeElement;return e}(this.dom.ownerDocument)==this.dom&&function(t){let e=null;function i(t){t.preventDefault(),t.stopImmediatePropagation(),e=t.getTargetRanges()[0]}if(t.contentDOM.addEventListener(\"beforeinput\",i,!0),t.dom.ownerDocument.execCommand(\"indent\"),t.contentDOM.removeEventListener(\"beforeinput\",i,!0),!e)return null;let n=e.startContainer,s=e.startOffset,r=e.endContainer,o=e.endOffset,l=t.docView.domAtPos(t.state.selection.main.anchor);le(l.node,l.offset,r,o)&&([n,s,r,o]=[r,o,n,s]);return{anchorNode:n,anchorOffset:s,focusNode:r,focusOffset:o}}(this.view)||ne(t.root);if(!e||this.selectionRange.eq(e))return!1;let i=re(this.dom,e);return i&&!this.selectionChanged&&t.inputState.lastFocusTime>Date.now()-200&&t.inputState.lastTouchTime<Date.now()-300&&function(t,e){let i=e.focusNode,n=e.focusOffset;if(!i||e.anchorNode!=i||e.anchorOffset!=n)return!1;for(;;)if(n){if(1!=i.nodeType)return!1;let t=i.childNodes[n-1];\"false\"==t.contentEditable?n--:(i=t,n=ce(i))}else{if(i==t)return!0;n=ae(i),i=i.parentNode}}(this.dom,e)?(this.view.inputState.lastFocusTime=0,t.docView.updateSelection(),!1):(this.selectionRange.setRange(e),i&&(this.selectionChanged=!0),!0)}setSelectionRange(t,e){this.selectionRange.set(t.node,t.offset,e.node,e.offset),this.selectionChanged=!1}clearSelectionRange(){this.selectionRange.set(null,0,null,0)}listenForScroll(){this.parentCheck=-1;let t=0,e=null;for(let i=this.dom;i;)if(1==i.nodeType)!e&&t<this.scrollTargets.length&&this.scrollTargets[t]==i?t++:e||(e=this.scrollTargets.slice(0,t)),e&&e.push(i),i=i.assignedSlot||i.parentNode;else{if(11!=i.nodeType)break;i=i.host}if(t<this.scrollTargets.length&&!e&&(e=this.scrollTargets.slice(0,t)),e){for(let t of this.scrollTargets)t.removeEventListener(\"scroll\",this.onScroll);for(let t of this.scrollTargets=e)t.addEventListener(\"scroll\",this.onScroll)}}ignore(t){if(!this.active)return t();try{return this.stop(),t()}finally{this.start(),this.clear()}}start(){this.active||(this.observer.observe(this.dom,Cs),As&&this.dom.addEventListener(\"DOMCharacterDataModified\",this.onCharData),this.active=!0)}stop(){this.active&&(this.active=!1,this.observer.disconnect(),As&&this.dom.removeEventListener(\"DOMCharacterDataModified\",this.onCharData))}clear(){this.processRecords(),this.queue.length=0,this.selectionChanged=!1}delayAndroidKey(t,e){var i;if(!this.delayedAndroidKey){let t=()=>{let t=this.delayedAndroidKey;t&&(this.clearDelayedAndroidKey(),!this.flush()&&t.force&&ye(this.dom,t.key,t.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(t)}this.delayedAndroidKey&&\"Enter\"!=t||(this.delayedAndroidKey={key:t,keyCode:e,force:this.lastChange<Date.now()-50||!!(null===(i=this.delayedAndroidKey)||void 0===i?void 0:i.force)})}clearDelayedAndroidKey(){this.win.cancelAnimationFrame(this.flushingAndroidKey),this.delayedAndroidKey=null,this.flushingAndroidKey=-1}flushSoon(){this.delayedFlush<0&&(this.delayedFlush=this.view.win.requestAnimationFrame((()=>{this.delayedFlush=-1,this.flush()})))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}processRecords(){let t=this.queue;for(let e of this.observer.takeRecords())t.push(e);t.length&&(this.queue=[]);let e=-1,i=-1,n=!1;for(let s of t){let t=this.readMutation(s);t&&(t.typeOver&&(n=!0),-1==e?({from:e,to:i}=t):(e=Math.min(t.from,e),i=Math.max(t.to,i)))}return{from:e,to:i,typeOver:n}}readChange(){let{from:t,to:e,typeOver:i}=this.processRecords(),n=this.selectionChanged&&re(this.dom,this.selectionRange);return t<0&&!n?null:(t>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1,new ks(this.view,t,e,i))}flush(t=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;t&&this.readSelectionRange();let e=this.readChange();if(!e)return!1;let i=this.view.state,n=Ss(this.view,e);return this.view.state==i&&this.view.update([]),n}readMutation(t){let e=this.view.docView.nearest(t.target);if(!e||e.ignoreMutation(t))return null;if(e.markDirty(\"attributes\"==t.type),\"attributes\"==t.type&&(e.dirty|=4),\"childList\"==t.type){let i=Ms(e,t.previousSibling||t.target.previousSibling,-1),n=Ms(e,t.nextSibling||t.target.nextSibling,1);return{from:i?e.posAfter(i):e.posAtStart,to:n?e.posBefore(n):e.posAtEnd,typeOver:!1}}return\"characterData\"==t.type?{from:e.posAtStart,to:e.posAtEnd,typeOver:t.target.nodeValue==t.oldValue}:null}setWindow(t){t!=this.win&&(this.removeWindowListeners(this.win),this.win=t,this.addWindowListeners(this.win))}addWindowListeners(t){t.addEventListener(\"resize\",this.onResize),t.addEventListener(\"beforeprint\",this.onPrint),t.addEventListener(\"scroll\",this.onScroll),t.document.addEventListener(\"selectionchange\",this.onSelectionChange)}removeWindowListeners(t){t.removeEventListener(\"scroll\",this.onScroll),t.removeEventListener(\"resize\",this.onResize),t.removeEventListener(\"beforeprint\",this.onPrint),t.document.removeEventListener(\"selectionchange\",this.onSelectionChange)}destroy(){var t,e,i;this.stop(),null===(t=this.intersection)||void 0===t||t.disconnect(),null===(e=this.gapIntersection)||void 0===e||e.disconnect(),null===(i=this.resize)||void 0===i||i.disconnect();for(let t of this.scrollTargets)t.removeEventListener(\"scroll\",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey)}}function Ms(t,e,i){for(;e;){let n=Se.get(e);if(n&&n.parent==t)return n;let s=e.parentNode;e=s!=t.dom?s:i>0?e.nextSibling:e.previousSibling}return null}class Ds{constructor(t={}){this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.contentDOM=document.createElement(\"div\"),this.scrollDOM=document.createElement(\"div\"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className=\"cm-scroller\",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement(\"div\"),this.announceDOM.style.cssText=\"position: absolute; top: -10000px\",this.announceDOM.setAttribute(\"aria-live\",\"polite\"),this.dom=document.createElement(\"div\"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM),this._dispatch=t.dispatch||(t=>this.update([t])),this.dispatch=this.dispatch.bind(this),this._root=t.root||function(t){for(;t;){if(t&&(9==t.nodeType||11==t.nodeType&&t.host))return t;t=t.assignedSlot||t.parentNode}return null}(t.parent)||document,this.viewState=new os(t.state||St.create(t)),this.plugins=this.state.facet(Oi).map((t=>new Di(t)));for(let t of this.plugins)t.update(this);this.observer=new Os(this),this.inputState=new yn(this),this.inputState.ensureHandlers(this,this.plugins),this.docView=new en(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),t.parent&&t.parent.appendChild(this.dom)}get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return this.inputState.composing>0}get compositionStarted(){return this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}dispatch(...t){this._dispatch(1==t.length&&t[0]instanceof ft?t[0]:this.state.update(...t))}update(t){if(0!=this.updateState)throw new Error(\"Calls to EditorView.update are not allowed while an update is in progress\");let e,i=!1,n=!1,s=this.state;for(let e of t){if(e.startState!=s)throw new RangeError(\"Trying to update state with a transaction that doesn't start from the previous state.\");s=e.state}if(this.destroyed)return void(this.viewState.state=s);let r=this.observer.delayedAndroidKey,o=null;if(r?(this.observer.clearDelayedAndroidKey(),o=this.observer.readChange(),(o&&!this.state.doc.eq(s.doc)||!this.state.selection.eq(s.selection))&&(o=null)):this.observer.clear(),s.facet(St.phrases)!=this.state.facet(St.phrases))return this.setState(s);e=Ii.create(this,s,t);let l=this.viewState.scrollTarget;try{this.updateState=2;for(let e of t){if(l&&(l=l.map(e.changes)),e.scrollIntoView){let{main:t}=e.state.selection;l=new xi(t.empty?t:E.cursor(t.head,t.head>t.anchor?-1:1))}for(let t of e.effects)t.is(ki)&&(l=t.value)}this.viewState.update(e,l),this.bidiCache=Rs.update(this.bidiCache,e.changes),e.empty||(this.updatePlugins(e),this.inputState.update(e)),i=this.docView.update(e),this.state.facet(Li)!=this.styleModules&&this.mountStyles(),n=this.updateAttrs(),this.showAnnouncements(t),this.docView.updateSelection(i,t.some((t=>t.isUserEvent(\"select.pointer\"))))}finally{this.updateState=0}if(e.startState.facet(ps)!=e.state.facet(ps)&&(this.viewState.mustMeasureContent=!0),(i||n||l||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),!e.empty)for(let t of this.state.facet(vi))t(e);o&&!Ss(this,o)&&r.force&&ye(this.contentDOM,r.key,r.keyCode)}setState(t){if(0!=this.updateState)throw new Error(\"Calls to EditorView.setState are not allowed while an update is in progress\");if(this.destroyed)return void(this.viewState.state=t);this.updateState=2;let e=this.hasFocus;try{for(let t of this.plugins)t.destroy(this);this.viewState=new os(t),this.plugins=t.facet(Oi).map((t=>new Di(t))),this.pluginMap.clear();for(let t of this.plugins)t.update(this);this.docView=new en(this),this.inputState.ensureHandlers(this,this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}e&&this.focus(),this.requestMeasure()}updatePlugins(t){let e=t.startState.facet(Oi),i=t.state.facet(Oi);if(e!=i){let n=[];for(let s of i){let i=e.indexOf(s);if(i<0)n.push(new Di(s));else{let e=this.plugins[i];e.mustUpdate=t,n.push(e)}}for(let e of this.plugins)e.mustUpdate!=t&&e.destroy(this);this.plugins=n,this.pluginMap.clear(),this.inputState.ensureHandlers(this,this.plugins)}else for(let e of this.plugins)e.mustUpdate=t;for(let t=0;t<this.plugins.length;t++)this.plugins[t].update(this)}measure(t=!0){if(this.destroyed)return;this.measureScheduled>-1&&cancelAnimationFrame(this.measureScheduled),this.measureScheduled=0,t&&this.observer.forceFlush();let e=null,{scrollHeight:i,scrollTop:n,clientHeight:s}=this.scrollDOM,r=n>i-s-4?i:n;try{for(let t=0;;t++){this.updateState=1;let i=this.viewport,n=this.viewState.lineBlockAtHeight(r),s=this.viewState.measure(this);if(!s&&!this.measureRequests.length&&null==this.viewState.scrollTarget)break;if(t>5){console.warn(this.measureRequests.length?\"Measure loop restarted more than 5 times\":\"Viewport failed to stabilize\");break}let o=[];4&s||([this.measureRequests,o]=[o,this.measureRequests]);let l=o.map((t=>{try{return t.read(this)}catch(t){return Si(this.state,t),Ps}})),a=Ii.create(this,this.state,[]),h=!1,c=!1;a.flags|=s,e?e.flags|=s:e=a,this.updateState=2,a.empty||(this.updatePlugins(a),this.inputState.update(a),this.updateAttrs(),h=this.docView.update(a));for(let t=0;t<o.length;t++)if(l[t]!=Ps)try{let e=o[t];e.write&&e.write(l[t],this)}catch(t){Si(this.state,t)}if(this.viewState.editorHeight)if(this.viewState.scrollTarget)this.docView.scrollIntoView(this.viewState.scrollTarget),this.viewState.scrollTarget=null,c=!0;else{let t=this.viewState.lineBlockAt(n.from).top-n.top;(t>1||t<-1)&&(this.scrollDOM.scrollTop+=t,c=!0)}if(h&&this.docView.updateSelection(!0),this.viewport.from==i.from&&this.viewport.to==i.to&&!c&&0==this.measureRequests.length)break}}finally{this.updateState=0,this.measureScheduled=-1}if(e&&!e.empty)for(let t of this.state.facet(vi))t(e)}get themeClasses(){return gs+\" \"+(this.state.facet(ms)?ws:vs)+\" \"+this.state.facet(ps)}updateAttrs(){let t=Es(this,Ti,{class:\"cm-editor\"+(this.hasFocus?\" cm-focused \":\" \")+this.themeClasses}),e={spellcheck:\"false\",autocorrect:\"off\",autocapitalize:\"off\",translate:\"no\",contenteditable:this.state.facet(Ci)?\"true\":\"false\",class:\"cm-content\",style:`${ze.tabSize}: ${this.state.tabSize}`,role:\"textbox\",\"aria-multiline\":\"true\"};this.state.readOnly&&(e[\"aria-readonly\"]=\"true\"),Es(this,Pi,e);let i=this.observer.ignore((()=>{let i=Ye(this.contentDOM,this.contentAttrs,e),n=Ye(this.dom,this.editorAttrs,t);return i||n}));return this.editorAttrs=t,this.contentAttrs=e,i}showAnnouncements(t){let e=!0;for(let i of t)for(let t of i.effects)if(t.is(Ds.announce)){e&&(this.announceDOM.textContent=\"\"),e=!1,this.announceDOM.appendChild(document.createElement(\"div\")).textContent=t.value}}mountStyles(){this.styleModules=this.state.facet(Li),$t.mount(this.root,this.styleModules.concat(xs).reverse())}readMeasured(){if(2==this.updateState)throw new Error(\"Reading the editor layout isn't allowed during an update\");0==this.updateState&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(t){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame((()=>this.measure()))),t){if(null!=t.key)for(let e=0;e<this.measureRequests.length;e++)if(this.measureRequests[e].key===t.key)return void(this.measureRequests[e]=t);this.measureRequests.push(t)}}plugin(t){let e=this.pluginMap.get(t);return(void 0===e||e&&e.spec!=t)&&this.pluginMap.set(t,e=this.plugins.find((e=>e.spec==t))||null),e&&e.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}elementAtHeight(t){return this.readMeasured(),this.viewState.elementAtHeight(t)}lineBlockAtHeight(t){return this.readMeasured(),this.viewState.lineBlockAtHeight(t)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(t){return this.viewState.lineBlockAt(t)}get contentHeight(){return this.viewState.contentHeight}moveByChar(t,e,i){return wn(this,t,vn(this,t,e,i))}moveByGroup(t,e){return wn(this,t,vn(this,t,e,(e=>function(t,e,i){let n=t.state.charCategorizer(e),s=n(i);return t=>{let e=n(t);return s==yt.Space&&(s=e),s==e}}(this,t.head,e))))}moveToLineBoundary(t,e,i=!0){return function(t,e,i,n){let s=t.state.doc.lineAt(e.head),r=n&&t.lineWrapping?t.coordsAtPos(e.assoc<0&&e.head>s.from?e.head-1:e.head):null;if(r){let e=t.dom.getBoundingClientRect(),n=t.textDirectionAt(s.from),o=t.posAtCoords({x:i==(n==Vi.LTR)?e.right-1:e.left+1,y:(r.top+r.bottom)/2});if(null!=o)return E.cursor(o,i?-1:1)}let o=ai.find(t.docView,e.head),l=o?i?o.posAtEnd:o.posAtStart:i?s.to:s.from;return E.cursor(l,i?-1:1)}(this,t,e,i)}moveVertically(t,e,i){return wn(this,t,function(t,e,i,n){let s=e.head,r=i?1:-1;if(s==(i?t.state.doc.length:0))return E.cursor(s,e.assoc);let o,l=e.goalColumn,a=t.contentDOM.getBoundingClientRect(),h=t.coordsAtPos(s),c=t.documentTop;if(h)null==l&&(l=h.left-a.left),o=r<0?h.top:h.bottom;else{let e=t.viewState.lineBlockAt(s);null==l&&(l=Math.min(a.right-a.left,t.defaultCharacterWidth*(s-e.from))),o=(r<0?e.top:e.bottom)+c}let u=a.left+l,f=null!=n?n:t.defaultLineHeight>>1;for(let i=0;;i+=10){let n=o+(f+i)*r,h=mn(t,{x:u,y:n},!1,r);if(n<a.top||n>a.bottom||(r<0?h<s:h>s))return E.cursor(h,e.assoc,void 0,l)}}(this,t,e,i))}domAtPos(t){return this.docView.domAtPos(t)}posAtDOM(t,e=0){return this.docView.posFromDOM(t,e)}posAtCoords(t,e=!0){return this.readMeasured(),mn(this,t,e)}coordsAtPos(t,e=1){this.readMeasured();let i=this.docView.coordsAt(t,e);if(!i||i.left==i.right)return i;let n=this.state.doc.lineAt(t),s=this.bidiSpans(n);return fe(i,s[$i.find(s,t-n.from,-1,e)].dir==Vi.LTR==e>0)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(t){return!this.state.facet(yi)||t<this.viewport.from||t>this.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(t))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(t){if(t.length>Ts)return Ki(t.length);let e=this.textDirectionAt(t.from);for(let i of this.bidiCache)if(i.from==t.from&&i.dir==e)return i.order;let i=function(t,e){let i=t.length,n=e==Wi?1:2,s=e==Wi?2:1;if(!t||1==n&&!Ui.test(t))return Ki(i);for(let e=0,s=n,o=n;e<i;e++){let i=(r=t.charCodeAt(e))<=247?Fi[r]:1424<=r&&r<=1524?2:1536<=r&&r<=1785?qi[r-1536]:1774<=r&&r<=2220?4:8192<=r&&r<=8203?256:64336<=r&&r<=65023?4:8204==r?256:1;512==i?i=s:8==i&&4==o&&(i=16),Qi[e]=4==i?2:i,7&i&&(o=i),s=i}var r;for(let t=0,e=n,s=n;t<i;t++){let n=Qi[t];if(128==n)t<i-1&&e==Qi[t+1]&&24&e?n=Qi[t]=e:Qi[t]=256;else if(64==n){let n=t+1;for(;n<i&&64==Qi[n];)n++;let r=t&&8==e||n<i&&8==Qi[n]?1==s?1:8:256;for(let e=t;e<n;e++)Qi[e]=r;t=n-1}else 8==n&&1==s&&(Qi[t]=1);e=n,7&n&&(s=n)}for(let e,r,o,l=0,a=0,h=0;l<i;l++)if(r=_i[e=t.charCodeAt(l)])if(r<0){for(let t=a-3;t>=0;t-=3)if(ji[t+1]==-r){let e=ji[t+2],i=2&e?n:4&e?1&e?s:n:0;i&&(Qi[l]=Qi[ji[t]]=i),a=t;break}}else{if(189==ji.length)break;ji[a++]=l,ji[a++]=e,ji[a++]=h}else if(2==(o=Qi[l])||1==o){let t=o==n;h=t?0:1;for(let e=a-3;e>=0;e-=3){let i=ji[e+2];if(2&i)break;if(t)ji[e+2]|=2;else{if(4&i)break;ji[e+2]|=4}}}for(let t=0;t<i;t++)if(256==Qi[t]){let e=t+1;for(;e<i&&256==Qi[e];)e++;let s=1==(t?Qi[t-1]:n),r=s==(1==(e<i?Qi[e]:n))?s?1:2:n;for(let i=t;i<e;i++)Qi[i]=r;t=e-1}let o=[];if(1==n)for(let t=0;t<i;){let e=t,n=1!=Qi[t++];for(;t<i&&n==(1!=Qi[t]);)t++;if(n)for(let i=t;i>e;){let t=i,n=2!=Qi[--i];for(;i>e&&n==(2!=Qi[i-1]);)i--;o.push(new $i(i,t,n?2:1))}else o.push(new $i(e,t,0))}else for(let t=0;t<i;){let e=t,n=2==Qi[t++];for(;t<i&&n==(2==Qi[t]);)t++;o.push(new $i(e,t,n?1:2))}return o}(t.text,e);return this.bidiCache.push(new Rs(t.from,t.to,e,i)),i}get hasFocus(){var t;return(this.dom.ownerDocument.hasFocus()||ze.safari&&(null===(t=this.inputState)||void 0===t?void 0:t.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore((()=>{ve(this.contentDOM),this.docView.updateSelection()}))}setRoot(t){this._root!=t&&(this._root=t,this.observer.setWindow((9==t.nodeType?t:t.ownerDocument).defaultView||window),this.mountStyles())}destroy(){for(let t of this.plugins)t.destroy(this);this.plugins=[],this.inputState.destroy(),this.dom.remove(),this.observer.destroy(),this.measureScheduled>-1&&cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(t,e={}){return ki.of(new xi(\"number\"==typeof t?E.cursor(t):t,e.y,e.x,e.yMargin,e.xMargin))}static domEventHandlers(t){return Mi.define((()=>({})),{eventHandlers:t})}static theme(t,e){let i=$t.newName(),n=[ps.of(i),Li.of(bs(`.${i}`,t))];return e&&e.dark&&n.push(ms.of(!0)),n}static baseTheme(t){return K.lowest(Li.of(bs(\".\"+gs,t,ys)))}static findFromDOM(t){var e;let i=t.querySelector(\".cm-content\"),n=i&&Se.get(i)||Se.get(t);return(null===(e=null==n?void 0:n.rootView)||void 0===e?void 0:e.view)||null}}Ds.styleModule=Li,Ds.inputHandler=wi,Ds.perLineTextDirection=yi,Ds.exceptionSink=gi,Ds.updateListener=vi,Ds.editable=Ci,Ds.mouseSelectionStyle=mi,Ds.dragMovesSelection=pi,Ds.clickAddsSelectionRange=di,Ds.decorations=Ri,Ds.atomicRanges=Ei,Ds.scrollMargins=Bi,Ds.darkTheme=ms,Ds.contentAttributes=Pi,Ds.editorAttributes=Ti,Ds.lineWrapping=Ds.contentAttributes.of({class:\"cm-lineWrapping\"}),Ds.announce=ut.define();const Ts=4096,Ps={};class Rs{constructor(t,e,i,n){this.from=t,this.to=e,this.dir=i,this.order=n}static update(t,e){if(e.empty)return t;let i=[],n=t.length?t[t.length-1].dir:Vi.LTR;for(let s=Math.max(0,t.length-10);s<t.length;s++){let r=t[s];r.dir!=n||e.touchesRange(r.from,r.to)||i.push(new Rs(e.mapPos(r.from,1),e.mapPos(r.to,-1),r.dir,r.order))}return i}}function Es(t,e,i){for(let n=t.state.facet(e),s=n.length-1;s>=0;s--){let e=n[s],r=\"function\"==typeof e?e(t):e;r&&Xe(r,i)}return i}const Bs=ze.mac?\"mac\":ze.windows?\"win\":ze.linux?\"linux\":\"key\";function Ls(t,e,i){return e.altKey&&(t=\"Alt-\"+t),e.ctrlKey&&(t=\"Ctrl-\"+t),e.metaKey&&(t=\"Meta-\"+t),!1!==i&&e.shiftKey&&(t=\"Shift-\"+t),t}const Ns=K.default(Ds.domEventHandlers({keydown:(t,e)=>Hs(Ws(e.state),t,e,\"editor\")})),Is=N.define({enables:Ns}),Vs=new WeakMap;function Ws(t){let e=t.facet(Is),i=Vs.get(e);return i||Vs.set(e,i=function(t,e=Bs){let i=Object.create(null),n=Object.create(null),s=(t,e)=>{let i=n[t];if(null==i)n[t]=e;else if(i!=e)throw new Error(\"Key binding \"+t+\" is used both as a regular binding and as a multi-stroke prefix\")},r=(t,n,r,o)=>{var l,a;let h=i[t]||(i[t]=Object.create(null)),c=n.split(/ (?!$)/).map((t=>function(t,e){const i=t.split(/-(?!$)/);let n,s,r,o,l=i[i.length-1];\"Space\"==l&&(l=\" \");for(let t=0;t<i.length-1;++t){const l=i[t];if(/^(cmd|meta|m)$/i.test(l))o=!0;else if(/^a(lt)?$/i.test(l))n=!0;else if(/^(c|ctrl|control)$/i.test(l))s=!0;else if(/^s(hift)?$/i.test(l))r=!0;else{if(!/^mod$/i.test(l))throw new Error(\"Unrecognized modifier name: \"+l);\"mac\"==e?o=!0:s=!0}}return n&&(l=\"Alt-\"+l),s&&(l=\"Ctrl-\"+l),o&&(l=\"Meta-\"+l),r&&(l=\"Shift-\"+l),l}(t,e)));for(let e=1;e<c.length;e++){let i=c.slice(0,e).join(\" \");s(i,!0),h[i]||(h[i]={preventDefault:!0,run:[e=>{let n=zs={view:e,prefix:i,scope:t};return setTimeout((()=>{zs==n&&(zs=null)}),4e3),!0}]})}let u=c.join(\" \");s(u,!1);let f=h[u]||(h[u]={preventDefault:!1,run:(null===(a=null===(l=h._any)||void 0===l?void 0:l.run)||void 0===a?void 0:a.slice())||[]});r&&f.run.push(r),o&&(f.preventDefault=!0)};for(let n of t){let t=n.scope?n.scope.split(\" \"):[\"editor\"];if(n.any)for(let e of t){let t=i[e]||(i[e]=Object.create(null));t._any||(t._any={preventDefault:!1,run:[]});for(let e in t)t[e].run.push(n.any)}let s=n[e]||n.key;if(s)for(let e of t)r(e,s,n.run,n.preventDefault),n.shift&&r(e,\"Shift-\"+s,n.shift,n.preventDefault)}return i}(e.reduce(((t,e)=>t.concat(e)),[]))),i}let zs=null;function Hs(t,e,i,n){let s=function(t){var e=!(te&&(t.ctrlKey||t.altKey||t.metaKey)||Yt&&t.shiftKey&&t.key&&1==t.key.length||\"Unidentified\"==t.key)&&t.key||(t.shiftKey?Jt:Gt)[t.keyCode]||t.key||\"Unidentified\";return\"Esc\"==e&&(e=\"Escape\"),\"Del\"==e&&(e=\"Delete\"),\"Left\"==e&&(e=\"ArrowLeft\"),\"Up\"==e&&(e=\"ArrowUp\"),\"Right\"==e&&(e=\"ArrowRight\"),\"Down\"==e&&(e=\"ArrowDown\"),e}(e),r=b(w(s,0))==s.length&&\" \"!=s,o=\"\",l=!1;zs&&zs.view==i&&zs.scope==n&&(o=zs.prefix+\" \",(l=kn.indexOf(e.keyCode)<0)&&(zs=null));let a,h,c=new Set,u=t=>{if(t){for(let n of t.run)if(!c.has(n)&&(c.add(n),n(i,e)))return!0;t.preventDefault&&(l=!0)}return!1},f=t[n];if(f){if(u(f[o+Ls(s,e,!r)]))return!0;if(r&&(e.altKey||e.metaKey||e.ctrlKey)&&(a=Gt[e.keyCode])&&a!=s){if(u(f[o+Ls(a,e,!0)]))return!0;if(e.shiftKey&&(h=Jt[e.keyCode])!=s&&h!=a&&u(f[o+Ls(h,e,!1)]))return!0}else if(r&&e.shiftKey&&u(f[o+Ls(s,e,!0)]))return!0;if(u(f._any))return!0}return l}const Fs=!ze.ios,qs=N.define({combine:t=>Ct(t,{cursorBlinkRate:1200,drawRangeCursor:!0},{cursorBlinkRate:(t,e)=>Math.min(t,e),drawRangeCursor:(t,e)=>t||e})});function _s(t={}){return[qs.of(t),Us,Qs,bi.of(!0)]}class js{constructor(t,e,i,n,s){this.left=t,this.top=e,this.width=i,this.height=n,this.className=s}draw(){let t=document.createElement(\"div\");return t.className=this.className,this.adjust(t),t}adjust(t){t.style.left=this.left+\"px\",t.style.top=this.top+\"px\",this.width>=0&&(t.style.width=this.width+\"px\"),t.style.height=this.height+\"px\"}eq(t){return this.left==t.left&&this.top==t.top&&this.width==t.width&&this.height==t.height&&this.className==t.className}}const Us=Mi.fromClass(class{constructor(t){this.view=t,this.rangePieces=[],this.cursors=[],this.measureReq={read:this.readPos.bind(this),write:this.drawSel.bind(this)},this.selectionLayer=t.scrollDOM.appendChild(document.createElement(\"div\")),this.selectionLayer.className=\"cm-selectionLayer\",this.selectionLayer.setAttribute(\"aria-hidden\",\"true\"),this.cursorLayer=t.scrollDOM.appendChild(document.createElement(\"div\")),this.cursorLayer.className=\"cm-cursorLayer\",this.cursorLayer.setAttribute(\"aria-hidden\",\"true\"),t.requestMeasure(this.measureReq),this.setBlinkRate()}setBlinkRate(){this.cursorLayer.style.animationDuration=this.view.state.facet(qs).cursorBlinkRate+\"ms\"}update(t){let e=t.startState.facet(qs)!=t.state.facet(qs);(e||t.selectionSet||t.geometryChanged||t.viewportChanged)&&this.view.requestMeasure(this.measureReq),t.transactions.some((t=>t.scrollIntoView))&&(this.cursorLayer.style.animationName=\"cm-blink\"==this.cursorLayer.style.animationName?\"cm-blink2\":\"cm-blink\"),e&&this.setBlinkRate()}readPos(){let{state:t}=this.view,e=t.facet(qs),i=t.selection.ranges.map((t=>t.empty?[]:function(t,e){if(e.to<=t.viewport.from||e.from>=t.viewport.to)return[];let i=Math.max(e.from,t.viewport.from),n=Math.min(e.to,t.viewport.to),s=t.textDirection==Vi.LTR,r=t.contentDOM,o=r.getBoundingClientRect(),l=Ks(t),a=window.getComputedStyle(r.firstChild),h=o.left+parseInt(a.paddingLeft)+Math.min(0,parseInt(a.textIndent)),c=o.right-parseInt(a.paddingRight),u=Js(t,i),f=Js(t,n),d=u.type==ei.Text?u:null,p=f.type==ei.Text?f:null;t.lineWrapping&&(d&&(d=Gs(t,i,d)),p&&(p=Gs(t,n,p)));if(d&&p&&d.from==p.from)return g(v(e.from,e.to,d));{let i=d?v(e.from,null,d):w(u,!1),n=p?v(null,e.to,p):w(f,!0),s=[];return(d||u).to<(p||f).from-1?s.push(m(h,i.bottom,c,n.top)):i.bottom<n.top&&t.elementAtHeight((i.bottom+n.top)/2).type==ei.Text&&(i.bottom=n.top=(i.bottom+n.top)/2),g(i).concat(s).concat(g(n))}function m(t,e,i,n){return new js(t-l.left,e-l.top-.01,i-t,n-e+.01,\"cm-selectionBackground\")}function g({top:t,bottom:e,horizontal:i}){let n=[];for(let s=0;s<i.length;s+=2)n.push(m(i[s],t,i[s+1],e));return n}function v(e,i,n){let r=1e9,o=-1e9,l=[];function a(e,i,a,u,f){let d=t.coordsAtPos(e,e==n.to?-2:2),p=t.coordsAtPos(a,a==n.from?2:-2);r=Math.min(d.top,p.top,r),o=Math.max(d.bottom,p.bottom,o),f==Vi.LTR?l.push(s&&i?h:d.left,s&&u?c:p.right):l.push(!s&&u?h:p.left,!s&&i?c:d.right)}let u=null!=e?e:n.from,f=null!=i?i:n.to;for(let n of t.visibleRanges)if(n.to>u&&n.from<f)for(let s=Math.max(n.from,u),r=Math.min(n.to,f);;){let n=t.state.doc.lineAt(s);for(let o of t.bidiSpans(n)){let t=o.from+n.from,l=o.to+n.from;if(t>=r)break;l>s&&a(Math.max(t,s),null==e&&t<=u,Math.min(l,r),null==i&&l>=f,o.dir)}if(s=n.to+1,s>=r)break}return 0==l.length&&a(u,null==e,f,null==i,t.textDirection),{top:r,bottom:o,horizontal:l}}function w(t,e){let i=o.top+(e?t.top:t.bottom);return{top:i,bottom:i,horizontal:[]}}}(this.view,t))).reduce(((t,e)=>t.concat(e))),n=[];for(let i of t.selection.ranges){let s=i==t.selection.main;if(i.empty?!s||Fs:e.drawRangeCursor){let t=Xs(this.view,i,s);t&&n.push(t)}}return{rangePieces:i,cursors:n}}drawSel({rangePieces:t,cursors:e}){if(t.length!=this.rangePieces.length||t.some(((t,e)=>!t.eq(this.rangePieces[e])))){this.selectionLayer.textContent=\"\";for(let e of t)this.selectionLayer.appendChild(e.draw());this.rangePieces=t}if(e.length!=this.cursors.length||e.some(((t,e)=>!t.eq(this.cursors[e])))){let t=this.cursorLayer.children;if(t.length!==e.length){this.cursorLayer.textContent=\"\";for(const t of e)this.cursorLayer.appendChild(t.draw())}else e.forEach(((e,i)=>e.adjust(t[i])));this.cursors=e}}destroy(){this.selectionLayer.remove(),this.cursorLayer.remove()}}),$s={\".cm-line\":{\"& ::selection\":{backgroundColor:\"transparent !important\"},\"&::selection\":{backgroundColor:\"transparent !important\"}}};Fs&&($s[\".cm-line\"].caretColor=\"transparent !important\");const Qs=K.highest(Ds.theme($s));function Ks(t){let e=t.scrollDOM.getBoundingClientRect();return{left:(t.textDirection==Vi.LTR?e.left:e.right-t.scrollDOM.clientWidth)-t.scrollDOM.scrollLeft,top:e.top-t.scrollDOM.scrollTop}}function Gs(t,e,i){let n=E.cursor(e);return{from:Math.max(i.from,t.moveToLineBoundary(n,!1,!0).from),to:Math.min(i.to,t.moveToLineBoundary(n,!0,!0).from),type:ei.Text}}function Js(t,e){let i=t.lineBlockAt(e);if(Array.isArray(i.type))for(let t of i.type)if(t.to>e||t.to==e&&(t.to==i.to||t.type==ei.Text))return t;return i}function Xs(t,e,i){let n=t.coordsAtPos(e.head,e.assoc||1);if(!n)return null;let s=Ks(t);return new js(n.left-s.left,n.top-s.top,-1,n.bottom-n.top,i?\"cm-cursor cm-cursor-primary\":\"cm-cursor cm-cursor-secondary\")}const Zs=ut.define({map:(t,e)=>null==t?null:e.mapPos(t)}),Ys=q.define({create:()=>null,update:(t,e)=>(null!=t&&(t=e.changes.mapPos(t)),e.effects.reduce(((t,e)=>e.is(Zs)?e.value:t),t))}),tr=Mi.fromClass(class{constructor(t){this.view=t,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(t){var e;let i=t.state.field(Ys);null==i?null!=this.cursor&&(null===(e=this.cursor)||void 0===e||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement(\"div\")),this.cursor.className=\"cm-dropCursor\"),(t.startState.field(Ys)!=i||t.docChanged||t.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let t=this.view.state.field(Ys),e=null!=t&&this.view.coordsAtPos(t);if(!e)return null;let i=this.view.scrollDOM.getBoundingClientRect();return{left:e.left-i.left+this.view.scrollDOM.scrollLeft,top:e.top-i.top+this.view.scrollDOM.scrollTop,height:e.bottom-e.top}}drawCursor(t){this.cursor&&(t?(this.cursor.style.left=t.left+\"px\",this.cursor.style.top=t.top+\"px\",this.cursor.style.height=t.height+\"px\"):this.cursor.style.left=\"-100000px\")}destroy(){this.cursor&&this.cursor.remove()}setDropPos(t){this.view.state.field(Ys)!=t&&this.view.dispatch({effects:Zs.of(t)})}},{eventHandlers:{dragover(t){this.setDropPos(this.view.posAtCoords({x:t.clientX,y:t.clientY}))},dragleave(t){t.target!=this.view.contentDOM&&this.view.contentDOM.contains(t.relatedTarget)||this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function er(t,e,i,n,s){e.lastIndex=0;for(let r,o=t.iterRange(i,n),l=i;!o.next().done;l+=o.value.length)if(!o.lineBreak)for(;r=e.exec(o.value);)s(l+r.index,r)}class ir{constructor(t){const{regexp:e,decoration:i,decorate:n,boundary:s,maxLength:r=1e3}=t;if(!e.global)throw new RangeError(\"The regular expression given to MatchDecorator should have its 'g' flag set\");if(this.regexp=e,n)this.addMatch=(t,e,i,s)=>n(s,i,i+t[0].length,t,e);else if(\"function\"==typeof i)this.addMatch=(t,e,n,s)=>{let r=i(t,e,n);r&&s(n,n+t[0].length,r)};else{if(!i)throw new RangeError(\"Either 'decorate' or 'decoration' should be provided to MatchDecorator\");this.addMatch=(t,e,n,s)=>s(n,n+t[0].length,i)}this.boundary=s,this.maxLength=r}createDeco(t){let e=new Pt,i=e.add.bind(e);for(let{from:e,to:n}of function(t,e){let i=t.visibleRanges;if(1==i.length&&i[0].from==t.viewport.from&&i[0].to==t.viewport.to)return i;let n=[];for(let{from:s,to:r}of i)s=Math.max(t.state.doc.lineAt(s).from,s-e),r=Math.min(t.state.doc.lineAt(r).to,r+e),n.length&&n[n.length-1].to>=s?n[n.length-1].to=r:n.push({from:s,to:r});return n}(t,this.maxLength))er(t.state.doc,this.regexp,e,n,((e,n)=>this.addMatch(n,t,e,i)));return e.finish()}updateDeco(t,e){let i=1e9,n=-1;return t.docChanged&&t.changes.iterChanges(((e,s,r,o)=>{o>t.view.viewport.from&&r<t.view.viewport.to&&(i=Math.min(r,i),n=Math.max(o,n))})),t.viewportChanged||n-i>1e3?this.createDeco(t.view):n>-1?this.updateRange(t.view,e.map(t.changes),i,n):e}updateRange(t,e,i,n){for(let s of t.visibleRanges){let r=Math.max(s.from,i),o=Math.min(s.to,n);if(o>r){let i=t.state.doc.lineAt(r),n=i.to<o?t.state.doc.lineAt(o):i,l=Math.max(s.from,i.from),a=Math.min(s.to,n.to);if(this.boundary){for(;r>i.from;r--)if(this.boundary.test(i.text[r-1-i.from])){l=r;break}for(;o<n.to;o++)if(this.boundary.test(n.text[o-n.from])){a=o;break}}let h,c=[],u=(t,e,i)=>c.push(i.range(t,e));if(i==n)for(this.regexp.lastIndex=l-i.from;(h=this.regexp.exec(i.text))&&h.index<a-i.from;)this.addMatch(h,t,h.index+i.from,u);else er(t.state.doc,this.regexp,l,a,((e,i)=>this.addMatch(i,t,e,u)));e=e.update({filterFrom:l,filterTo:a,filter:(t,e)=>t<l||e>a,add:c})}}return e}}const nr=null!=/x/.unicode?\"gu\":\"g\",sr=new RegExp(\"[\\0-\\b\\n-\u001f-­؜​‎‏\\u2028\\u2029‭‮⁦⁧⁩\\ufeff￹-￼]\",nr),rr={0:\"null\",7:\"bell\",8:\"backspace\",10:\"newline\",11:\"vertical tab\",13:\"carriage return\",27:\"escape\",8203:\"zero width space\",8204:\"zero width non-joiner\",8205:\"zero width joiner\",8206:\"left-to-right mark\",8207:\"right-to-left mark\",8232:\"line separator\",8237:\"left-to-right override\",8238:\"right-to-left override\",8294:\"left-to-right isolate\",8295:\"right-to-left isolate\",8297:\"pop directional isolate\",8233:\"paragraph separator\",65279:\"zero width no-break space\",65532:\"object replacement\"};let or=null;const lr=N.define({combine(t){let e=Ct(t,{render:null,specialChars:sr,addSpecialChars:null});return(e.replaceTabs=!function(){var t;if(null==or&&\"undefined\"!=typeof document&&document.body){let e=document.body.style;or=null!=(null!==(t=e.tabSize)&&void 0!==t?t:e.MozTabSize)}return or||!1}())&&(e.specialChars=new RegExp(\"\\t|\"+e.specialChars.source,nr)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+\"|\"+e.addSpecialChars.source,nr)),e}});function ar(t={}){return[lr.of(t),hr||(hr=Mi.fromClass(class{constructor(t){this.view=t,this.decorations=ii.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(t.state.facet(lr)),this.decorations=this.decorator.createDeco(t)}makeDecorator(t){return new ir({regexp:t.specialChars,decoration:(e,i,n)=>{let{doc:s}=i.state,r=w(e[0],0);if(9==r){let t=s.lineAt(n),e=i.state.tabSize,r=Ft(t.text,e,n-t.from);return ii.replace({widget:new ur((e-r%e)*this.view.defaultCharacterWidth)})}return this.decorationCache[r]||(this.decorationCache[r]=ii.replace({widget:new cr(t,r)}))},boundary:t.replaceTabs?void 0:/[^]/})}update(t){let e=t.state.facet(lr);t.startState.facet(lr)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(t.view)):this.decorations=this.decorator.updateDeco(t,this.decorations)}},{decorations:t=>t.decorations}))]}let hr=null;class cr extends ti{constructor(t,e){super(),this.options=t,this.code=e}eq(t){return t.code==this.code}toDOM(t){let e=function(t){return t>=32?\"•\":10==t?\"␤\":String.fromCharCode(9216+t)}(this.code),i=t.state.phrase(\"Control character\")+\" \"+(rr[this.code]||\"0x\"+this.code.toString(16)),n=this.options.render&&this.options.render(this.code,i,e);if(n)return n;let s=document.createElement(\"span\");return s.textContent=e,s.title=i,s.setAttribute(\"aria-label\",i),s.className=\"cm-specialChar\",s}ignoreEvent(){return!1}}class ur extends ti{constructor(t){super(),this.width=t}eq(t){return t.width==this.width}toDOM(){let t=document.createElement(\"span\");return t.textContent=\"\\t\",t.className=\"cm-tab\",t.style.width=this.width+\"px\",t}ignoreEvent(){return!1}}const fr=ii.line({class:\"cm-activeLine\"}),dr=Mi.fromClass(class{constructor(t){this.decorations=this.getDeco(t)}update(t){(t.docChanged||t.selectionSet)&&(this.decorations=this.getDeco(t.view))}getDeco(t){let e=-1,i=[];for(let n of t.state.selection.ranges){let s=t.lineBlockAt(n.head);s.from>e&&(i.push(fr.range(s.from)),e=s.from)}return ii.set(i)}},{decorations:t=>t.decorations}),pr=2e3;function mr(t,e){let i=t.posAtCoords({x:e.clientX,y:e.clientY},!1),n=t.state.doc.lineAt(i),s=i-n.from,r=s>pr?-1:s==n.length?function(t,e){let i=t.coordsAtPos(t.viewport.from);return i?Math.round(Math.abs((i.left-e)/t.defaultCharacterWidth)):-1}(t,e.clientX):Ft(n.text,t.state.tabSize,i-n.from);return{line:n.number,col:r,off:s}}function gr(t,e){let i=mr(t,e),n=t.state.selection;return i?{update(t){if(t.docChanged){let e=t.changes.mapPos(t.startState.doc.line(i.line).from),s=t.state.doc.lineAt(e);i={line:s.number,col:i.col,off:Math.min(i.off,s.length)},n=n.map(t.changes)}},get(e,s,r){let o=mr(t,e);if(!o)return n;let l=function(t,e,i){let n=Math.min(e.line,i.line),s=Math.max(e.line,i.line),r=[];if(e.off>pr||i.off>pr||e.col<0||i.col<0){let o=Math.min(e.off,i.off),l=Math.max(e.off,i.off);for(let e=n;e<=s;e++){let i=t.doc.line(e);i.length<=l&&r.push(E.range(i.from+o,i.to+l))}}else{let o=Math.min(e.col,i.col),l=Math.max(e.col,i.col);for(let e=n;e<=s;e++){let i=t.doc.line(e),n=qt(i.text,o,t.tabSize,!0);if(n<0)r.push(E.cursor(i.to));else{let e=qt(i.text,l,t.tabSize);r.push(E.range(i.from+n,i.from+e))}}}return r}(t.state,i,o);return l.length?r?E.create(l.concat(n.ranges)):E.create(l):n}}:null}function vr(t){let e=(null==t?void 0:t.eventFilter)||(t=>t.altKey&&0==t.button);return Ds.mouseSelectionStyle.of(((t,i)=>e(i)?gr(t,i):null))}const wr={Alt:[18,t=>t.altKey],Control:[17,t=>t.ctrlKey],Shift:[16,t=>t.shiftKey],Meta:[91,t=>t.metaKey]},yr={style:\"cursor: crosshair\"};function br(t={}){let[e,i]=wr[t.key||\"Alt\"],n=Mi.fromClass(class{constructor(t){this.view=t,this.isDown=!1}set(t){this.isDown!=t&&(this.isDown=t,this.view.update([]))}},{eventHandlers:{keydown(t){this.set(t.keyCode==e||i(t))},keyup(t){t.keyCode!=e&&i(t)||this.set(!1)},mousemove(t){this.set(i(t))}}});return[n,Ds.contentAttributes.of((t=>{var e;return(null===(e=t.plugin(n))||void 0===e?void 0:e.isDown)?yr:null}))]}const xr=\"-10000px\";class kr{constructor(t,e,i){this.facet=e,this.createTooltipView=i,this.input=t.state.facet(e),this.tooltips=this.input.filter((t=>t)),this.tooltipViews=this.tooltips.map(i)}update(t){var e;let i=t.state.facet(this.facet),n=i.filter((t=>t));if(i===this.input){for(let e of this.tooltipViews)e.update&&e.update(t);return!1}let s=[];for(let e=0;e<n.length;e++){let i=n[e],r=-1;if(i){for(let t=0;t<this.tooltips.length;t++){let e=this.tooltips[t];e&&e.create==i.create&&(r=t)}if(r<0)s[e]=this.createTooltipView(i);else{let i=s[e]=this.tooltipViews[r];i.update&&i.update(t)}}}for(let t of this.tooltipViews)s.indexOf(t)<0&&(t.dom.remove(),null===(e=t.destroy)||void 0===e||e.call(t));return this.input=i,this.tooltips=n,this.tooltipViews=s,!0}}function Sr(t){let{win:e}=t;return{top:0,left:0,bottom:e.innerHeight,right:e.innerWidth}}const Cr=N.define({combine:t=>{var e,i,n;return{position:ze.ios?\"absolute\":(null===(e=t.find((t=>t.position)))||void 0===e?void 0:e.position)||\"fixed\",parent:(null===(i=t.find((t=>t.parent)))||void 0===i?void 0:i.parent)||null,tooltipSpace:(null===(n=t.find((t=>t.tooltipSpace)))||void 0===n?void 0:n.tooltipSpace)||Sr}}}),Ar=Mi.fromClass(class{constructor(t){this.view=t,this.inView=!0,this.lastTransaction=0,this.measureTimeout=-1;let e=t.state.facet(Cr);this.position=e.position,this.parent=e.parent,this.classes=t.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.manager=new kr(t,Dr,(t=>this.createTooltip(t))),this.intersectionObserver=\"function\"==typeof IntersectionObserver?new IntersectionObserver((t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()}),{threshold:[1]}):null,this.observeIntersection(),t.win.addEventListener(\"resize\",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement(\"div\"),this.container.style.position=\"relative\",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let t of this.manager.tooltipViews)this.intersectionObserver.observe(t.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout((()=>{this.measureTimeout=-1,this.maybeMeasure()}),50))}update(t){t.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(t);e&&this.observeIntersection();let i=e||t.geometryChanged,n=t.state.facet(Cr);if(n.position!=this.position){this.position=n.position;for(let t of this.manager.tooltipViews)t.dom.style.position=this.position;i=!0}if(n.parent!=this.parent){this.parent&&this.container.remove(),this.parent=n.parent,this.createContainer();for(let t of this.manager.tooltipViews)this.container.appendChild(t.dom);i=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);i&&this.maybeMeasure()}createTooltip(t){let e=t.create(this.view);if(e.dom.classList.add(\"cm-tooltip\"),t.arrow&&!e.dom.querySelector(\".cm-tooltip > .cm-tooltip-arrow\")){let t=document.createElement(\"div\");t.className=\"cm-tooltip-arrow\",e.dom.appendChild(t)}return e.dom.style.position=this.position,e.dom.style.top=xr,this.container.appendChild(e.dom),e.mount&&e.mount(this.view),e}destroy(){var t,e;this.view.win.removeEventListener(\"resize\",this.measureSoon);for(let e of this.manager.tooltipViews)e.dom.remove(),null===(t=e.destroy)||void 0===t||t.call(e);null===(e=this.intersectionObserver)||void 0===e||e.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let t=this.view.dom.getBoundingClientRect();return{editor:t,parent:this.parent?this.container.getBoundingClientRect():t,pos:this.manager.tooltips.map(((t,e)=>{let i=this.manager.tooltipViews[e];return i.getCoords?i.getCoords(t.pos):this.view.coordsAtPos(t.pos)})),size:this.manager.tooltipViews.map((({dom:t})=>t.getBoundingClientRect())),space:this.view.state.facet(Cr).tooltipSpace(this.view)}}writeMeasure(t){let{editor:e,space:i}=t,n=[];for(let s=0;s<this.manager.tooltips.length;s++){let r=this.manager.tooltips[s],o=this.manager.tooltipViews[s],{dom:l}=o,a=t.pos[s],h=t.size[s];if(!a||a.bottom<=Math.max(e.top,i.top)||a.top>=Math.min(e.bottom,i.bottom)||a.right<Math.max(e.left,i.left)-.1||a.left>Math.min(e.right,i.right)+.1){l.style.top=xr;continue}let c=r.arrow?o.dom.querySelector(\".cm-tooltip-arrow\"):null,u=c?7:0,f=h.right-h.left,d=h.bottom-h.top,p=o.offset||Mr,m=this.view.textDirection==Vi.LTR,g=h.width>i.right-i.left?m?i.left:i.right-h.width:m?Math.min(a.left-(c?14:0)+p.x,i.right-f):Math.max(i.left,a.left-f+(c?14:0)-p.x),v=!!r.above;!r.strictSide&&(v?a.top-(h.bottom-h.top)-p.y<i.top:a.bottom+(h.bottom-h.top)+p.y>i.bottom)&&v==i.bottom-a.bottom>a.top-i.top&&(v=!v);let w=v?a.top-d-u-p.y:a.bottom+u+p.y,y=g+f;if(!0!==o.overlap)for(let t of n)t.left<y&&t.right>g&&t.top<w+d&&t.bottom>w&&(w=v?t.top-d-2-u:t.bottom+u+2);\"absolute\"==this.position?(l.style.top=w-t.parent.top+\"px\",l.style.left=g-t.parent.left+\"px\"):(l.style.top=w+\"px\",l.style.left=g+\"px\"),c&&(c.style.left=a.left+(m?p.x:-p.x)-(g+14-7)+\"px\"),!0!==o.overlap&&n.push({left:g,top:w,right:y,bottom:w+d}),l.classList.toggle(\"cm-tooltip-above\",v),l.classList.toggle(\"cm-tooltip-below\",!v),o.positioned&&o.positioned(t.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let t of this.manager.tooltipViews)t.dom.style.top=xr}},{eventHandlers:{scroll(){this.maybeMeasure()}}}),Or=Ds.baseTheme({\".cm-tooltip\":{zIndex:100},\"&light .cm-tooltip\":{border:\"1px solid #bbb\",backgroundColor:\"#f5f5f5\"},\"&light .cm-tooltip-section:not(:first-child)\":{borderTop:\"1px solid #bbb\"},\"&dark .cm-tooltip\":{backgroundColor:\"#333338\",color:\"white\"},\".cm-tooltip-arrow\":{height:\"7px\",width:\"14px\",position:\"absolute\",zIndex:-1,overflow:\"hidden\",\"&:before, &:after\":{content:\"''\",position:\"absolute\",width:0,height:0,borderLeft:\"7px solid transparent\",borderRight:\"7px solid transparent\"},\".cm-tooltip-above &\":{bottom:\"-7px\",\"&:before\":{borderTop:\"7px solid #bbb\"},\"&:after\":{borderTop:\"7px solid #f5f5f5\",bottom:\"1px\"}},\".cm-tooltip-below &\":{top:\"-7px\",\"&:before\":{borderBottom:\"7px solid #bbb\"},\"&:after\":{borderBottom:\"7px solid #f5f5f5\",top:\"1px\"}}},\"&dark .cm-tooltip .cm-tooltip-arrow\":{\"&:before\":{borderTopColor:\"#333338\",borderBottomColor:\"#333338\"},\"&:after\":{borderTopColor:\"transparent\",borderBottomColor:\"transparent\"}}}),Mr={x:0,y:0},Dr=N.define({enables:[Ar,Or]}),Tr=N.define();class Pr{constructor(t){this.view=t,this.mounted=!1,this.dom=document.createElement(\"div\"),this.dom.classList.add(\"cm-tooltip-hover\"),this.manager=new kr(t,Tr,(t=>this.createHostedView(t)))}static create(t){return new Pr(t)}createHostedView(t){let e=t.create(this.view);return e.dom.classList.add(\"cm-tooltip-section\"),this.dom.appendChild(e.dom),this.mounted&&e.mount&&e.mount(this.view),e}mount(t){for(let e of this.manager.tooltipViews)e.mount&&e.mount(t);this.mounted=!0}positioned(t){for(let e of this.manager.tooltipViews)e.positioned&&e.positioned(t)}update(t){this.manager.update(t)}}const Rr=Dr.compute([Tr],(t=>{let e=t.facet(Tr).filter((t=>t));return 0===e.length?null:{pos:Math.min(...e.map((t=>t.pos))),end:Math.max(...e.filter((t=>null!=t.end)).map((t=>t.end))),create:Pr.create,above:e[0].above,arrow:e.some((t=>t.arrow))}}));class Er{constructor(t,e,i,n,s){this.view=t,this.source=e,this.field=i,this.setHover=n,this.hoverTime=s,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:t.dom,time:0},this.checkHover=this.checkHover.bind(this),t.dom.addEventListener(\"mouseleave\",this.mouseleave=this.mouseleave.bind(this)),t.dom.addEventListener(\"mousemove\",this.mousemove=this.mousemove.bind(this))}update(){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout((()=>this.startHover()),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active)return;let t=Date.now()-this.lastMove.time;t<this.hoverTime?this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime-t):this.startHover()}startHover(){clearTimeout(this.restartTimeout);let{lastMove:t}=this,e=this.view.contentDOM.contains(t.target)?this.view.posAtCoords(t):null;if(null==e)return;let i=this.view.coordsAtPos(e);if(null==i||t.y<i.top||t.y>i.bottom||t.x<i.left-this.view.defaultCharacterWidth||t.x>i.right+this.view.defaultCharacterWidth)return;let n=this.view.bidiSpans(this.view.state.doc.lineAt(e)).find((t=>t.from<=e&&t.to>=e)),s=n&&n.dir==Vi.RTL?-1:1,r=this.source(this.view,e,t.x<i.left?-s:s);if(null==r?void 0:r.then){let t=this.pending={pos:e};r.then((e=>{this.pending==t&&(this.pending=null,e&&this.view.dispatch({effects:this.setHover.of(e)}))}),(t=>Si(this.view.state,t,\"hover tooltip\")))}else r&&this.view.dispatch({effects:this.setHover.of(r)})}mousemove(t){var e;this.lastMove={x:t.clientX,y:t.clientY,target:t.target,time:Date.now()},this.hoverTimeout<0&&(this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime));let i=this.active;if(i&&!Br(this.lastMove.target)||this.pending){let{pos:n}=i||this.pending,s=null!==(e=null==i?void 0:i.end)&&void 0!==e?e:n;(n==s?this.view.posAtCoords(this.lastMove)==n:function(t,e,i,n,s,r){let o=document.createRange(),l=t.domAtPos(e),a=t.domAtPos(i);o.setEnd(a.node,a.offset),o.setStart(l.node,l.offset);let h=o.getClientRects();o.detach();for(let t=0;t<h.length;t++){let e=h[t];if(Math.max(e.top-s,s-e.bottom,e.left-n,n-e.right)<=r)return!0}return!1}(this.view,n,s,t.clientX,t.clientY,6))||(this.view.dispatch({effects:this.setHover.of(null)}),this.pending=null)}}mouseleave(t){clearTimeout(this.hoverTimeout),this.hoverTimeout=-1,this.active&&!Br(t.relatedTarget)&&this.view.dispatch({effects:this.setHover.of(null)})}destroy(){clearTimeout(this.hoverTimeout),this.view.dom.removeEventListener(\"mouseleave\",this.mouseleave),this.view.dom.removeEventListener(\"mousemove\",this.mousemove)}}function Br(t){for(let e=t;e;e=e.parentNode)if(1==e.nodeType&&e.classList.contains(\"cm-tooltip\"))return!0;return!1}function Lr(t,e={}){let i=ut.define(),n=q.define({create:()=>null,update(t,n){if(t&&(e.hideOnChange&&(n.docChanged||n.selection)||e.hideOn&&e.hideOn(n,t)))return null;if(t&&n.docChanged){let e=n.changes.mapPos(t.pos,-1,k.TrackDel);if(null==e)return null;let i=Object.assign(Object.create(null),t);i.pos=e,null!=t.end&&(i.end=n.changes.mapPos(t.end)),t=i}for(let e of n.effects)e.is(i)&&(t=e.value),e.is(Nr)&&(t=null);return t},provide:t=>Tr.from(t)});return[n,Mi.define((s=>new Er(s,t,n,i,e.hoverTime||300))),Rr]}const Nr=ut.define(),Ir=N.define({combine(t){let e,i;for(let n of t)e=e||n.topContainer,i=i||n.bottomContainer;return{topContainer:e,bottomContainer:i}}});function Vr(t,e){let i=t.plugin(Wr),n=i?i.specs.indexOf(e):-1;return n>-1?i.panels[n]:null}const Wr=Mi.fromClass(class{constructor(t){this.input=t.state.facet(Fr),this.specs=this.input.filter((t=>t)),this.panels=this.specs.map((e=>e(t)));let e=t.state.facet(Ir);this.top=new zr(t,!0,e.topContainer),this.bottom=new zr(t,!1,e.bottomContainer),this.top.sync(this.panels.filter((t=>t.top))),this.bottom.sync(this.panels.filter((t=>!t.top)));for(let t of this.panels)t.dom.classList.add(\"cm-panel\"),t.mount&&t.mount()}update(t){let e=t.state.facet(Ir);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new zr(t.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new zr(t.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let i=t.state.facet(Fr);if(i!=this.input){let e=i.filter((t=>t)),n=[],s=[],r=[],o=[];for(let i of e){let e,l=this.specs.indexOf(i);l<0?(e=i(t.view),o.push(e)):(e=this.panels[l],e.update&&e.update(t)),n.push(e),(e.top?s:r).push(e)}this.specs=e,this.panels=n,this.top.sync(s),this.bottom.sync(r);for(let t of o)t.dom.classList.add(\"cm-panel\"),t.mount&&t.mount()}else for(let e of this.panels)e.update&&e.update(t)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:t=>Ds.scrollMargins.of((e=>{let i=e.plugin(t);return i&&{top:i.top.scrollMargin(),bottom:i.bottom.scrollMargin()}}))});class zr{constructor(t,e,i){this.view=t,this.top=e,this.container=i,this.dom=void 0,this.classes=\"\",this.panels=[],this.syncClasses()}sync(t){for(let e of this.panels)e.destroy&&t.indexOf(e)<0&&e.destroy();this.panels=t,this.syncDOM()}syncDOM(){if(0==this.panels.length)return void(this.dom&&(this.dom.remove(),this.dom=void 0));if(!this.dom){this.dom=document.createElement(\"div\"),this.dom.className=this.top?\"cm-panels cm-panels-top\":\"cm-panels cm-panels-bottom\",this.dom.style[this.top?\"top\":\"bottom\"]=\"0\";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let t=this.dom.firstChild;for(let e of this.panels)if(e.dom.parentNode==this.dom){for(;t!=e.dom;)t=Hr(t);t=t.nextSibling}else this.dom.insertBefore(e.dom,t);for(;t;)t=Hr(t)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(this.container&&this.classes!=this.view.themeClasses){for(let t of this.classes.split(\" \"))t&&this.container.classList.remove(t);for(let t of(this.classes=this.view.themeClasses).split(\" \"))t&&this.container.classList.add(t)}}}function Hr(t){let e=t.nextSibling;return t.remove(),e}const Fr=N.define({enables:Wr});class qr extends At{compare(t){return this==t||this.constructor==t.constructor&&this.eq(t)}eq(t){return!1}destroy(t){}}qr.prototype.elementClass=\"\",qr.prototype.toDOM=void 0,qr.prototype.mapMode=k.TrackBefore,qr.prototype.startSide=qr.prototype.endSide=-1,qr.prototype.point=!0;const _r=N.define(),jr={class:\"\",renderEmptyElements:!1,elementStyle:\"\",markers:()=>Tt.empty,lineMarker:()=>null,lineMarkerChange:null,initialSpacer:null,updateSpacer:null,domEventHandlers:{}},Ur=N.define();function $r(t){return[Kr(),Ur.of(Object.assign(Object.assign({},jr),t))]}const Qr=N.define({combine:t=>t.some((t=>t))});function Kr(t){let e=[Gr];return t&&!1===t.fixed&&e.push(Qr.of(!0)),e}const Gr=Mi.fromClass(class{constructor(t){this.view=t,this.prevViewport=t.viewport,this.dom=document.createElement(\"div\"),this.dom.className=\"cm-gutters\",this.dom.setAttribute(\"aria-hidden\",\"true\"),this.dom.style.minHeight=this.view.contentHeight+\"px\",this.gutters=t.state.facet(Ur).map((e=>new Yr(t,e)));for(let t of this.gutters)this.dom.appendChild(t.dom);this.fixed=!t.state.facet(Qr),this.fixed&&(this.dom.style.position=\"sticky\"),this.syncGutters(!1),t.scrollDOM.insertBefore(this.dom,t.contentDOM)}update(t){if(this.updateGutters(t)){let e=this.prevViewport,i=t.view.viewport,n=Math.min(e.to,i.to)-Math.max(e.from,i.from);this.syncGutters(n<.8*(i.to-i.from))}t.geometryChanged&&(this.dom.style.minHeight=this.view.contentHeight+\"px\"),this.view.state.facet(Qr)!=!this.fixed&&(this.fixed=!this.fixed,this.dom.style.position=this.fixed?\"sticky\":\"\"),this.prevViewport=t.view.viewport}syncGutters(t){let e=this.dom.nextSibling;t&&this.dom.remove();let i=Tt.iter(this.view.state.facet(_r),this.view.viewport.from),n=[],s=this.gutters.map((t=>new Zr(t,this.view.viewport,-this.view.documentPadding.top)));for(let t of this.view.viewportLineBlocks){let e;if(Array.isArray(t.type)){for(let i of t.type)if(i.type==ei.Text){e=i;break}}else e=t.type==ei.Text?t:void 0;if(e){n.length&&(n=[]),Xr(i,n,t.from);for(let t of s)t.line(this.view,e,n)}}for(let t of s)t.finish();t&&this.view.scrollDOM.insertBefore(this.dom,e)}updateGutters(t){let e=t.startState.facet(Ur),i=t.state.facet(Ur),n=t.docChanged||t.heightChanged||t.viewportChanged||!Tt.eq(t.startState.facet(_r),t.state.facet(_r),t.view.viewport.from,t.view.viewport.to);if(e==i)for(let e of this.gutters)e.update(t)&&(n=!0);else{n=!0;let s=[];for(let n of i){let i=e.indexOf(n);i<0?s.push(new Yr(this.view,n)):(this.gutters[i].update(t),s.push(this.gutters[i]))}for(let t of this.gutters)t.dom.remove(),s.indexOf(t)<0&&t.destroy();for(let t of s)this.dom.appendChild(t.dom);this.gutters=s}return n}destroy(){for(let t of this.gutters)t.destroy();this.dom.remove()}},{provide:t=>Ds.scrollMargins.of((e=>{let i=e.plugin(t);return i&&0!=i.gutters.length&&i.fixed?e.textDirection==Vi.LTR?{left:i.dom.offsetWidth}:{right:i.dom.offsetWidth}:null}))});function Jr(t){return Array.isArray(t)?t:[t]}function Xr(t,e,i){for(;t.value&&t.from<=i;)t.from==i&&e.push(t.value),t.next()}class Zr{constructor(t,e,i){this.gutter=t,this.height=i,this.localMarkers=[],this.i=0,this.cursor=Tt.iter(t.markers,e.from)}line(t,e,i){this.localMarkers.length&&(this.localMarkers=[]),Xr(this.cursor,this.localMarkers,e.from);let n=i.length?this.localMarkers.concat(i):this.localMarkers,s=this.gutter.config.lineMarker(t,e,n);s&&n.unshift(s);let r=this.gutter;if(0==n.length&&!r.config.renderEmptyElements)return;let o=e.top-this.height;if(this.i==r.elements.length){let i=new to(t,e.height,o,n);r.elements.push(i),r.dom.appendChild(i.dom)}else r.elements[this.i].update(t,e.height,o,n);this.height=e.bottom,this.i++}finish(){let t=this.gutter;for(;t.elements.length>this.i;){let e=t.elements.pop();t.dom.removeChild(e.dom),e.destroy()}}}class Yr{constructor(t,e){this.view=t,this.config=e,this.elements=[],this.spacer=null,this.dom=document.createElement(\"div\"),this.dom.className=\"cm-gutter\"+(this.config.class?\" \"+this.config.class:\"\");for(let i in e.domEventHandlers)this.dom.addEventListener(i,(n=>{let s=t.lineBlockAtHeight(n.clientY-t.documentTop);e.domEventHandlers[i](t,s,n)&&n.preventDefault()}));this.markers=Jr(e.markers(t)),e.initialSpacer&&(this.spacer=new to(t,0,0,[e.initialSpacer(t)]),this.dom.appendChild(this.spacer.dom),this.spacer.dom.style.cssText+=\"visibility: hidden; pointer-events: none\")}update(t){let e=this.markers;if(this.markers=Jr(this.config.markers(t.view)),this.spacer&&this.config.updateSpacer){let e=this.config.updateSpacer(this.spacer.markers[0],t);e!=this.spacer.markers[0]&&this.spacer.update(t.view,0,0,[e])}let i=t.view.viewport;return!Tt.eq(this.markers,e,i.from,i.to)||!!this.config.lineMarkerChange&&this.config.lineMarkerChange(t)}destroy(){for(let t of this.elements)t.destroy()}}class to{constructor(t,e,i,n){this.height=-1,this.above=0,this.markers=[],this.dom=document.createElement(\"div\"),this.dom.className=\"cm-gutterElement\",this.update(t,e,i,n)}update(t,e,i,n){this.height!=e&&(this.dom.style.height=(this.height=e)+\"px\"),this.above!=i&&(this.dom.style.marginTop=(this.above=i)?i+\"px\":\"\"),function(t,e){if(t.length!=e.length)return!1;for(let i=0;i<t.length;i++)if(!t[i].compare(e[i]))return!1;return!0}(this.markers,n)||this.setMarkers(t,n)}setMarkers(t,e){let i=\"cm-gutterElement\",n=this.dom.firstChild;for(let s=0,r=0;;){let o=r,l=s<e.length?e[s++]:null,a=!1;if(l){let t=l.elementClass;t&&(i+=\" \"+t);for(let t=r;t<this.markers.length;t++)if(this.markers[t].compare(l)){o=t,a=!0;break}}else o=this.markers.length;for(;r<o;){let t=this.markers[r++];if(t.toDOM){t.destroy(n);let e=n.nextSibling;n.remove(),n=e}}if(!l)break;l.toDOM&&(a?n=n.nextSibling:this.dom.insertBefore(l.toDOM(t),n)),a&&r++}this.dom.className=i,this.markers=e}destroy(){this.setMarkers(null,[])}}const eo=N.define(),io=N.define({combine:t=>Ct(t,{formatNumber:String,domEventHandlers:{}},{domEventHandlers(t,e){let i=Object.assign({},t);for(let t in e){let n=i[t],s=e[t];i[t]=n?(t,e,i)=>n(t,e,i)||s(t,e,i):s}return i}})});class no extends qr{constructor(t){super(),this.number=t}eq(t){return this.number==t.number}toDOM(){return document.createTextNode(this.number)}}function so(t,e){return t.state.facet(io).formatNumber(e,t.state)}const ro=Ur.compute([io],(t=>({class:\"cm-lineNumbers\",renderEmptyElements:!1,markers:t=>t.state.facet(eo),lineMarker:(t,e,i)=>i.some((t=>t.toDOM))?null:new no(so(t,t.state.doc.lineAt(e.from).number)),lineMarkerChange:t=>t.startState.facet(io)!=t.state.facet(io),initialSpacer:t=>new no(so(t,lo(t.state.doc.lines))),updateSpacer(t,e){let i=so(e.view,lo(e.view.state.doc.lines));return i==t.number?t:new no(i)},domEventHandlers:t.facet(io).domEventHandlers})));function oo(t={}){return[io.of(t),Kr(),ro]}function lo(t){let e=9;for(;e<t;)e=10*e+9;return e}const ao=new class extends qr{constructor(){super(...arguments),this.elementClass=\"cm-activeLineGutter\"}},ho=_r.compute([\"selection\"],(t=>{let e=[],i=-1;for(let n of t.selection.ranges){let s=t.doc.lineAt(n.head).from;s>i&&(i=s,e.push(ao.range(s)))}return Tt.of(e)}));const co=1024;let uo=0;class fo{constructor(t,e){this.from=t,this.to=e}}class po{constructor(t={}){this.id=uo++,this.perNode=!!t.perNode,this.deserialize=t.deserialize||(()=>{throw new Error(\"This node type doesn't define a deserialize function\")})}add(t){if(this.perNode)throw new RangeError(\"Can't add per-node props to node types\");return\"function\"!=typeof t&&(t=go.match(t)),e=>{let i=t(e);return void 0===i?null:[this,i]}}}po.closedBy=new po({deserialize:t=>t.split(\" \")}),po.openedBy=new po({deserialize:t=>t.split(\" \")}),po.group=new po({deserialize:t=>t.split(\" \")}),po.contextHash=new po({perNode:!0}),po.lookAhead=new po({perNode:!0}),po.mounted=new po({perNode:!0});const mo=Object.create(null);class go{constructor(t,e,i,n=0){this.name=t,this.props=e,this.id=i,this.flags=n}static define(t){let e=t.props&&t.props.length?Object.create(null):mo,i=(t.top?1:0)|(t.skipped?2:0)|(t.error?4:0)|(null==t.name?8:0),n=new go(t.name||\"\",e,t.id,i);if(t.props)for(let i of t.props)if(Array.isArray(i)||(i=i(n)),i){if(i[0].perNode)throw new RangeError(\"Can't store a per-node prop on a node type\");e[i[0].id]=i[1]}return n}prop(t){return this.props[t.id]}get isTop(){return(1&this.flags)>0}get isSkipped(){return(2&this.flags)>0}get isError(){return(4&this.flags)>0}get isAnonymous(){return(8&this.flags)>0}is(t){if(\"string\"==typeof t){if(this.name==t)return!0;let e=this.prop(po.group);return!!e&&e.indexOf(t)>-1}return this.id==t}static match(t){let e=Object.create(null);for(let i in t)for(let n of i.split(\" \"))e[n]=t[i];return t=>{for(let i=t.prop(po.group),n=-1;n<(i?i.length:0);n++){let s=e[n<0?t.name:i[n]];if(s)return s}}}}go.none=new go(\"\",Object.create(null),0,8);class vo{constructor(t){this.types=t;for(let e=0;e<t.length;e++)if(t[e].id!=e)throw new RangeError(\"Node type ids should correspond to array positions when creating a node set\")}extend(...t){let e=[];for(let i of this.types){let n=null;for(let e of t){let t=e(i);t&&(n||(n=Object.assign({},i.props)),n[t[0].id]=t[1])}e.push(n?new go(i.name,n,i.id,i.flags):i)}return new vo(e)}}const wo=new WeakMap,yo=new WeakMap;var bo;!function(t){t[t.ExcludeBuffers=1]=\"ExcludeBuffers\",t[t.IncludeAnonymous=2]=\"IncludeAnonymous\",t[t.IgnoreMounts=4]=\"IgnoreMounts\",t[t.IgnoreOverlays=8]=\"IgnoreOverlays\"}(bo||(bo={}));class xo{constructor(t,e,i,n,s){if(this.type=t,this.children=e,this.positions=i,this.length=n,this.props=null,s&&s.length){this.props=Object.create(null);for(let[t,e]of s)this.props[\"number\"==typeof t?t:t.id]=e}}toString(){let t=this.prop(po.mounted);if(t&&!t.overlay)return t.tree.toString();let e=\"\";for(let t of this.children){let i=t.toString();i&&(e&&(e+=\",\"),e+=i)}return this.type.name?(/\\W/.test(this.type.name)&&!this.type.isError?JSON.stringify(this.type.name):this.type.name)+(e.length?\"(\"+e+\")\":\"\"):e}cursor(t=0){return new Eo(this.topNode,t)}cursorAt(t,e=0,i=0){let n=wo.get(this)||this.topNode,s=new Eo(n);return s.moveTo(t,e),wo.set(this,s._tree),s}get topNode(){return new Mo(this,0,0,null)}resolve(t,e=0){let i=Oo(wo.get(this)||this.topNode,t,e,!1);return wo.set(this,i),i}resolveInner(t,e=0){let i=Oo(yo.get(this)||this.topNode,t,e,!0);return yo.set(this,i),i}iterate(t){let{enter:e,leave:i,from:n=0,to:s=this.length}=t;for(let r=this.cursor((t.mode||0)|bo.IncludeAnonymous);;){let t=!1;if(r.from<=s&&r.to>=n&&(r.type.isAnonymous||!1!==e(r))){if(r.firstChild())continue;t=!0}for(;t&&i&&!r.type.isAnonymous&&i(r),!r.nextSibling();){if(!r.parent())return;t=!0}}}prop(t){return t.perNode?this.props?this.props[t.id]:void 0:this.type.prop(t)}get propValues(){let t=[];if(this.props)for(let e in this.props)t.push([+e,this.props[e]]);return t}balance(t={}){return this.children.length<=8?this:Io(go.none,this.children,this.positions,0,this.children.length,0,this.length,((t,e,i)=>new xo(this.type,t,e,i,this.propValues)),t.makeTree||((t,e,i)=>new xo(go.none,t,e,i)))}static build(t){return function(t){var e;let{buffer:i,nodeSet:n,maxBufferLength:s=co,reused:r=[],minRepeatType:o=n.types.length}=t,l=Array.isArray(i)?new ko(i,i.length):i,a=n.types,h=0,c=0;function u(t,e,i,v,w){let{id:y,start:b,end:x,size:k}=l,S=c;for(;k<0;){if(l.next(),-1==k){let e=r[y];return i.push(e),void v.push(b-t)}if(-3==k)return void(h=y);if(-4==k)return void(c=y);throw new RangeError(`Unrecognized record size: ${k}`)}let C,A,O=a[y],M=b-t;if(x-b<=s&&(A=m(l.pos-e,w))){let e=new Uint16Array(A.size-A.skip),i=l.pos-A.size,s=e.length;for(;l.pos>i;)s=g(A.start,e,s);C=new So(e,x-A.start,n),M=A.start-t}else{let t=l.pos-k;l.next();let e=[],i=[],n=y>=o?y:-1,r=0,a=x;for(;l.pos>t;)n>=0&&l.id==n&&l.size>=0?(l.end<=a-s&&(d(e,i,b,r,l.end,a,n,S),r=e.length,a=l.end),l.next()):u(b,t,e,i,n);if(n>=0&&r>0&&r<e.length&&d(e,i,b,r,b,a,n,S),e.reverse(),i.reverse(),n>-1&&r>0){let t=f(O);C=Io(O,e,i,0,e.length,0,x-b,t,t)}else C=p(O,e,i,x-b,S-x)}i.push(C),v.push(M)}function f(t){return(e,i,n)=>{let s,r,o=0,l=e.length-1;if(l>=0&&(s=e[l])instanceof xo){if(!l&&s.type==t&&s.length==n)return s;(r=s.prop(po.lookAhead))&&(o=i[l]+s.length+r)}return p(t,e,i,n,o)}}function d(t,e,i,s,r,o,l,a){let h=[],c=[];for(;t.length>s;)h.push(t.pop()),c.push(e.pop()+i-r);t.push(p(n.types[l],h,c,o-r,a-o)),e.push(r-i)}function p(t,e,i,n,s=0,r){if(h){let t=[po.contextHash,h];r=r?[t].concat(r):[t]}if(s>25){let t=[po.lookAhead,s];r=r?[t].concat(r):[t]}return new xo(t,e,i,n,r)}function m(t,e){let i=l.fork(),n=0,r=0,a=0,h=i.end-s,c={size:0,start:0,skip:0};t:for(let s=i.pos-t;i.pos>s;){let t=i.size;if(i.id==e&&t>=0){c.size=n,c.start=r,c.skip=a,a+=4,n+=4,i.next();continue}let l=i.pos-t;if(t<0||l<s||i.start<h)break;let u=i.id>=o?4:0,f=i.start;for(i.next();i.pos>l;){if(i.size<0){if(-3!=i.size)break t;u+=4}else i.id>=o&&(u+=4);i.next()}r=f,n+=t,a+=u}return(e<0||n==t)&&(c.size=n,c.start=r,c.skip=a),c.size>4?c:void 0}function g(t,e,i){let{id:n,start:s,end:r,size:a}=l;if(l.next(),a>=0&&n<o){let o=i;if(a>4){let n=l.pos-(a-4);for(;l.pos>n;)i=g(t,e,i)}e[--i]=o,e[--i]=r-t,e[--i]=s-t,e[--i]=n}else-3==a?h=n:-4==a&&(c=n);return i}let v=[],w=[];for(;l.pos>0;)u(t.start||0,t.bufferStart||0,v,w,-1);let y=null!==(e=t.length)&&void 0!==e?e:v.length?w[0]+v[0].length:0;return new xo(a[t.topID],v.reverse(),w.reverse(),y)}(t)}}xo.empty=new xo(go.none,[],[],0);class ko{constructor(t,e){this.buffer=t,this.index=e}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new ko(this.buffer,this.index)}}class So{constructor(t,e,i){this.buffer=t,this.length=e,this.set=i}get type(){return go.none}toString(){let t=[];for(let e=0;e<this.buffer.length;)t.push(this.childString(e)),e=this.buffer[e+3];return t.join(\",\")}childString(t){let e=this.buffer[t],i=this.buffer[t+3],n=this.set.types[e],s=n.name;if(/\\W/.test(s)&&!n.isError&&(s=JSON.stringify(s)),i==(t+=4))return s;let r=[];for(;t<i;)r.push(this.childString(t)),t=this.buffer[t+3];return s+\"(\"+r.join(\",\")+\")\"}findChild(t,e,i,n,s){let{buffer:r}=this,o=-1;for(let l=t;l!=e&&!(Co(s,n,r[l+1],r[l+2])&&(o=l,i>0));l=r[l+3]);return o}slice(t,e,i,n){let s=this.buffer,r=new Uint16Array(e-t);for(let n=t,o=0;n<e;)r[o++]=s[n++],r[o++]=s[n++]-i,r[o++]=s[n++]-i,r[o++]=s[n++]-t;return new So(r,n-i,this.set)}}function Co(t,e,i,n){switch(t){case-2:return i<e;case-1:return n>=e&&i<e;case 0:return i<e&&n>e;case 1:return i<=e&&n>e;case 2:return n>e;case 4:return!0}}function Ao(t,e){let i=t.childBefore(e);for(;i;){let e=i.lastChild;if(!e||e.to!=i.to)break;e.type.isError&&e.from==e.to?(t=i,i=e.prevSibling):i=e}return t}function Oo(t,e,i,n){for(var s;t.from==t.to||(i<1?t.from>=e:t.from>e)||(i>-1?t.to<=e:t.to<e);){let e=!n&&t instanceof Mo&&t.index<0?null:t.parent;if(!e)return t;t=e}let r=n?0:bo.IgnoreOverlays;if(n)for(let n=t,o=n.parent;o;n=o,o=n.parent)n instanceof Mo&&n.index<0&&(null===(s=o.enter(e,i,r))||void 0===s?void 0:s.from)!=n.from&&(t=o);for(;;){let n=t.enter(e,i,r);if(!n)return t;t=n}}class Mo{constructor(t,e,i,n){this._tree=t,this.from=e,this.index=i,this._parent=n}get type(){return this._tree.type}get name(){return this._tree.type.name}get to(){return this.from+this._tree.length}nextChild(t,e,i,n,s=0){for(let r=this;;){for(let{children:o,positions:l}=r._tree,a=e>0?o.length:-1;t!=a;t+=e){let a=o[t],h=l[t]+r.from;if(Co(n,i,h,h+a.length))if(a instanceof So){if(s&bo.ExcludeBuffers)continue;let o=a.findChild(0,a.buffer.length,e,i-h,n);if(o>-1)return new Ro(new Po(r,a,t,h),null,o)}else if(s&bo.IncludeAnonymous||!a.type.isAnonymous||Bo(a)){let o;if(!(s&bo.IgnoreMounts)&&a.props&&(o=a.prop(po.mounted))&&!o.overlay)return new Mo(o.tree,h,t,r);let l=new Mo(a,h,t,r);return s&bo.IncludeAnonymous||!l.type.isAnonymous?l:l.nextChild(e<0?a.children.length-1:0,e,i,n)}}if(s&bo.IncludeAnonymous||!r.type.isAnonymous)return null;if(t=r.index>=0?r.index+e:e<0?-1:r._parent._tree.children.length,r=r._parent,!r)return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(t){return this.nextChild(0,1,t,2)}childBefore(t){return this.nextChild(this._tree.children.length-1,-1,t,-2)}enter(t,e,i=0){let n;if(!(i&bo.IgnoreOverlays)&&(n=this._tree.prop(po.mounted))&&n.overlay){let i=t-this.from;for(let{from:t,to:s}of n.overlay)if((e>0?t<=i:t<i)&&(e<0?s>=i:s>i))return new Mo(n.tree,n.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,t,e,i)}nextSignificantParent(){let t=this;for(;t.type.isAnonymous&&t._parent;)t=t._parent;return t}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}cursor(t=0){return new Eo(this,t)}get tree(){return this._tree}toTree(){return this._tree}resolve(t,e=0){return Oo(this,t,e,!1)}resolveInner(t,e=0){return Oo(this,t,e,!0)}enterUnfinishedNodesBefore(t){return Ao(this,t)}getChild(t,e=null,i=null){let n=Do(this,t,e,i);return n.length?n[0]:null}getChildren(t,e=null,i=null){return Do(this,t,e,i)}toString(){return this._tree.toString()}get node(){return this}matchContext(t){return To(this,t)}}function Do(t,e,i,n){let s=t.cursor(),r=[];if(!s.firstChild())return r;if(null!=i)for(;!s.type.is(i);)if(!s.nextSibling())return r;for(;;){if(null!=n&&s.type.is(n))return r;if(s.type.is(e)&&r.push(s.node),!s.nextSibling())return null==n?r:[]}}function To(t,e,i=e.length-1){for(let n=t.parent;i>=0;n=n.parent){if(!n)return!1;if(!n.type.isAnonymous){if(e[i]&&e[i]!=n.name)return!1;i--}}return!0}class Po{constructor(t,e,i,n){this.parent=t,this.buffer=e,this.index=i,this.start=n}}class Ro{constructor(t,e,i){this.context=t,this._parent=e,this.index=i,this.type=t.buffer.set.types[t.buffer.buffer[i]]}get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}child(t,e,i){let{buffer:n}=this.context,s=n.findChild(this.index+4,n.buffer[this.index+3],t,e-this.context.start,i);return s<0?null:new Ro(this.context,this,s)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(t){return this.child(1,t,2)}childBefore(t){return this.child(-1,t,-2)}enter(t,e,i=0){if(i&bo.ExcludeBuffers)return null;let{buffer:n}=this.context,s=n.findChild(this.index+4,n.buffer[this.index+3],e>0?1:-1,t-this.context.start,e);return s<0?null:new Ro(this.context,this,s)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(t){return this._parent?null:this.context.parent.nextChild(this.context.index+t,t,0,4)}get nextSibling(){let{buffer:t}=this.context,e=t.buffer[this.index+3];return e<(this._parent?t.buffer[this._parent.index+3]:t.buffer.length)?new Ro(this.context,this._parent,e):this.externalSibling(1)}get prevSibling(){let{buffer:t}=this.context,e=this._parent?this._parent.index+4:0;return this.index==e?this.externalSibling(-1):new Ro(this.context,this._parent,t.findChild(e,this.index,-1,0,4))}cursor(t=0){return new Eo(this,t)}get tree(){return null}toTree(){let t=[],e=[],{buffer:i}=this.context,n=this.index+4,s=i.buffer[this.index+3];if(s>n){let r=i.buffer[this.index+1],o=i.buffer[this.index+2];t.push(i.slice(n,s,r,o)),e.push(0)}return new xo(this.type,t,e,this.to-this.from)}resolve(t,e=0){return Oo(this,t,e,!1)}resolveInner(t,e=0){return Oo(this,t,e,!0)}enterUnfinishedNodesBefore(t){return Ao(this,t)}toString(){return this.context.buffer.childString(this.index)}getChild(t,e=null,i=null){let n=Do(this,t,e,i);return n.length?n[0]:null}getChildren(t,e=null,i=null){return Do(this,t,e,i)}get node(){return this}matchContext(t){return To(this,t)}}class Eo{constructor(t,e=0){if(this.mode=e,this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,t instanceof Mo)this.yieldNode(t);else{this._tree=t.context.parent,this.buffer=t.context;for(let e=t._parent;e;e=e._parent)this.stack.unshift(e.index);this.bufferNode=t,this.yieldBuf(t.index)}}get name(){return this.type.name}yieldNode(t){return!!t&&(this._tree=t,this.type=t.type,this.from=t.from,this.to=t.to,!0)}yieldBuf(t,e){this.index=t;let{start:i,buffer:n}=this.buffer;return this.type=e||n.set.types[n.buffer[t]],this.from=i+n.buffer[t+1],this.to=i+n.buffer[t+2],!0}yield(t){return!!t&&(t instanceof Mo?(this.buffer=null,this.yieldNode(t)):(this.buffer=t.context,this.yieldBuf(t.index,t.type)))}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(t,e,i){if(!this.buffer)return this.yield(this._tree.nextChild(t<0?this._tree._tree.children.length-1:0,t,e,i,this.mode));let{buffer:n}=this.buffer,s=n.findChild(this.index+4,n.buffer[this.index+3],t,e-this.buffer.start,i);return!(s<0)&&(this.stack.push(this.index),this.yieldBuf(s))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(t){return this.enterChild(1,t,2)}childBefore(t){return this.enterChild(-1,t,-2)}enter(t,e,i=this.mode){return this.buffer?!(i&bo.ExcludeBuffers)&&this.enterChild(1,t,e):this.yield(this._tree.enter(t,e,i))}parent(){if(!this.buffer)return this.yieldNode(this.mode&bo.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let t=this.mode&bo.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(t)}sibling(t){if(!this.buffer)return!!this._tree._parent&&this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+t,t,0,4,this.mode));let{buffer:e}=this.buffer,i=this.stack.length-1;if(t<0){let t=i<0?0:this.stack[i]+4;if(this.index!=t)return this.yieldBuf(e.findChild(t,this.index,-1,0,4))}else{let t=e.buffer[this.index+3];if(t<(i<0?e.buffer.length:e.buffer[this.stack[i]+3]))return this.yieldBuf(t)}return i<0&&this.yield(this.buffer.parent.nextChild(this.buffer.index+t,t,0,4,this.mode))}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(t){let e,i,{buffer:n}=this;if(n){if(t>0){if(this.index<n.buffer.buffer.length)return!1}else for(let t=0;t<this.index;t++)if(n.buffer.buffer[t+3]<this.index)return!1;({index:e,parent:i}=n)}else({index:e,_parent:i}=this._tree);for(;i;({index:e,_parent:i}=i))if(e>-1)for(let n=e+t,s=t<0?-1:i._tree.children.length;n!=s;n+=t){let t=i._tree.children[n];if(this.mode&bo.IncludeAnonymous||t instanceof So||!t.type.isAnonymous||Bo(t))return!1}return!0}move(t,e){if(e&&this.enterChild(t,0,4))return!0;for(;;){if(this.sibling(t))return!0;if(this.atLastNode(t)||!this.parent())return!1}}next(t=!0){return this.move(1,t)}prev(t=!0){return this.move(-1,t)}moveTo(t,e=0){for(;(this.from==this.to||(e<1?this.from>=t:this.from>t)||(e>-1?this.to<=t:this.to<t))&&this.parent(););for(;this.enterChild(1,t,e););return this}get node(){if(!this.buffer)return this._tree;let t=this.bufferNode,e=null,i=0;if(t&&t.context==this.buffer)t:for(let n=this.index,s=this.stack.length;s>=0;){for(let r=t;r;r=r._parent)if(r.index==n){if(n==this.index)return r;e=r,i=s+1;break t}n=this.stack[--s]}for(let t=i;t<this.stack.length;t++)e=new Ro(this.buffer,e,this.stack[t]);return this.bufferNode=new Ro(this.buffer,e,this.index)}get tree(){return this.buffer?null:this._tree._tree}iterate(t,e){for(let i=0;;){let n=!1;if(this.type.isAnonymous||!1!==t(this)){if(this.firstChild()){i++;continue}this.type.isAnonymous||(n=!0)}for(;n&&e&&e(this),n=this.type.isAnonymous,!this.nextSibling();){if(!i)return;this.parent(),i--,n=!0}}}matchContext(t){if(!this.buffer)return To(this.node,t);let{buffer:e}=this.buffer,{types:i}=e.set;for(let n=t.length-1,s=this.stack.length-1;n>=0;s--){if(s<0)return To(this.node,t,n);let r=i[e.buffer[this.stack[s]]];if(!r.isAnonymous){if(t[n]&&t[n]!=r.name)return!1;n--}}return!0}}function Bo(t){return t.children.some((t=>t instanceof So||!t.type.isAnonymous||Bo(t)))}const Lo=new WeakMap;function No(t,e){if(!t.isAnonymous||e instanceof So||e.type!=t)return 1;let i=Lo.get(e);if(null==i){i=1;for(let n of e.children){if(n.type!=t||!(n instanceof xo)){i=1;break}i+=No(t,n)}Lo.set(e,i)}return i}function Io(t,e,i,n,s,r,o,l,a){let h=0;for(let i=n;i<s;i++)h+=No(t,e[i]);let c=Math.ceil(1.5*h/8),u=[],f=[];return function e(i,n,s,o,l){for(let h=s;h<o;){let s=h,d=n[h],p=No(t,i[h]);for(h++;h<o;h++){let e=No(t,i[h]);if(p+e>=c)break;p+=e}if(h==s+1){if(p>c){let t=i[s];e(t.children,t.positions,0,t.children.length,n[s]+l);continue}u.push(i[s])}else{let e=n[h-1]+i[h-1].length-d;u.push(Io(t,i,n,s,h,d,e,null,a))}f.push(d+l-r)}}(e,i,n,s,0),(l||a)(u,f,o)}class Vo{constructor(t,e,i,n,s=!1,r=!1){this.from=t,this.to=e,this.tree=i,this.offset=n,this.open=(s?1:0)|(r?2:0)}get openStart(){return(1&this.open)>0}get openEnd(){return(2&this.open)>0}static addTree(t,e=[],i=!1){let n=[new Vo(0,t.length,t,0,!1,i)];for(let i of e)i.to>t.length&&n.push(i);return n}static applyChanges(t,e,i=128){if(!e.length)return t;let n=[],s=1,r=t.length?t[0]:null;for(let o=0,l=0,a=0;;o++){let h=o<e.length?e[o]:null,c=h?h.fromA:1e9;if(c-l>=i)for(;r&&r.from<c;){let e=r;if(l>=e.from||c<=e.to||a){let t=Math.max(e.from,l)-a,i=Math.min(e.to,c)-a;e=t>=i?null:new Vo(t,i,e.tree,e.offset+a,o>0,!!h)}if(e&&n.push(e),r.to>c)break;r=s<t.length?t[s++]:null}if(!h)break;l=h.toA,a=h.toA-h.toB}return n}}class Wo{startParse(t,e,i){return\"string\"==typeof t&&(t=new zo(t)),i=i?i.length?i.map((t=>new fo(t.from,t.to))):[new fo(0,0)]:[new fo(0,t.length)],this.createParse(t,e||[],i)}parse(t,e,i){let n=this.startParse(t,e,i);for(;;){let t=n.advance();if(t)return t}}}class zo{constructor(t){this.string=t}get length(){return this.string.length}chunk(t){return this.string.slice(t)}get lineChunks(){return!1}read(t,e){return this.string.slice(t,e)}}new po({perNode:!0});let Ho=0;class Fo{constructor(t,e,i){this.set=t,this.base=e,this.modified=i,this.id=Ho++}static define(t){if(null==t?void 0:t.base)throw new Error(\"Can not derive from a modified tag\");let e=new Fo([],null,[]);if(e.set.push(e),t)for(let i of t.set)e.set.push(i);return e}static defineModifier(){let t=new _o;return e=>e.modified.indexOf(t)>-1?e:_o.get(e.base||e,e.modified.concat(t).sort(((t,e)=>t.id-e.id)))}}let qo=0;class _o{constructor(){this.instances=[],this.id=qo++}static get(t,e){if(!e.length)return t;let i=e[0].instances.find((i=>{return i.base==t&&(n=e,s=i.modified,n.length==s.length&&n.every(((t,e)=>t==s[e])));var n,s}));if(i)return i;let n=[],s=new Fo(n,t,e);for(let t of e)t.instances.push(s);let r=function(t){let e=[[]];for(let i=0;i<t.length;i++)for(let n=0,s=e.length;n<s;n++)e.push(e[n].concat(t[i]));return e.sort(((t,e)=>e.length-t.length))}(e);for(let e of t.set)if(!e.modified.length)for(let t of r)n.push(_o.get(e,t));return s}}function jo(t){let e=Object.create(null);for(let i in t){let n=t[i];Array.isArray(n)||(n=[n]);for(let t of i.split(\" \"))if(t){let i=[],s=2,r=t;for(let e=0;;){if(\"...\"==r&&e>0&&e+3==t.length){s=1;break}let n=/^\"(?:[^\"\\\\]|\\\\.)*?\"|[^\\/!]+/.exec(r);if(!n)throw new RangeError(\"Invalid path: \"+t);if(i.push(\"*\"==n[0]?\"\":'\"'==n[0][0]?JSON.parse(n[0]):n[0]),e+=n[0].length,e==t.length)break;let o=t[e++];if(e==t.length&&\"!\"==o){s=0;break}if(\"/\"!=o)throw new RangeError(\"Invalid path: \"+t);r=t.slice(e)}let o=i.length-1,l=i[o];if(!l)throw new RangeError(\"Invalid path: \"+t);let a=new $o(n,s,o>0?i.slice(0,o):null);e[l]=a.sort(e[l])}}return Uo.add(e)}const Uo=new po;class $o{constructor(t,e,i,n){this.tags=t,this.mode=e,this.context=i,this.next=n}get opaque(){return 0==this.mode}get inherit(){return 1==this.mode}sort(t){return!t||t.depth<this.depth?(this.next=t,this):(t.next=this.sort(t.next),t)}get depth(){return this.context?this.context.length:0}}function Qo(t,e){let i=Object.create(null);for(let e of t)if(Array.isArray(e.tag))for(let t of e.tag)i[t.id]=e.class;else i[e.tag.id]=e.class;let{scope:n,all:s=null}=e||{};return{style:t=>{let e=s;for(let n of t)for(let t of n.set){let n=i[t.id];if(n){e=e?e+\" \"+n:n;break}}return e},scope:n}}function Ko(t,e,i,n=0,s=t.length){let r=new Go(n,Array.isArray(e)?e:[e],i);r.highlightRange(t.cursor(),n,s,\"\",r.highlighters),r.flush(s)}$o.empty=new $o([],2,null);class Go{constructor(t,e,i){this.at=t,this.highlighters=e,this.span=i,this.class=\"\"}startSpan(t,e){e!=this.class&&(this.flush(t),t>this.at&&(this.at=t),this.class=e)}flush(t){t>this.at&&this.class&&this.span(this.at,t,this.class)}highlightRange(t,e,i,n,s){let{type:r,from:o,to:l}=t;if(o>=i||l<=e)return;r.isTop&&(s=this.highlighters.filter((t=>!t.scope||t.scope(r))));let a=n,h=function(t){let e=t.type.prop(Uo);for(;e&&e.context&&!t.matchContext(e.context);)e=e.next;return e||null}(t)||$o.empty,c=function(t,e){let i=null;for(let n of t){let t=n.style(e);t&&(i=i?i+\" \"+t:t)}return i}(s,h.tags);if(c&&(a&&(a+=\" \"),a+=c,1==h.mode&&(n+=(n?\" \":\"\")+c)),this.startSpan(t.from,a),h.opaque)return;let u=t.tree&&t.tree.prop(po.mounted);if(u&&u.overlay){let r=t.node.enter(u.overlay[0].from+o,1),h=this.highlighters.filter((t=>!t.scope||t.scope(u.tree.type))),c=t.firstChild();for(let f=0,d=o;;f++){let p=f<u.overlay.length?u.overlay[f]:null,m=p?p.from+o:l,g=Math.max(e,d),v=Math.min(i,m);if(g<v&&c)for(;t.from<v&&(this.highlightRange(t,g,v,n,s),this.startSpan(Math.min(i,t.to),a),!(t.to>=m)&&t.nextSibling()););if(!p||m>i)break;d=p.to+o,d>e&&(this.highlightRange(r.cursor(),Math.max(e,p.from+o),Math.min(i,d),n,h),this.startSpan(d,a))}c&&t.parent()}else if(t.firstChild()){do{if(!(t.to<=e)){if(t.from>=i)break;this.highlightRange(t,e,i,n,s),this.startSpan(Math.min(i,t.to),a)}}while(t.nextSibling());t.parent()}}}const Jo=Fo.define,Xo=Jo(),Zo=Jo(),Yo=Jo(Zo),tl=Jo(Zo),el=Jo(),il=Jo(el),nl=Jo(el),sl=Jo(),rl=Jo(sl),ol=Jo(),ll=Jo(),al=Jo(),hl=Jo(al),cl=Jo(),ul={comment:Xo,lineComment:Jo(Xo),blockComment:Jo(Xo),docComment:Jo(Xo),name:Zo,variableName:Jo(Zo),typeName:Yo,tagName:Jo(Yo),propertyName:tl,attributeName:Jo(tl),className:Jo(Zo),labelName:Jo(Zo),namespace:Jo(Zo),macroName:Jo(Zo),literal:el,string:il,docString:Jo(il),character:Jo(il),attributeValue:Jo(il),number:nl,integer:Jo(nl),float:Jo(nl),bool:Jo(el),regexp:Jo(el),escape:Jo(el),color:Jo(el),url:Jo(el),keyword:ol,self:Jo(ol),null:Jo(ol),atom:Jo(ol),unit:Jo(ol),modifier:Jo(ol),operatorKeyword:Jo(ol),controlKeyword:Jo(ol),definitionKeyword:Jo(ol),moduleKeyword:Jo(ol),operator:ll,derefOperator:Jo(ll),arithmeticOperator:Jo(ll),logicOperator:Jo(ll),bitwiseOperator:Jo(ll),compareOperator:Jo(ll),updateOperator:Jo(ll),definitionOperator:Jo(ll),typeOperator:Jo(ll),controlOperator:Jo(ll),punctuation:al,separator:Jo(al),bracket:hl,angleBracket:Jo(hl),squareBracket:Jo(hl),paren:Jo(hl),brace:Jo(hl),content:sl,heading:rl,heading1:Jo(rl),heading2:Jo(rl),heading3:Jo(rl),heading4:Jo(rl),heading5:Jo(rl),heading6:Jo(rl),contentSeparator:Jo(sl),list:Jo(sl),quote:Jo(sl),emphasis:Jo(sl),strong:Jo(sl),link:Jo(sl),monospace:Jo(sl),strikethrough:Jo(sl),inserted:Jo(),deleted:Jo(),changed:Jo(),invalid:Jo(),meta:cl,documentMeta:Jo(cl),annotation:Jo(cl),processingInstruction:Jo(cl),definition:Fo.defineModifier(),constant:Fo.defineModifier(),function:Fo.defineModifier(),standard:Fo.defineModifier(),local:Fo.defineModifier(),special:Fo.defineModifier()};var fl;Qo([{tag:ul.link,class:\"tok-link\"},{tag:ul.heading,class:\"tok-heading\"},{tag:ul.emphasis,class:\"tok-emphasis\"},{tag:ul.strong,class:\"tok-strong\"},{tag:ul.keyword,class:\"tok-keyword\"},{tag:ul.atom,class:\"tok-atom\"},{tag:ul.bool,class:\"tok-bool\"},{tag:ul.url,class:\"tok-url\"},{tag:ul.labelName,class:\"tok-labelName\"},{tag:ul.inserted,class:\"tok-inserted\"},{tag:ul.deleted,class:\"tok-deleted\"},{tag:ul.literal,class:\"tok-literal\"},{tag:ul.string,class:\"tok-string\"},{tag:ul.number,class:\"tok-number\"},{tag:[ul.regexp,ul.escape,ul.special(ul.string)],class:\"tok-string2\"},{tag:ul.variableName,class:\"tok-variableName\"},{tag:ul.local(ul.variableName),class:\"tok-variableName tok-local\"},{tag:ul.definition(ul.variableName),class:\"tok-variableName tok-definition\"},{tag:ul.special(ul.variableName),class:\"tok-variableName2\"},{tag:ul.definition(ul.propertyName),class:\"tok-propertyName tok-definition\"},{tag:ul.typeName,class:\"tok-typeName\"},{tag:ul.namespace,class:\"tok-namespace\"},{tag:ul.className,class:\"tok-className\"},{tag:ul.macroName,class:\"tok-macroName\"},{tag:ul.propertyName,class:\"tok-propertyName\"},{tag:ul.operator,class:\"tok-operator\"},{tag:ul.comment,class:\"tok-comment\"},{tag:ul.meta,class:\"tok-meta\"},{tag:ul.invalid,class:\"tok-invalid\"},{tag:ul.punctuation,class:\"tok-punctuation\"}]);const dl=new po;class pl{constructor(t,e,i=[],n=\"\"){this.data=t,this.name=n,St.prototype.hasOwnProperty(\"tree\")||Object.defineProperty(St.prototype,\"tree\",{get(){return vl(this)}}),this.parser=e,this.extension=[Ol.of(this),St.languageData.of(((t,e,i)=>t.facet(ml(t,e,i))))].concat(i)}isActiveAt(t,e,i=-1){return ml(t,e,i)==this.data}findRegions(t){let e=t.facet(Ol);if((null==e?void 0:e.data)==this.data)return[{from:0,to:t.doc.length}];if(!e||!e.allowsNesting)return[];let i=[],n=(t,e)=>{if(t.prop(dl)==this.data)return void i.push({from:e,to:e+t.length});let s=t.prop(po.mounted);if(s){if(s.tree.prop(dl)==this.data){if(s.overlay)for(let t of s.overlay)i.push({from:t.from+e,to:t.to+e});else i.push({from:e,to:e+t.length});return}if(s.overlay){let t=i.length;if(n(s.tree,s.overlay[0].from+e),i.length>t)return}}for(let i=0;i<t.children.length;i++){let s=t.children[i];s instanceof xo&&n(s,t.positions[i]+e)}};return n(vl(t),0),i}get allowsNesting(){return!0}}function ml(t,e,i){let n=t.facet(Ol);if(!n)return null;let s=n.data;if(n.allowsNesting)for(let n=vl(t).topNode;n;n=n.enter(e,i,bo.ExcludeBuffers))s=n.type.prop(dl)||s;return s}pl.setState=ut.define();class gl extends pl{constructor(t,e,i){super(t,e,[],i),this.parser=e}static define(t){let e=(i=t.languageData,N.define({combine:i?t=>t.concat(i):void 0}));var i;return new gl(e,t.parser.configure({props:[dl.add((t=>t.isTop?e:void 0))]}),t.name)}configure(t,e){return new gl(this.data,this.parser.configure(t),e||this.name)}get allowsNesting(){return this.parser.hasWrappers()}}function vl(t){let e=t.field(pl.state,!1);return e?e.tree:xo.empty}class wl{constructor(t,e=t.length){this.doc=t,this.length=e,this.cursorPos=0,this.string=\"\",this.cursor=t.iter()}syncTo(t){return this.string=this.cursor.next(t-this.cursorPos).value,this.cursorPos=t+this.string.length,this.cursorPos-this.string.length}chunk(t){return this.syncTo(t),this.string}get lineChunks(){return!0}read(t,e){let i=this.cursorPos-this.string.length;return t<i||e>=this.cursorPos?this.doc.sliceString(t,e):this.string.slice(t-i,e-i)}}let yl=null;class bl{constructor(t,e,i=[],n,s,r,o,l){this.parser=t,this.state=e,this.fragments=i,this.tree=n,this.treeLen=s,this.viewport=r,this.skipped=o,this.scheduleOn=l,this.parse=null,this.tempSkipped=[]}static create(t,e,i){return new bl(t,e,[],xo.empty,0,i,[],null)}startParse(){return this.parser.startParse(new wl(this.state.doc),this.fragments)}work(t,e){return null!=e&&e>=this.state.doc.length&&(e=void 0),this.tree!=xo.empty&&this.isDone(null!=e?e:this.state.doc.length)?(this.takeTree(),!0):this.withContext((()=>{var i;if(\"number\"==typeof t){let e=Date.now()+t;t=()=>Date.now()>e}for(this.parse||(this.parse=this.startParse()),null!=e&&(null==this.parse.stoppedAt||this.parse.stoppedAt>e)&&e<this.state.doc.length&&this.parse.stopAt(e);;){let n=this.parse.advance();if(n){if(this.fragments=this.withoutTempSkipped(Vo.addTree(n,this.fragments,null!=this.parse.stoppedAt)),this.treeLen=null!==(i=this.parse.stoppedAt)&&void 0!==i?i:this.state.doc.length,this.tree=n,this.parse=null,!(this.treeLen<(null!=e?e:this.state.doc.length)))return!0;this.parse=this.startParse()}if(t())return!1}}))}takeTree(){let t,e;this.parse&&(t=this.parse.parsedPos)>=this.treeLen&&((null==this.parse.stoppedAt||this.parse.stoppedAt>t)&&this.parse.stopAt(t),this.withContext((()=>{for(;!(e=this.parse.advance()););})),this.treeLen=t,this.tree=e,this.fragments=this.withoutTempSkipped(Vo.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(t){let e=yl;yl=this;try{return t()}finally{yl=e}}withoutTempSkipped(t){for(let e;e=this.tempSkipped.pop();)t=xl(t,e.from,e.to);return t}changes(t,e){let{fragments:i,tree:n,treeLen:s,viewport:r,skipped:o}=this;if(this.takeTree(),!t.empty){let e=[];if(t.iterChangedRanges(((t,i,n,s)=>e.push({fromA:t,toA:i,fromB:n,toB:s}))),i=Vo.applyChanges(i,e),n=xo.empty,s=0,r={from:t.mapPos(r.from,-1),to:t.mapPos(r.to,1)},this.skipped.length){o=[];for(let e of this.skipped){let i=t.mapPos(e.from,1),n=t.mapPos(e.to,-1);i<n&&o.push({from:i,to:n})}}}return new bl(this.parser,e,i,n,s,r,o,this.scheduleOn)}updateViewport(t){if(this.viewport.from==t.from&&this.viewport.to==t.to)return!1;this.viewport=t;let e=this.skipped.length;for(let e=0;e<this.skipped.length;e++){let{from:i,to:n}=this.skipped[e];i<t.to&&n>t.from&&(this.fragments=xl(this.fragments,i,n),this.skipped.splice(e--,1))}return!(this.skipped.length>=e)&&(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(t,e){this.skipped.push({from:t,to:e})}static getSkippingParser(t){return new class extends Wo{createParse(e,i,n){let s=n[0].from,r=n[n.length-1].to;return{parsedPos:s,advance(){let e=yl;if(e){for(let t of n)e.tempSkipped.push(t);t&&(e.scheduleOn=e.scheduleOn?Promise.all([e.scheduleOn,t]):t)}return this.parsedPos=r,new xo(go.none,[],[],r-s)},stoppedAt:null,stopAt(){}}}}}isDone(t){t=Math.min(t,this.state.doc.length);let e=this.fragments;return this.treeLen>=t&&e.length&&0==e[0].from&&e[0].to>=t}static get(){return yl}}function xl(t,e,i){return Vo.applyChanges(t,[{fromA:e,toA:i,fromB:e,toB:i}])}class kl{constructor(t){this.context=t,this.tree=t.tree}apply(t){if(!t.docChanged&&this.tree==this.context.tree)return this;let e=this.context.changes(t.changes,t.state),i=this.context.treeLen==t.startState.doc.length?void 0:Math.max(t.changes.mapPos(this.context.treeLen),e.viewport.to);return e.work(20,i)||e.takeTree(),new kl(e)}static init(t){let e=Math.min(3e3,t.doc.length),i=bl.create(t.facet(Ol).parser,t,{from:0,to:e});return i.work(20,e)||i.takeTree(),new kl(i)}}pl.state=q.define({create:kl.init,update(t,e){for(let t of e.effects)if(t.is(pl.setState))return t.value;return e.startState.facet(Ol)!=e.state.facet(Ol)?kl.init(e.state):t.apply(e)}});let Sl=t=>{let e=setTimeout((()=>t()),500);return()=>clearTimeout(e)};\"undefined\"!=typeof requestIdleCallback&&(Sl=t=>{let e=-1,i=setTimeout((()=>{e=requestIdleCallback(t,{timeout:400})}),100);return()=>e<0?clearTimeout(i):cancelIdleCallback(e)});const Cl=\"undefined\"!=typeof navigator&&(null===(fl=navigator.scheduling)||void 0===fl?void 0:fl.isInputPending)?()=>navigator.scheduling.isInputPending():null,Al=Mi.fromClass(class{constructor(t){this.view=t,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(t){let e=this.view.state.field(pl.state).context;(e.updateViewport(t.view.viewport)||this.view.viewport.to>e.treeLen)&&this.scheduleWork(),t.docChanged&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(e)}scheduleWork(){if(this.working)return;let{state:t}=this.view,e=t.field(pl.state);e.tree==e.context.tree&&e.context.isDone(t.doc.length)||(this.working=Sl(this.work))}work(t){this.working=null;let e=Date.now();if(this.chunkEnd<e&&(this.chunkEnd<0||this.view.hasFocus)&&(this.chunkEnd=e+3e4,this.chunkBudget=3e3),this.chunkBudget<=0)return;let{state:i,viewport:{to:n}}=this.view,s=i.field(pl.state);if(s.tree==s.context.tree&&s.context.isDone(n+1e5))return;let r=Date.now()+Math.min(this.chunkBudget,100,t&&!Cl?Math.max(25,t.timeRemaining()-5):1e9),o=s.context.treeLen<n&&i.doc.length>n+1e3,l=s.context.work((()=>Cl&&Cl()||Date.now()>r),n+(o?0:1e5));this.chunkBudget-=Date.now()-e,(l||this.chunkBudget<=0)&&(s.context.takeTree(),this.view.dispatch({effects:pl.setState.of(new kl(s.context))})),this.chunkBudget>0&&(!l||o)&&this.scheduleWork(),this.checkAsyncSchedule(s.context)}checkAsyncSchedule(t){t.scheduleOn&&(this.workScheduled++,t.scheduleOn.then((()=>this.scheduleWork())).catch((t=>Si(this.view.state,t))).then((()=>this.workScheduled--)),t.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),Ol=N.define({combine:t=>t.length?t[0]:null,enables:t=>[pl.state,Al,Ds.contentAttributes.compute([t],(e=>{let i=e.facet(t);return i&&i.name?{\"data-language\":i.name}:{}}))]});class Ml{constructor(t,e=[]){this.language=t,this.support=e,this.extension=[t,e]}}const Dl=N.define(),Tl=N.define({combine:t=>{if(!t.length)return\"  \";if(!/^(?: +|\\t+)$/.test(t[0]))throw new Error(\"Invalid indent unit: \"+JSON.stringify(t[0]));return t[0]}});function Pl(t){let e=t.facet(Tl);return 9==e.charCodeAt(0)?t.tabSize*e.length:e.length}function Rl(t,e){let i=\"\",n=t.tabSize;if(9==t.facet(Tl).charCodeAt(0))for(;e>=n;)i+=\"\\t\",e-=n;for(let t=0;t<e;t++)i+=\" \";return i}function El(t,e){t instanceof St&&(t=new Bl(t));for(let i of t.state.facet(Dl)){let n=i(t,e);if(void 0!==n)return n}let i=vl(t.state);return i?function(t,e,i){return Il(e.resolveInner(i).enterUnfinishedNodesBefore(i),i,t)}(t,i,e):null}class Bl{constructor(t,e={}){this.state=t,this.options=e,this.unit=Pl(t)}lineAt(t,e=1){let i=this.state.doc.lineAt(t),{simulateBreak:n,simulateDoubleBreak:s}=this.options;return null!=n&&n>=i.from&&n<=i.to?s&&n==t?{text:\"\",from:t}:(e<0?n<t:n<=t)?{text:i.text.slice(n-i.from),from:n}:{text:i.text.slice(0,n-i.from),from:i.from}:i}textAfterPos(t,e=1){if(this.options.simulateDoubleBreak&&t==this.options.simulateBreak)return\"\";let{text:i,from:n}=this.lineAt(t,e);return i.slice(t-n,Math.min(i.length,t+100-n))}column(t,e=1){let{text:i,from:n}=this.lineAt(t,e),s=this.countColumn(i,t-n),r=this.options.overrideIndentation?this.options.overrideIndentation(n):-1;return r>-1&&(s+=r-this.countColumn(i,i.search(/\\S|$/))),s}countColumn(t,e=t.length){return Ft(t,this.state.tabSize,e)}lineIndent(t,e=1){let{text:i,from:n}=this.lineAt(t,e),s=this.options.overrideIndentation;if(s){let t=s(n);if(t>-1)return t}return this.countColumn(i,i.search(/\\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}}const Ll=new po;function Nl(t){let e=t.type.prop(Ll);if(e)return e;let i,n=t.firstChild;if(n&&(i=n.type.prop(po.closedBy))){let e=t.lastChild,n=e&&i.indexOf(e.name)>-1;return t=>function(t,e,i,n,s){let r=t.textAfter,o=r.match(/^\\s*/)[0].length,l=n&&r.slice(o,o+n.length)==n||s==t.pos+o,a=e?function(t){let e=t.node,i=e.childAfter(e.from),n=e.lastChild;if(!i)return null;let s=t.options.simulateBreak,r=t.state.doc.lineAt(i.from),o=null==s||s<=r.from?r.to:Math.min(r.to,s);for(let t=i.to;;){let s=e.childAfter(t);if(!s||s==n)return null;if(!s.type.isSkipped)return s.from<o?i:null;t=s.to}}(t):null;return a?l?t.column(a.from):t.column(a.to):t.baseIndent+(l?0:t.unit*i)}(t,!0,1,void 0,n&&!function(t){return t.pos==t.options.simulateBreak&&t.options.simulateDoubleBreak}(t)?e.from:void 0)}return null==t.parent?Vl:null}function Il(t,e,i){for(;t;t=t.parent){let n=Nl(t);if(n)return n(Wl.create(i,e,t))}return null}function Vl(){return 0}class Wl extends Bl{constructor(t,e,i){super(t.state,t.options),this.base=t,this.pos=e,this.node=i}static create(t,e,i){return new Wl(t,e,i)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){let t=this.state.doc.lineAt(this.node.from);for(;;){let e=this.node.resolve(t.from);for(;e.parent&&e.parent.from==e.from;)e=e.parent;if(zl(e,this.node))break;t=this.state.doc.lineAt(e.from)}return this.lineIndent(t.from)}continue(){let t=this.node.parent;return t?Il(t,this.pos,this.base):0}}function zl(t,e){for(let i=e;i;i=i.parent)if(t==i)return!0;return!1}function Hl({except:t,units:e=1}={}){return i=>{let n=t&&t.test(i.textAfter);return i.baseIndent+(n?0:e*i.unit)}}const Fl=N.define(),ql=new po;function _l(t){let e=t.lastChild;return e&&e.to==t.to&&e.type.isError}function jl(t,e,i){for(let n of t.facet(Fl)){let s=n(t,e,i);if(s)return s}return function(t,e,i){let n=vl(t);if(n.length<i)return null;let s=null;for(let r=n.resolveInner(i,1);r;r=r.parent){if(r.to<=i||r.from>i)continue;if(s&&r.from<e)break;let o=r.type.prop(ql);if(o&&(r.to<n.length-50||n.length==t.doc.length||!_l(r))){let n=o(r,t);n&&n.from<=i&&n.from>=e&&n.to>i&&(s=n)}}return s}(t,e,i)}function Ul(t,e){let i=e.mapPos(t.from,1),n=e.mapPos(t.to,-1);return i>=n?void 0:{from:i,to:n}}const $l=ut.define({map:Ul}),Ql=ut.define({map:Ul});function Kl(t){let e=[];for(let{head:i}of t.state.selection.ranges)e.some((t=>t.from<=i&&t.to>=i))||e.push(t.lineBlockAt(i));return e}const Gl=q.define({create:()=>ii.none,update(t,e){t=t.map(e.changes);for(let i of e.effects)i.is($l)&&!Xl(t,i.value.from,i.value.to)?t=t.update({add:[sa.range(i.value.from,i.value.to)]}):i.is(Ql)&&(t=t.update({filter:(t,e)=>i.value.from!=t||i.value.to!=e,filterFrom:i.value.from,filterTo:i.value.to}));if(e.selection){let i=!1,{head:n}=e.selection.main;t.between(n,n,((t,e)=>{t<n&&e>n&&(i=!0)})),i&&(t=t.update({filterFrom:n,filterTo:n,filter:(t,e)=>e<=n||t>=n}))}return t},provide:t=>Ds.decorations.from(t),toJSON(t,e){let i=[];return t.between(0,e.doc.length,((t,e)=>{i.push(t,e)})),i},fromJSON(t){if(!Array.isArray(t)||t.length%2)throw new RangeError(\"Invalid JSON for fold state\");let e=[];for(let i=0;i<t.length;){let n=t[i++],s=t[i++];if(\"number\"!=typeof n||\"number\"!=typeof s)throw new RangeError(\"Invalid JSON for fold state\");e.push(sa.range(n,s))}return ii.set(e,!0)}});function Jl(t,e,i){var n;let s=null;return null===(n=t.field(Gl,!1))||void 0===n||n.between(e,i,((t,e)=>{(!s||s.from>t)&&(s={from:t,to:e})})),s}function Xl(t,e,i){let n=!1;return t.between(e,e,((t,s)=>{t==e&&s==i&&(n=!0)})),n}function Zl(t,e){return t.field(Gl,!1)?e:e.concat(ut.appendConfig.of(na()))}function Yl(t,e,i=!0){let n=t.state.doc.lineAt(e.from).number,s=t.state.doc.lineAt(e.to).number;return Ds.announce.of(`${t.state.phrase(i?\"Folded lines\":\"Unfolded lines\")} ${n} ${t.state.phrase(\"to\")} ${s}.`)}const ta=[{key:\"Ctrl-Shift-[\",mac:\"Cmd-Alt-[\",run:t=>{for(let e of Kl(t)){let i=jl(t.state,e.from,e.to);if(i)return t.dispatch({effects:Zl(t.state,[$l.of(i),Yl(t,i)])}),!0}return!1}},{key:\"Ctrl-Shift-]\",mac:\"Cmd-Alt-]\",run:t=>{if(!t.state.field(Gl,!1))return!1;let e=[];for(let i of Kl(t)){let n=Jl(t.state,i.from,i.to);n&&e.push(Ql.of(n),Yl(t,n,!1))}return e.length&&t.dispatch({effects:e}),e.length>0}},{key:\"Ctrl-Alt-[\",run:t=>{let{state:e}=t,i=[];for(let n=0;n<e.doc.length;){let s=t.lineBlockAt(n),r=jl(e,s.from,s.to);r&&i.push($l.of(r)),n=(r?t.lineBlockAt(r.to):s).to+1}return i.length&&t.dispatch({effects:Zl(t.state,i)}),!!i.length}},{key:\"Ctrl-Alt-]\",run:t=>{let e=t.state.field(Gl,!1);if(!e||!e.size)return!1;let i=[];return e.between(0,t.state.doc.length,((t,e)=>{i.push(Ql.of({from:t,to:e}))})),t.dispatch({effects:i}),!0}}],ea={placeholderDOM:null,placeholderText:\"…\"},ia=N.define({combine:t=>Ct(t,ea)});function na(t){let e=[Gl,aa];return t&&e.push(ia.of(t)),e}const sa=ii.replace({widget:new class extends ti{toDOM(t){let{state:e}=t,i=e.facet(ia),n=e=>{let i=t.lineBlockAt(t.posAtDOM(e.target)),n=Jl(t.state,i.from,i.to);n&&t.dispatch({effects:Ql.of(n)}),e.preventDefault()};if(i.placeholderDOM)return i.placeholderDOM(t,n);let s=document.createElement(\"span\");return s.textContent=i.placeholderText,s.setAttribute(\"aria-label\",e.phrase(\"folded code\")),s.title=e.phrase(\"unfold\"),s.className=\"cm-foldPlaceholder\",s.onclick=n,s}}}),ra={openText:\"⌄\",closedText:\"›\",markerDOM:null,domEventHandlers:{},foldingChanged:()=>!1};class oa extends qr{constructor(t,e){super(),this.config=t,this.open=e}eq(t){return this.config==t.config&&this.open==t.open}toDOM(t){if(this.config.markerDOM)return this.config.markerDOM(this.open);let e=document.createElement(\"span\");return e.textContent=this.open?this.config.openText:this.config.closedText,e.title=t.state.phrase(this.open?\"Fold line\":\"Unfold line\"),e}}function la(t={}){let e=Object.assign(Object.assign({},ra),t),i=new oa(e,!0),n=new oa(e,!1),s=Mi.fromClass(class{constructor(t){this.from=t.viewport.from,this.markers=this.buildMarkers(t)}update(t){(t.docChanged||t.viewportChanged||t.startState.facet(Ol)!=t.state.facet(Ol)||t.startState.field(Gl,!1)!=t.state.field(Gl,!1)||vl(t.startState)!=vl(t.state)||e.foldingChanged(t))&&(this.markers=this.buildMarkers(t.view))}buildMarkers(t){let e=new Pt;for(let s of t.viewportLineBlocks){let r=Jl(t.state,s.from,s.to)?n:jl(t.state,s.from,s.to)?i:null;r&&e.add(s.from,s.from,r)}return e.finish()}}),{domEventHandlers:r}=e;return[s,$r({class:\"cm-foldGutter\",markers(t){var e;return(null===(e=t.plugin(s))||void 0===e?void 0:e.markers)||Tt.empty},initialSpacer:()=>new oa(e,!1),domEventHandlers:Object.assign(Object.assign({},r),{click:(t,e,i)=>{if(r.click&&r.click(t,e,i))return!0;let n=Jl(t.state,e.from,e.to);if(n)return t.dispatch({effects:Ql.of(n)}),!0;let s=jl(t.state,e.from,e.to);return!!s&&(t.dispatch({effects:$l.of(s)}),!0)}})}),na()]}const aa=Ds.baseTheme({\".cm-foldPlaceholder\":{backgroundColor:\"#eee\",border:\"1px solid #ddd\",color:\"#888\",borderRadius:\".2em\",margin:\"0 1px\",padding:\"0 1px\",cursor:\"pointer\"},\".cm-foldGutter span\":{padding:\"0 1px\",cursor:\"pointer\"}});class ha{constructor(t,e){let i;function n(t){let e=$t.newName();return(i||(i=Object.create(null)))[\".\"+e]=t,e}this.specs=t;const s=\"string\"==typeof e.all?e.all:e.all?n(e.all):void 0,r=e.scope;this.scope=r instanceof pl?t=>t.prop(dl)==r.data:r?t=>t==r:void 0,this.style=Qo(t.map((t=>({tag:t.tag,class:t.class||n(Object.assign({},t,{tag:null}))}))),{all:s}).style,this.module=i?new $t(i):null,this.themeType=e.themeType}static define(t,e){return new ha(t,e||{})}}const ca=N.define(),ua=N.define({combine:t=>t.length?[t[0]]:null});function fa(t){let e=t.facet(ca);return e.length?e:t.facet(ua)}function da(t,e){let i,n=[ma];return t instanceof ha&&(t.module&&n.push(Ds.styleModule.of(t.module)),i=t.themeType),(null==e?void 0:e.fallback)?n.push(ua.of(t)):i?n.push(ca.computeN([Ds.darkTheme],(e=>e.facet(Ds.darkTheme)==(\"dark\"==i)?[t]:[]))):n.push(ca.of(t)),n}class pa{constructor(t){this.markCache=Object.create(null),this.tree=vl(t.state),this.decorations=this.buildDeco(t,fa(t.state))}update(t){let e=vl(t.state),i=fa(t.state),n=i!=fa(t.startState);e.length<t.view.viewport.to&&!n&&e.type==this.tree.type?this.decorations=this.decorations.map(t.changes):(e!=this.tree||t.viewportChanged||n)&&(this.tree=e,this.decorations=this.buildDeco(t.view,i))}buildDeco(t,e){if(!e||!this.tree.length)return ii.none;let i=new Pt;for(let{from:n,to:s}of t.visibleRanges)Ko(this.tree,e,((t,e,n)=>{i.add(t,e,this.markCache[n]||(this.markCache[n]=ii.mark({class:n})))}),n,s);return i.finish()}}const ma=K.high(Mi.fromClass(pa,{decorations:t=>t.decorations})),ga=ha.define([{tag:ul.meta,color:\"#7a757a\"},{tag:ul.link,textDecoration:\"underline\"},{tag:ul.heading,textDecoration:\"underline\",fontWeight:\"bold\"},{tag:ul.emphasis,fontStyle:\"italic\"},{tag:ul.strong,fontWeight:\"bold\"},{tag:ul.strikethrough,textDecoration:\"line-through\"},{tag:ul.keyword,color:\"#708\"},{tag:[ul.atom,ul.bool,ul.url,ul.contentSeparator,ul.labelName],color:\"#219\"},{tag:[ul.literal,ul.inserted],color:\"#164\"},{tag:[ul.string,ul.deleted],color:\"#a11\"},{tag:[ul.regexp,ul.escape,ul.special(ul.string)],color:\"#e40\"},{tag:ul.definition(ul.variableName),color:\"#00f\"},{tag:ul.local(ul.variableName),color:\"#30a\"},{tag:[ul.typeName,ul.namespace],color:\"#085\"},{tag:ul.className,color:\"#167\"},{tag:[ul.special(ul.variableName),ul.macroName],color:\"#256\"},{tag:ul.definition(ul.propertyName),color:\"#00c\"},{tag:ul.comment,color:\"#940\"},{tag:ul.invalid,color:\"#f00\"}]),va=Ds.baseTheme({\"&.cm-focused .cm-matchingBracket\":{backgroundColor:\"#328c8252\"},\"&.cm-focused .cm-nonmatchingBracket\":{backgroundColor:\"#bb555544\"}}),wa=\"()[]{}\",ya=N.define({combine:t=>Ct(t,{afterCursor:!0,brackets:wa,maxScanDistance:1e4,renderMatch:ka})}),ba=ii.mark({class:\"cm-matchingBracket\"}),xa=ii.mark({class:\"cm-nonmatchingBracket\"});function ka(t){let e=[],i=t.matched?ba:xa;return e.push(i.range(t.start.from,t.start.to)),t.end&&e.push(i.range(t.end.from,t.end.to)),e}const Sa=q.define({create:()=>ii.none,update(t,e){if(!e.docChanged&&!e.selection)return t;let i=[],n=e.state.facet(ya);for(let t of e.state.selection.ranges){if(!t.empty)continue;let s=Ma(e.state,t.head,-1,n)||t.head>0&&Ma(e.state,t.head-1,1,n)||n.afterCursor&&(Ma(e.state,t.head,1,n)||t.head<e.state.doc.length&&Ma(e.state,t.head+1,-1,n));s&&(i=i.concat(n.renderMatch(s,e.state)))}return ii.set(i,!0)},provide:t=>Ds.decorations.from(t)}),Ca=[Sa,va];function Aa(t={}){return[ya.of(t),Ca]}function Oa(t,e,i){let n=t.prop(e<0?po.openedBy:po.closedBy);if(n)return n;if(1==t.name.length){let n=i.indexOf(t.name);if(n>-1&&n%2==(e<0?1:0))return[i[n+e]]}return null}function Ma(t,e,i,n={}){let s=n.maxScanDistance||1e4,r=n.brackets||wa,o=vl(t),l=o.resolveInner(e,i);for(let n=l;n;n=n.parent){let s=Oa(n.type,i,r);if(s&&n.from<n.to)return Da(t,e,i,n,s,r)}return function(t,e,i,n,s,r,o){let l=i<0?t.sliceDoc(e-1,e):t.sliceDoc(e,e+1),a=o.indexOf(l);if(a<0||a%2==0!=i>0)return null;let h={from:i<0?e-1:e,to:i>0?e+1:e},c=t.doc.iterRange(e,i>0?t.doc.length:0),u=0;for(let t=0;!c.next().done&&t<=r;){let r=c.value;i<0&&(t+=r.length);let l=e+t*i;for(let t=i>0?0:r.length-1,e=i>0?r.length:-1;t!=e;t+=i){let e=o.indexOf(r[t]);if(!(e<0||n.resolveInner(l+t,1).type!=s))if(e%2==0==i>0)u++;else{if(1==u)return{start:h,end:{from:l+t,to:l+t+1},matched:e>>1==a>>1};u--}}i>0&&(t+=r.length)}return c.done?{start:h,matched:!1}:null}(t,e,i,o,l.type,s,r)}function Da(t,e,i,n,s,r){let o=n.parent,l={from:n.from,to:n.to},a=0,h=null==o?void 0:o.cursor();if(h&&(i<0?h.childBefore(n.from):h.childAfter(n.to)))do{if(i<0?h.to<=n.from:h.from>=n.to){if(0==a&&s.indexOf(h.type.name)>-1&&h.from<h.to)return{start:l,end:{from:h.from,to:h.to},matched:!0};if(Oa(h.type,i,r))a++;else if(Oa(h.type,-i,r)){if(0==a)return{start:l,end:h.from==h.to?void 0:{from:h.from,to:h.to},matched:!1};a--}}}while(i<0?h.prevSibling():h.nextSibling());return{start:l,matched:!1}}const Ta=Object.create(null),Pa=[go.none],Ra=[],Ea=Object.create(null);for(let[t,e]of[[\"variable\",\"variableName\"],[\"variable-2\",\"variableName.special\"],[\"string-2\",\"string.special\"],[\"def\",\"variableName.definition\"],[\"tag\",\"tagName\"],[\"attribute\",\"attributeName\"],[\"type\",\"typeName\"],[\"builtin\",\"variableName.standard\"],[\"qualifier\",\"modifier\"],[\"error\",\"invalid\"],[\"header\",\"heading\"],[\"property\",\"propertyName\"]])Ea[t]=La(Ta,e);function Ba(t,e){Ra.indexOf(t)>-1||(Ra.push(t),console.warn(e))}function La(t,e){let i=null;for(let n of e.split(\".\")){let e=t[n]||ul[n];e?\"function\"==typeof e?i?i=e(i):Ba(n,`Modifier ${n} used at start of tag`):i?Ba(n,`Tag ${n} used as modifier`):i=e:Ba(n,`Unknown highlighting tag ${n}`)}if(!i)return 0;let n=e.replace(/ /g,\"_\"),s=go.define({id:Pa.length,name:n,props:[jo({[n]:i})]});return Pa.push(s),s.id}function Na(t,e){return({state:i,dispatch:n})=>{if(i.readOnly)return!1;let s=t(e,i);return!!s&&(n(i.update(s)),!0)}}const Ia=Na(Fa,0),Va=Na(Ha,0),Wa=Na(((t,e)=>Ha(t,e,function(t){let e=[];for(let i of t.selection.ranges){let n=t.doc.lineAt(i.from),s=i.to<=n.to?n:t.doc.lineAt(i.to),r=e.length-1;r>=0&&e[r].to>n.from?e[r].to=s.to:e.push({from:n.from,to:s.to})}return e}(e))),0);function za(t,e=t.selection.main.head){let i=t.languageDataAt(\"commentTokens\",e);return i.length?i[0]:{}}function Ha(t,e,i=e.selection.ranges){let n=i.map((t=>za(e,t.from).block));if(!n.every((t=>t)))return null;let s=i.map(((t,i)=>function(t,{open:e,close:i},n,s){let r,o,l=t.sliceDoc(n-50,n),a=t.sliceDoc(s,s+50),h=/\\s*$/.exec(l)[0].length,c=/^\\s*/.exec(a)[0].length,u=l.length-h;if(l.slice(u-e.length,u)==e&&a.slice(c,c+i.length)==i)return{open:{pos:n-h,margin:h&&1},close:{pos:s+c,margin:c&&1}};s-n<=100?r=o=t.sliceDoc(n,s):(r=t.sliceDoc(n,n+50),o=t.sliceDoc(s-50,s));let f=/^\\s*/.exec(r)[0].length,d=/\\s*$/.exec(o)[0].length,p=o.length-d-i.length;return r.slice(f,f+e.length)==e&&o.slice(p,p+i.length)==i?{open:{pos:n+f+e.length,margin:/\\s/.test(r.charAt(f+e.length))?1:0},close:{pos:s-d-i.length,margin:/\\s/.test(o.charAt(p-1))?1:0}}:null}(e,n[i],t.from,t.to)));if(2!=t&&!s.every((t=>t)))return{changes:e.changes(i.map(((t,e)=>s[e]?[]:[{from:t.from,insert:n[e].open+\" \"},{from:t.to,insert:\" \"+n[e].close}])))};if(1!=t&&s.some((t=>t))){let t=[];for(let e,i=0;i<s.length;i++)if(e=s[i]){let s=n[i],{open:r,close:o}=e;t.push({from:r.pos-s.open.length,to:r.pos+r.margin},{from:o.pos-o.margin,to:o.pos+s.close.length})}return{changes:t}}return null}function Fa(t,e,i=e.selection.ranges){let n=[],s=-1;for(let{from:t,to:r}of i){let i=n.length,o=1e9;for(let i=t;i<=r;){let l=e.doc.lineAt(i);if(l.from>s&&(t==r||r>l.from)){s=l.from;let t=za(e,i).line;if(!t)continue;let r=/^\\s*/.exec(l.text)[0].length,a=r==l.length,h=l.text.slice(r,r+t.length)==t?r:-1;r<l.text.length&&r<o&&(o=r),n.push({line:l,comment:h,token:t,indent:r,empty:a,single:!1})}i=l.to+1}if(o<1e9)for(let t=i;t<n.length;t++)n[t].indent<n[t].line.text.length&&(n[t].indent=o);n.length==i+1&&(n[i].single=!0)}if(2!=t&&n.some((t=>t.comment<0&&(!t.empty||t.single)))){let t=[];for(let{line:e,token:i,indent:s,empty:r,single:o}of n)!o&&r||t.push({from:e.from+s,insert:i+\" \"});let i=e.changes(t);return{changes:i,selection:e.selection.map(i,1)}}if(1!=t&&n.some((t=>t.comment>=0))){let t=[];for(let{line:e,comment:i,token:s}of n)if(i>=0){let n=e.from+i,r=n+s.length;\" \"==e.text[r-e.from]&&r++,t.push({from:n,to:r})}return{changes:t}}return null}const qa=at.define(),_a=at.define(),ja=N.define(),Ua=N.define({combine:t=>Ct(t,{minDepth:100,newGroupDelay:500},{minDepth:Math.max,newGroupDelay:Math.min})});const $a=q.define({create:()=>ah.empty,update(t,e){let i=e.state.facet(Ua),n=e.annotation(qa);if(n){let s=e.docChanged?E.single(function(t){let e=0;return t.iterChangedRanges(((t,i)=>e=i)),e}(e.changes)):void 0,r=Ya.fromTransaction(e,s),o=n.side,l=0==o?t.undone:t.done;return l=r?th(l,l.length,i.minDepth,r):nh(l,e.startState.selection),new ah(0==o?n.rest:l,0==o?l:n.rest)}let s=e.annotation(_a);if(\"full\"!=s&&\"before\"!=s||(t=t.isolate()),!1===e.annotation(ft.addToHistory))return e.changes.empty?t:t.addMapping(e.changes.desc);let r=Ya.fromTransaction(e),o=e.annotation(ft.time),l=e.annotation(ft.userEvent);return r?t=t.addChanges(r,o,l,i.newGroupDelay,i.minDepth):e.selection&&(t=t.addSelection(e.startState.selection,o,l,i.newGroupDelay)),\"full\"!=s&&\"after\"!=s||(t=t.isolate()),t},toJSON:t=>({done:t.done.map((t=>t.toJSON())),undone:t.undone.map((t=>t.toJSON()))}),fromJSON:t=>new ah(t.done.map(Ya.fromJSON),t.undone.map(Ya.fromJSON))});function Qa(t={}){return[$a,Ua.of(t),Ds.domEventHandlers({beforeinput(t,e){let i=\"historyUndo\"==t.inputType?Ga:\"historyRedo\"==t.inputType?Ja:null;return!!i&&(t.preventDefault(),i(e))}})]}function Ka(t,e){return function({state:i,dispatch:n}){if(!e&&i.readOnly)return!1;let s=i.field($a,!1);if(!s)return!1;let r=s.pop(t,i,e);return!!r&&(n(r),!0)}}const Ga=Ka(0,!1),Ja=Ka(1,!1),Xa=Ka(0,!0),Za=Ka(1,!0);class Ya{constructor(t,e,i,n,s){this.changes=t,this.effects=e,this.mapped=i,this.startSelection=n,this.selectionsAfter=s}setSelAfter(t){return new Ya(this.changes,this.effects,this.mapped,this.startSelection,t)}toJSON(){var t,e,i;return{changes:null===(t=this.changes)||void 0===t?void 0:t.toJSON(),mapped:null===(e=this.mapped)||void 0===e?void 0:e.toJSON(),startSelection:null===(i=this.startSelection)||void 0===i?void 0:i.toJSON(),selectionsAfter:this.selectionsAfter.map((t=>t.toJSON()))}}static fromJSON(t){return new Ya(t.changes&&C.fromJSON(t.changes),[],t.mapped&&S.fromJSON(t.mapped),t.startSelection&&E.fromJSON(t.startSelection),t.selectionsAfter.map(E.fromJSON))}static fromTransaction(t,e){let i=ih;for(let e of t.startState.facet(ja)){let n=e(t);n.length&&(i=i.concat(n))}return!i.length&&t.changes.empty?null:new Ya(t.changes.invert(t.startState.doc),i,void 0,e||t.startState.selection,ih)}static selection(t){return new Ya(void 0,ih,void 0,void 0,t)}}function th(t,e,i,n){let s=e+1>i+20?e-i-1:0,r=t.slice(s,e);return r.push(n),r}function eh(t,e){return t.length?e.length?t.concat(e):t:e}const ih=[];function nh(t,e){if(t.length){let i=t[t.length-1],n=i.selectionsAfter.slice(Math.max(0,i.selectionsAfter.length-200));return n.length&&n[n.length-1].eq(e)?t:(n.push(e),th(t,t.length-1,1e9,i.setSelAfter(n)))}return[Ya.selection([e])]}function sh(t){let e=t[t.length-1],i=t.slice();return i[t.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),i}function rh(t,e){if(!t.length)return t;let i=t.length,n=ih;for(;i;){let s=oh(t[i-1],e,n);if(s.changes&&!s.changes.empty||s.effects.length){let e=t.slice(0,i);return e[i-1]=s,e}e=s.mapped,i--,n=s.selectionsAfter}return n.length?[Ya.selection(n)]:ih}function oh(t,e,i){let n=eh(t.selectionsAfter.length?t.selectionsAfter.map((t=>t.map(e))):ih,i);if(!t.changes)return Ya.selection(n);let s=t.changes.map(e),r=e.mapDesc(t.changes,!0),o=t.mapped?t.mapped.composeDesc(r):r;return new Ya(s,ut.mapEffects(t.effects,e),o,t.startSelection.map(r),n)}const lh=/^(input\\.type|delete)($|\\.)/;class ah{constructor(t,e,i=0,n){this.done=t,this.undone=e,this.prevTime=i,this.prevUserEvent=n}isolate(){return this.prevTime?new ah(this.done,this.undone):this}addChanges(t,e,i,n,s){let r=this.done,o=r[r.length-1];return r=o&&o.changes&&!o.changes.empty&&t.changes&&(!i||lh.test(i))&&(!o.selectionsAfter.length&&e-this.prevTime<n&&function(t,e){let i=[],n=!1;return t.iterChangedRanges(((t,e)=>i.push(t,e))),e.iterChangedRanges(((t,e,s,r)=>{for(let t=0;t<i.length;){let e=i[t++],o=i[t++];r>=e&&s<=o&&(n=!0)}})),n}(o.changes,t.changes)||\"input.type.compose\"==i)?th(r,r.length-1,s,new Ya(t.changes.compose(o.changes),eh(t.effects,o.effects),o.mapped,o.startSelection,ih)):th(r,r.length,s,t),new ah(r,ih,e,i)}addSelection(t,e,i,n){let s=this.done.length?this.done[this.done.length-1].selectionsAfter:ih;return s.length>0&&e-this.prevTime<n&&i==this.prevUserEvent&&i&&/^select($|\\.)/.test(i)&&(r=s[s.length-1],o=t,r.ranges.length==o.ranges.length&&0===r.ranges.filter(((t,e)=>t.empty!=o.ranges[e].empty)).length)?this:new ah(nh(this.done,t),this.undone,e,i);var r,o}addMapping(t){return new ah(rh(this.done,t),rh(this.undone,t),this.prevTime,this.prevUserEvent)}pop(t,e,i){let n=0==t?this.done:this.undone;if(0==n.length)return null;let s=n[n.length-1];if(i&&s.selectionsAfter.length)return e.update({selection:s.selectionsAfter[s.selectionsAfter.length-1],annotations:qa.of({side:t,rest:sh(n)}),userEvent:0==t?\"select.undo\":\"select.redo\",scrollIntoView:!0});if(s.changes){let i=1==n.length?ih:n.slice(0,n.length-1);return s.mapped&&(i=rh(i,s.mapped)),e.update({changes:s.changes,selection:s.startSelection,effects:s.effects,annotations:qa.of({side:t,rest:i}),filter:!1,userEvent:0==t?\"undo\":\"redo\",scrollIntoView:!0})}return null}}ah.empty=new ah(ih,ih);const hh=[{key:\"Mod-z\",run:Ga,preventDefault:!0},{key:\"Mod-y\",mac:\"Mod-Shift-z\",run:Ja,preventDefault:!0},{linux:\"Ctrl-Shift-z\",run:Ja,preventDefault:!0},{key:\"Mod-u\",run:Xa,preventDefault:!0},{key:\"Alt-u\",mac:\"Mod-Shift-u\",run:Za,preventDefault:!0}];function ch(t,e){return E.create(t.ranges.map(e),t.mainIndex)}function uh(t,e){return t.update({selection:e,scrollIntoView:!0,userEvent:\"select\"})}function fh({state:t,dispatch:e},i){let n=ch(t.selection,i);return!n.eq(t.selection)&&(e(uh(t,n)),!0)}function dh(t,e){return E.cursor(e?t.to:t.from)}function ph(t,e){return fh(t,(i=>i.empty?t.moveByChar(i,e):dh(i,e)))}function mh(t){return t.textDirectionAt(t.state.selection.main.head)==Vi.LTR}const gh=t=>ph(t,!mh(t)),vh=t=>ph(t,mh(t));function wh(t,e){return fh(t,(i=>i.empty?t.moveByGroup(i,e):dh(i,e)))}function yh(t,e,i){if(e.type.prop(i))return!0;let n=e.to-e.from;return n&&(n>2||/[^\\s,.;:]/.test(t.sliceDoc(e.from,e.to)))||e.firstChild}function bh(t,e,i){let n,s,r=vl(t).resolveInner(e.head),o=i?po.closedBy:po.openedBy;for(let n=e.head;;){let e=i?r.childAfter(n):r.childBefore(n);if(!e)break;yh(t,e,o)?r=e:n=i?e.to:e.from}return s=r.type.prop(o)&&(n=i?Ma(t,r.from,1):Ma(t,r.to,-1))&&n.matched?i?n.end.to:n.end.from:i?r.to:r.from,E.cursor(s,i?-1:1)}function xh(t,e){return fh(t,(i=>{if(!i.empty)return dh(i,e);let n=t.moveVertically(i,e);return n.head!=i.head?n:t.moveToLineBoundary(i,e)}))}const kh=t=>xh(t,!1),Sh=t=>xh(t,!0);function Ch(t){return Math.max(t.defaultLineHeight,Math.min(t.dom.clientHeight,innerHeight)-5)}function Ah(t,e){let{state:i}=t,n=ch(i.selection,(i=>i.empty?t.moveVertically(i,e,Ch(t)):dh(i,e)));if(n.eq(i.selection))return!1;let s,r=t.coordsAtPos(i.selection.main.head),o=t.scrollDOM.getBoundingClientRect();return r&&r.top>o.top&&r.bottom<o.bottom&&r.top-o.top<=t.scrollDOM.scrollHeight-t.scrollDOM.scrollTop-t.scrollDOM.clientHeight&&(s=Ds.scrollIntoView(n.main.head,{y:\"start\",yMargin:r.top-o.top})),t.dispatch(uh(i,n),{effects:s}),!0}const Oh=t=>Ah(t,!1),Mh=t=>Ah(t,!0);function Dh(t,e,i){let n=t.lineBlockAt(e.head),s=t.moveToLineBoundary(e,i);if(s.head==e.head&&s.head!=(i?n.to:n.from)&&(s=t.moveToLineBoundary(e,i,!1)),!i&&s.head==n.from&&n.length){let i=/^\\s*/.exec(t.state.sliceDoc(n.from,Math.min(n.from+100,n.to)))[0].length;i&&e.head!=n.from+i&&(s=E.cursor(n.from+i))}return s}function Th(t,e){let i=ch(t.state.selection,(t=>{let i=e(t);return E.range(t.anchor,i.head,i.goalColumn)}));return!i.eq(t.state.selection)&&(t.dispatch(uh(t.state,i)),!0)}function Ph(t,e){return Th(t,(i=>t.moveByChar(i,e)))}const Rh=t=>Ph(t,!mh(t)),Eh=t=>Ph(t,mh(t));function Bh(t,e){return Th(t,(i=>t.moveByGroup(i,e)))}function Lh(t,e){return Th(t,(i=>t.moveVertically(i,e)))}const Nh=t=>Lh(t,!1),Ih=t=>Lh(t,!0);function Vh(t,e){return Th(t,(i=>t.moveVertically(i,e,Ch(t))))}const Wh=t=>Vh(t,!1),zh=t=>Vh(t,!0),Hh=({state:t,dispatch:e})=>(e(uh(t,{anchor:0})),!0),Fh=({state:t,dispatch:e})=>(e(uh(t,{anchor:t.doc.length})),!0),qh=({state:t,dispatch:e})=>(e(uh(t,{anchor:t.selection.main.anchor,head:0})),!0),_h=({state:t,dispatch:e})=>(e(uh(t,{anchor:t.selection.main.anchor,head:t.doc.length})),!0);function jh(t,e){if(t.state.readOnly)return!1;let i=\"delete.selection\",{state:n}=t,s=n.changeByRange((n=>{let{from:s,to:r}=n;if(s==r){let n=e(s);n<s?(i=\"delete.backward\",n=Uh(t,n,!1)):n>s&&(i=\"delete.forward\",n=Uh(t,n,!0)),s=Math.min(s,n),r=Math.max(r,n)}else s=Uh(t,s,!1),r=Uh(t,r,!0);return s==r?{range:n}:{changes:{from:s,to:r},range:E.cursor(s)}}));return!s.changes.empty&&(t.dispatch(n.update(s,{scrollIntoView:!0,userEvent:i,effects:\"delete.selection\"==i?Ds.announce.of(n.phrase(\"Selection deleted\")):void 0})),!0)}function Uh(t,e,i){if(t instanceof Ds)for(let n of t.state.facet(Ds.atomicRanges).map((e=>e(t))))n.between(e,e,((t,n)=>{t<e&&n>e&&(e=i?n:t)}));return e}const $h=(t,e)=>jh(t,(i=>{let n,s,{state:r}=t,o=r.doc.lineAt(i);if(!e&&i>o.from&&i<o.from+200&&!/[^ \\t]/.test(n=o.text.slice(0,i-o.from))){if(\"\\t\"==n[n.length-1])return i-1;let t=Ft(n,r.tabSize)%Pl(r)||Pl(r);for(let e=0;e<t&&\" \"==n[n.length-1-e];e++)i--;s=i}else s=d(o.text,i-o.from,e,e)+o.from,s==i&&o.number!=(e?r.doc.lines:1)&&(s+=e?1:-1);return s})),Qh=t=>$h(t,!1),Kh=t=>$h(t,!0),Gh=(t,e)=>jh(t,(i=>{let n=i,{state:s}=t,r=s.doc.lineAt(n),o=s.charCategorizer(n);for(let t=null;;){if(n==(e?r.to:r.from)){n==i&&r.number!=(e?s.doc.lines:1)&&(n+=e?1:-1);break}let l=d(r.text,n-r.from,e)+r.from,a=r.text.slice(Math.min(n,l)-r.from,Math.max(n,l)-r.from),h=o(a);if(null!=t&&h!=t)break;\" \"==a&&n==i||(t=h),n=l}return n})),Jh=t=>Gh(t,!1),Xh=t=>jh(t,(e=>{let i=t.lineBlockAt(e).to;return e<i?i:Math.min(t.state.doc.length,e+1)}));function Zh(t){let e=[],i=-1;for(let n of t.selection.ranges){let s=t.doc.lineAt(n.from),r=t.doc.lineAt(n.to);if(n.empty||n.to!=r.from||(r=t.doc.lineAt(n.to-1)),i>=s.number){let t=e[e.length-1];t.to=r.to,t.ranges.push(n)}else e.push({from:s.from,to:r.to,ranges:[n]});i=r.number+1}return e}function Yh(t,e,i){if(t.readOnly)return!1;let n=[],s=[];for(let e of Zh(t)){if(i?e.to==t.doc.length:0==e.from)continue;let r=t.doc.lineAt(i?e.to+1:e.from-1),o=r.length+1;if(i){n.push({from:e.to,to:r.to},{from:e.from,insert:r.text+t.lineBreak});for(let i of e.ranges)s.push(E.range(Math.min(t.doc.length,i.anchor+o),Math.min(t.doc.length,i.head+o)))}else{n.push({from:r.from,to:e.from},{from:e.to,insert:t.lineBreak+r.text});for(let t of e.ranges)s.push(E.range(t.anchor-o,t.head-o))}}return!!n.length&&(e(t.update({changes:n,scrollIntoView:!0,selection:E.create(s,t.selection.mainIndex),userEvent:\"move.line\"})),!0)}function tc(t,e,i){if(t.readOnly)return!1;let n=[];for(let e of Zh(t))i?n.push({from:e.from,insert:t.doc.slice(e.from,e.to)+t.lineBreak}):n.push({from:e.to,insert:t.lineBreak+t.doc.slice(e.from,e.to)});return e(t.update({changes:n,scrollIntoView:!0,userEvent:\"input.copyline\"})),!0}const ec=ic(!1);function ic(t){return({state:i,dispatch:n})=>{if(i.readOnly)return!1;let s=i.changeByRange((n=>{let{from:s,to:r}=n,o=i.doc.lineAt(s),l=!t&&s==r&&function(t,e){if(/\\(\\)|\\[\\]|\\{\\}/.test(t.sliceDoc(e-1,e+1)))return{from:e,to:e};let i,n=vl(t).resolveInner(e),s=n.childBefore(e),r=n.childAfter(e);return s&&r&&s.to<=e&&r.from>=e&&(i=s.type.prop(po.closedBy))&&i.indexOf(r.name)>-1&&t.doc.lineAt(s.to).from==t.doc.lineAt(r.from).from?{from:s.to,to:r.from}:null}(i,s);t&&(s=r=(r<=o.to?o:i.doc.lineAt(r)).to);let a=new Bl(i,{simulateBreak:s,simulateDoubleBreak:!!l}),h=El(a,s);for(null==h&&(h=/^\\s*/.exec(i.doc.lineAt(s).text)[0].length);r<o.to&&/\\s/.test(o.text[r-o.from]);)r++;l?({from:s,to:r}=l):s>o.from&&s<o.from+100&&!/\\S/.test(o.text.slice(0,s))&&(s=o.from);let c=[\"\",Rl(i,h)];return l&&c.push(Rl(i,a.lineIndent(o.from,-1))),{changes:{from:s,to:r,insert:e.of(c)},range:E.cursor(s+1+c[1].length)}}));return n(i.update(s,{scrollIntoView:!0,userEvent:\"input\"})),!0}}function nc(t,e){let i=-1;return t.changeByRange((n=>{let s=[];for(let r=n.from;r<=n.to;){let o=t.doc.lineAt(r);o.number>i&&(n.empty||n.to>o.from)&&(e(o,s,n),i=o.number),r=o.to+1}let r=t.changes(s);return{changes:s,range:E.range(r.mapPos(n.anchor,1),r.mapPos(n.head,1))}}))}const sc=[{key:\"Alt-ArrowLeft\",mac:\"Ctrl-ArrowLeft\",run:t=>fh(t,(e=>bh(t.state,e,!mh(t)))),shift:t=>Th(t,(e=>bh(t.state,e,!mh(t))))},{key:\"Alt-ArrowRight\",mac:\"Ctrl-ArrowRight\",run:t=>fh(t,(e=>bh(t.state,e,mh(t)))),shift:t=>Th(t,(e=>bh(t.state,e,mh(t))))},{key:\"Alt-ArrowUp\",run:({state:t,dispatch:e})=>Yh(t,e,!1)},{key:\"Shift-Alt-ArrowUp\",run:({state:t,dispatch:e})=>tc(t,e,!1)},{key:\"Alt-ArrowDown\",run:({state:t,dispatch:e})=>Yh(t,e,!0)},{key:\"Shift-Alt-ArrowDown\",run:({state:t,dispatch:e})=>tc(t,e,!0)},{key:\"Escape\",run:({state:t,dispatch:e})=>{let i=t.selection,n=null;return i.ranges.length>1?n=E.create([i.main]):i.main.empty||(n=E.create([E.cursor(i.main.head)])),!!n&&(e(uh(t,n)),!0)}},{key:\"Mod-Enter\",run:ic(!0)},{key:\"Alt-l\",mac:\"Ctrl-l\",run:({state:t,dispatch:e})=>{let i=Zh(t).map((({from:e,to:i})=>E.range(e,Math.min(i+1,t.doc.length))));return e(t.update({selection:E.create(i),userEvent:\"select\"})),!0}},{key:\"Mod-i\",run:({state:t,dispatch:e})=>{let i=ch(t.selection,(e=>{var i;let n=vl(t).resolveInner(e.head,1);for(;!(n.from<e.from&&n.to>=e.to||n.to>e.to&&n.from<=e.from)&&(null===(i=n.parent)||void 0===i?void 0:i.parent);)n=n.parent;return E.range(n.to,n.from)}));return e(uh(t,i)),!0},preventDefault:!0},{key:\"Mod-[\",run:({state:t,dispatch:e})=>!t.readOnly&&(e(t.update(nc(t,((e,i)=>{let n=/^\\s*/.exec(e.text)[0];if(!n)return;let s=Ft(n,t.tabSize),r=0,o=Rl(t,Math.max(0,s-Pl(t)));for(;r<n.length&&r<o.length&&n.charCodeAt(r)==o.charCodeAt(r);)r++;i.push({from:e.from+r,to:e.from+n.length,insert:o.slice(r)})})),{userEvent:\"delete.dedent\"})),!0)},{key:\"Mod-]\",run:({state:t,dispatch:e})=>!t.readOnly&&(e(t.update(nc(t,((e,i)=>{i.push({from:e.from,insert:t.facet(Tl)})})),{userEvent:\"input.indent\"})),!0)},{key:\"Mod-Alt-\\\\\",run:({state:t,dispatch:e})=>{if(t.readOnly)return!1;let i=Object.create(null),n=new Bl(t,{overrideIndentation:t=>{let e=i[t];return null==e?-1:e}}),s=nc(t,((e,s,r)=>{let o=El(n,e.from);if(null==o)return;/\\S/.test(e.text)||(o=0);let l=/^\\s*/.exec(e.text)[0],a=Rl(t,o);(l!=a||r.from<e.from+l.length)&&(i[e.from]=o,s.push({from:e.from,to:e.from+l.length,insert:a}))}));return s.changes.empty||e(t.update(s,{userEvent:\"indent\"})),!0}},{key:\"Shift-Mod-k\",run:t=>{if(t.state.readOnly)return!1;let{state:e}=t,i=e.changes(Zh(e).map((({from:t,to:i})=>(t>0?t--:i<e.doc.length&&i++,{from:t,to:i})))),n=ch(e.selection,(e=>t.moveVertically(e,!0))).map(i);return t.dispatch({changes:i,selection:n,scrollIntoView:!0,userEvent:\"delete.line\"}),!0}},{key:\"Shift-Mod-\\\\\",run:({state:t,dispatch:e})=>function(t,e,i){let n=!1,s=ch(t.selection,(e=>{let s=Ma(t,e.head,-1)||Ma(t,e.head,1)||e.head>0&&Ma(t,e.head-1,1)||e.head<t.doc.length&&Ma(t,e.head+1,-1);if(!s||!s.end)return e;n=!0;let r=s.start.from==e.head?s.end.to:s.end.from;return i?E.range(e.anchor,r):E.cursor(r)}));return!!n&&(e(uh(t,s)),!0)}(t,e,!1)},{key:\"Mod-/\",run:t=>{let e=za(t.state);return e.line?Ia(t):!!e.block&&Wa(t)}},{key:\"Alt-A\",run:Va}].concat([{key:\"ArrowLeft\",run:gh,shift:Rh,preventDefault:!0},{key:\"Mod-ArrowLeft\",mac:\"Alt-ArrowLeft\",run:t=>wh(t,!mh(t)),shift:t=>Bh(t,!mh(t)),preventDefault:!0},{mac:\"Cmd-ArrowLeft\",run:t=>fh(t,(e=>Dh(t,e,!mh(t)))),shift:t=>Th(t,(e=>Dh(t,e,!mh(t)))),preventDefault:!0},{key:\"ArrowRight\",run:vh,shift:Eh,preventDefault:!0},{key:\"Mod-ArrowRight\",mac:\"Alt-ArrowRight\",run:t=>wh(t,mh(t)),shift:t=>Bh(t,mh(t)),preventDefault:!0},{mac:\"Cmd-ArrowRight\",run:t=>fh(t,(e=>Dh(t,e,mh(t)))),shift:t=>Th(t,(e=>Dh(t,e,mh(t)))),preventDefault:!0},{key:\"ArrowUp\",run:kh,shift:Nh,preventDefault:!0},{mac:\"Cmd-ArrowUp\",run:Hh,shift:qh},{mac:\"Ctrl-ArrowUp\",run:Oh,shift:Wh},{key:\"ArrowDown\",run:Sh,shift:Ih,preventDefault:!0},{mac:\"Cmd-ArrowDown\",run:Fh,shift:_h},{mac:\"Ctrl-ArrowDown\",run:Mh,shift:zh},{key:\"PageUp\",run:Oh,shift:Wh},{key:\"PageDown\",run:Mh,shift:zh},{key:\"Home\",run:t=>fh(t,(e=>Dh(t,e,!1))),shift:t=>Th(t,(e=>Dh(t,e,!1))),preventDefault:!0},{key:\"Mod-Home\",run:Hh,shift:qh},{key:\"End\",run:t=>fh(t,(e=>Dh(t,e,!0))),shift:t=>Th(t,(e=>Dh(t,e,!0))),preventDefault:!0},{key:\"Mod-End\",run:Fh,shift:_h},{key:\"Enter\",run:ec},{key:\"Mod-a\",run:({state:t,dispatch:e})=>(e(t.update({selection:{anchor:0,head:t.doc.length},userEvent:\"select\"})),!0)},{key:\"Backspace\",run:Qh,shift:Qh},{key:\"Delete\",run:Kh},{key:\"Mod-Backspace\",mac:\"Alt-Backspace\",run:Jh},{key:\"Mod-Delete\",mac:\"Alt-Delete\",run:t=>Gh(t,!0)},{mac:\"Mod-Backspace\",run:t=>jh(t,(e=>{let i=t.lineBlockAt(e).from;return e>i?i:Math.max(0,e-1)}))},{mac:\"Mod-Delete\",run:Xh}].concat([{key:\"Ctrl-b\",run:gh,shift:Rh,preventDefault:!0},{key:\"Ctrl-f\",run:vh,shift:Eh},{key:\"Ctrl-p\",run:kh,shift:Nh},{key:\"Ctrl-n\",run:Sh,shift:Ih},{key:\"Ctrl-a\",run:t=>fh(t,(e=>E.cursor(t.lineBlockAt(e.head).from,1))),shift:t=>Th(t,(e=>E.cursor(t.lineBlockAt(e.head).from)))},{key:\"Ctrl-e\",run:t=>fh(t,(e=>E.cursor(t.lineBlockAt(e.head).to,-1))),shift:t=>Th(t,(e=>E.cursor(t.lineBlockAt(e.head).to)))},{key:\"Ctrl-d\",run:Kh},{key:\"Ctrl-h\",run:Qh},{key:\"Ctrl-k\",run:Xh},{key:\"Ctrl-Alt-h\",run:Jh},{key:\"Ctrl-o\",run:({state:t,dispatch:i})=>{if(t.readOnly)return!1;let n=t.changeByRange((t=>({changes:{from:t.from,to:t.to,insert:e.of([\"\",\"\"])},range:E.cursor(t.from)})));return i(t.update(n,{scrollIntoView:!0,userEvent:\"input\"})),!0}},{key:\"Ctrl-t\",run:({state:t,dispatch:e})=>{if(t.readOnly)return!1;let i=t.changeByRange((e=>{if(!e.empty||0==e.from||e.from==t.doc.length)return{range:e};let i=e.from,n=t.doc.lineAt(i),s=i==n.from?i-1:d(n.text,i-n.from,!1)+n.from,r=i==n.to?i+1:d(n.text,i-n.from,!0)+n.from;return{changes:{from:s,to:r,insert:t.doc.slice(i,r).append(t.doc.slice(s,i))},range:E.cursor(r)}}));return!i.changes.empty&&(e(t.update(i,{scrollIntoView:!0,userEvent:\"move.character\"})),!0)}},{key:\"Ctrl-v\",run:Mh}].map((t=>({mac:t.key,run:t.run,shift:t.shift})))));function rc(){var t=arguments[0];\"string\"==typeof t&&(t=document.createElement(t));var e=1,i=arguments[1];if(i&&\"object\"==typeof i&&null==i.nodeType&&!Array.isArray(i)){for(var n in i)if(Object.prototype.hasOwnProperty.call(i,n)){var s=i[n];\"string\"==typeof s?t.setAttribute(n,s):null!=s&&(t[n]=s)}e++}for(;e<arguments.length;e++)oc(t,arguments[e]);return t}function oc(t,e){if(\"string\"==typeof e)t.appendChild(document.createTextNode(e));else if(null==e);else if(null!=e.nodeType)t.appendChild(e);else{if(!Array.isArray(e))throw new RangeError(\"Unsupported child node: \"+e);for(var i=0;i<e.length;i++)oc(t,e[i])}}const lc=\"function\"==typeof String.prototype.normalize?t=>t.normalize(\"NFKD\"):t=>t;class ac{constructor(t,e,i=0,n=t.length,s,r){this.test=r,this.value={from:0,to:0},this.done=!1,this.matches=[],this.buffer=\"\",this.bufferPos=0,this.iter=t.iterRange(i,n),this.bufferStart=i,this.normalize=s?t=>s(lc(t)):lc,this.query=this.normalize(e)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return w(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let t=this.peek();if(t<0)return this.done=!0,this;let e=y(t),i=this.bufferStart+this.bufferPos;this.bufferPos+=b(t);let n=this.normalize(e);for(let t=0,s=i;;t++){let r=n.charCodeAt(t),o=this.match(r,s);if(o)return this.value=o,this;if(t==n.length-1)break;s==i&&t<e.length&&e.charCodeAt(t)==r&&s++}}}match(t,e){let i=null;for(let n=0;n<this.matches.length;n+=2){let s=this.matches[n],r=!1;this.query.charCodeAt(s)==t&&(s==this.query.length-1?i={from:this.matches[n+1],to:e+1}:(this.matches[n]++,r=!0)),r||(this.matches.splice(n,2),n-=2)}return this.query.charCodeAt(0)==t&&(1==this.query.length?i={from:e,to:e+1}:this.matches.push(1,e)),i&&this.test&&!this.test(i.from,i.to,this.buffer,this.bufferPos)&&(i=null),i}}\"undefined\"!=typeof Symbol&&(ac.prototype[Symbol.iterator]=function(){return this});const hc={from:-1,to:-1,match:/.*/.exec(\"\")},cc=\"gm\"+(null==/x/.unicode?\"\":\"u\");class uc{constructor(t,e,i,n=0,s=t.length){if(this.text=t,this.to=s,this.curLine=\"\",this.done=!1,this.value=hc,/\\\\[sWDnr]|\\n|\\r|\\[\\^/.test(e))return new pc(t,e,i,n,s);this.re=new RegExp(e,cc+((null==i?void 0:i.ignoreCase)?\"i\":\"\")),this.test=null==i?void 0:i.test,this.iter=t.iter();let r=t.lineAt(n);this.curLineStart=r.from,this.matchPos=mc(t,n),this.getLine(this.curLineStart)}getLine(t){this.iter.next(t),this.iter.lineBreak?this.curLine=\"\":(this.curLine=this.iter.value,this.curLineStart+this.curLine.length>this.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine=\"\":this.getLine(0)}next(){for(let t=this.matchPos-this.curLineStart;;){this.re.lastIndex=t;let e=this.matchPos<=this.to&&this.re.exec(this.curLine);if(e){let i=this.curLineStart+e.index,n=i+e[0].length;if(this.matchPos=mc(this.text,n+(i==n?1:0)),i==this.curLineStart+this.curLine.length&&this.nextLine(),(i<n||i>this.value.to)&&(!this.test||this.test(i,n,e)))return this.value={from:i,to:n,match:e},this;t=this.matchPos-this.curLineStart}else{if(!(this.curLineStart+this.curLine.length<this.to))return this.done=!0,this;this.nextLine(),t=0}}}}const fc=new WeakMap;class dc{constructor(t,e){this.from=t,this.text=e}get to(){return this.from+this.text.length}static get(t,e,i){let n=fc.get(t);if(!n||n.from>=i||n.to<=e){let n=new dc(e,t.sliceString(e,i));return fc.set(t,n),n}if(n.from==e&&n.to==i)return n;let{text:s,from:r}=n;return r>e&&(s=t.sliceString(e,r)+s,r=e),n.to<i&&(s+=t.sliceString(n.to,i)),fc.set(t,new dc(r,s)),new dc(e,s.slice(e-r,i-r))}}class pc{constructor(t,e,i,n,s){this.text=t,this.to=s,this.done=!1,this.value=hc,this.matchPos=mc(t,n),this.re=new RegExp(e,cc+((null==i?void 0:i.ignoreCase)?\"i\":\"\")),this.test=null==i?void 0:i.test,this.flat=dc.get(t,n,this.chunkEnd(n+5e3))}chunkEnd(t){return t>=this.to?this.to:this.text.lineAt(t).to}next(){for(;;){let t=this.re.lastIndex=this.matchPos-this.flat.from,e=this.re.exec(this.flat.text);if(e&&!e[0]&&e.index==t&&(this.re.lastIndex=t+1,e=this.re.exec(this.flat.text)),e){let t=this.flat.from+e.index,i=t+e[0].length;if((this.flat.to>=this.to||e.index+e[0].length<=this.flat.text.length-10)&&(!this.test||this.test(t,i,e)))return this.value={from:t,to:i,match:e},this.matchPos=mc(this.text,i+(t==i?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=dc.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+2*this.flat.text.length))}}}function mc(t,e){if(e>=t.length)return e;let i,n=t.lineAt(e);for(;e<n.to&&(i=n.text.charCodeAt(e-n.from))>=56320&&i<57344;)e++;return e}function gc(t){let e=rc(\"input\",{class:\"cm-textfield\",name:\"line\"});function i(){let i=/^([+-])?(\\d+)?(:\\d+)?(%)?$/.exec(e.value);if(!i)return;let{state:n}=t,s=n.doc.lineAt(n.selection.main.head),[,r,o,l,a]=i,h=l?+l.slice(1):0,c=o?+o:s.number;if(o&&a){let t=c/100;r&&(t=t*(\"-\"==r?-1:1)+s.number/n.doc.lines),c=Math.round(n.doc.lines*t)}else o&&r&&(c=c*(\"-\"==r?-1:1)+s.number);let u=n.doc.line(Math.max(1,Math.min(n.doc.lines,c)));t.dispatch({effects:vc.of(!1),selection:E.cursor(u.from+Math.max(0,Math.min(h,u.length))),scrollIntoView:!0}),t.focus()}return{dom:rc(\"form\",{class:\"cm-gotoLine\",onkeydown:e=>{27==e.keyCode?(e.preventDefault(),t.dispatch({effects:vc.of(!1)}),t.focus()):13==e.keyCode&&(e.preventDefault(),i())},onsubmit:t=>{t.preventDefault(),i()}},rc(\"label\",t.state.phrase(\"Go to line\"),\": \",e),\" \",rc(\"button\",{class:\"cm-button\",type:\"submit\"},t.state.phrase(\"go\")))}}\"undefined\"!=typeof Symbol&&(uc.prototype[Symbol.iterator]=pc.prototype[Symbol.iterator]=function(){return this});const vc=ut.define(),wc=q.define({create:()=>!0,update(t,e){for(let i of e.effects)i.is(vc)&&(t=i.value);return t},provide:t=>Fr.from(t,(t=>t?gc:null))}),yc=Ds.baseTheme({\".cm-panel.cm-gotoLine\":{padding:\"2px 6px 4px\",\"& label\":{fontSize:\"80%\"}}}),bc={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},xc=N.define({combine:t=>Ct(t,bc,{highlightWordAroundCursor:(t,e)=>t||e,minSelectionLength:Math.min,maxMatches:Math.min})});function kc(t){let e=[Mc,Oc];return t&&e.push(xc.of(t)),e}const Sc=ii.mark({class:\"cm-selectionMatch\"}),Cc=ii.mark({class:\"cm-selectionMatch cm-selectionMatch-main\"});function Ac(t,e,i,n){return!(0!=i&&t(e.sliceDoc(i-1,i))==yt.Word||n!=e.doc.length&&t(e.sliceDoc(n,n+1))==yt.Word)}const Oc=Mi.fromClass(class{constructor(t){this.decorations=this.getDeco(t)}update(t){(t.selectionSet||t.docChanged||t.viewportChanged)&&(this.decorations=this.getDeco(t.view))}getDeco(t){let e=t.state.facet(xc),{state:i}=t,n=i.selection;if(n.ranges.length>1)return ii.none;let s,r=n.main,o=null;if(r.empty){if(!e.highlightWordAroundCursor)return ii.none;let t=i.wordAt(r.head);if(!t)return ii.none;o=i.charCategorizer(r.head),s=i.sliceDoc(t.from,t.to)}else{let t=r.to-r.from;if(t<e.minSelectionLength||t>200)return ii.none;if(e.wholeWords){if(s=i.sliceDoc(r.from,r.to),o=i.charCategorizer(r.head),!Ac(o,i,r.from,r.to)||!function(t,e,i,n){return t(e.sliceDoc(i,i+1))==yt.Word&&t(e.sliceDoc(n-1,n))==yt.Word}(o,i,r.from,r.to))return ii.none}else if(s=i.sliceDoc(r.from,r.to).trim(),!s)return ii.none}let l=[];for(let n of t.visibleRanges){let t=new ac(i.doc,s,n.from,n.to);for(;!t.next().done;){let{from:n,to:s}=t.value;if((!o||Ac(o,i,n,s))&&(r.empty&&n<=r.from&&s>=r.to?l.push(Cc.range(n,s)):(n>=r.to||s<=r.from)&&l.push(Sc.range(n,s)),l.length>e.maxMatches))return ii.none}}return ii.set(l)}},{decorations:t=>t.decorations}),Mc=Ds.baseTheme({\".cm-selectionMatch\":{backgroundColor:\"#99ff7780\"},\".cm-searchMatch .cm-selectionMatch\":{backgroundColor:\"transparent\"}});const Dc=N.define({combine:t=>Ct(t,{top:!1,caseSensitive:!1,literal:!1,wholeWord:!1,createPanel:t=>new eu(t)})});class Tc{constructor(t){this.search=t.search,this.caseSensitive=!!t.caseSensitive,this.literal=!!t.literal,this.regexp=!!t.regexp,this.replace=t.replace||\"\",this.valid=!!this.search&&(!this.regexp||function(t){try{return new RegExp(t,cc),!0}catch(t){return!1}}(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!t.wholeWord}unquote(t){return this.literal?t:t.replace(/\\\\([nrt\\\\])/g,((t,e)=>\"n\"==e?\"\\n\":\"r\"==e?\"\\r\":\"t\"==e?\"\\t\":\"\\\\\"))}eq(t){return this.search==t.search&&this.replace==t.replace&&this.caseSensitive==t.caseSensitive&&this.regexp==t.regexp&&this.wholeWord==t.wholeWord}create(){return this.regexp?new Ic(this):new Ec(this)}getCursor(t,e=0,i){let n=t.doc?t:St.create({doc:t});return null==i&&(i=n.doc.length),this.regexp?Bc(this,n,e,i):Rc(this,n,e,i)}}class Pc{constructor(t){this.spec=t}}function Rc(t,e,i,n){return new ac(e.doc,t.unquoted,i,n,t.caseSensitive?void 0:t=>t.toLowerCase(),t.wholeWord?function(t,e){return(i,n,s,r)=>((r>i||r+s.length<n)&&(r=Math.max(0,i-2),s=t.sliceString(r,Math.min(t.length,n+2))),!(e(Lc(s,i-r))==yt.Word&&e(Nc(s,i-r))==yt.Word||e(Nc(s,n-r))==yt.Word&&e(Lc(s,n-r))==yt.Word))}(e.doc,e.charCategorizer(e.selection.main.head)):void 0)}class Ec extends Pc{constructor(t){super(t)}nextMatch(t,e,i){let n=Rc(this.spec,t,i,t.doc.length).nextOverlapping();return n.done&&(n=Rc(this.spec,t,0,e).nextOverlapping()),n.done?null:n.value}prevMatchInRange(t,e,i){for(let n=i;;){let i=Math.max(e,n-1e4-this.spec.unquoted.length),s=Rc(this.spec,t,i,n),r=null;for(;!s.nextOverlapping().done;)r=s.value;if(r)return r;if(i==e)return null;n-=1e4}}prevMatch(t,e,i){return this.prevMatchInRange(t,0,e)||this.prevMatchInRange(t,i,t.doc.length)}getReplacement(t){return this.spec.unquote(this.spec.replace)}matchAll(t,e){let i=Rc(this.spec,t,0,t.doc.length),n=[];for(;!i.next().done;){if(n.length>=e)return null;n.push(i.value)}return n}highlight(t,e,i,n){let s=Rc(this.spec,t,Math.max(0,e-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,t.doc.length));for(;!s.next().done;)n(s.value.from,s.value.to)}}function Bc(t,e,i,n){return new uc(e.doc,t.search,{ignoreCase:!t.caseSensitive,test:t.wholeWord?(s=e.charCategorizer(e.selection.main.head),(t,e,i)=>!i[0].length||(s(Lc(i.input,i.index))!=yt.Word||s(Nc(i.input,i.index))!=yt.Word)&&(s(Nc(i.input,i.index+i[0].length))!=yt.Word||s(Lc(i.input,i.index+i[0].length))!=yt.Word)):void 0},i,n);var s}function Lc(t,e){return t.slice(d(t,e,!1),e)}function Nc(t,e){return t.slice(e,d(t,e))}class Ic extends Pc{nextMatch(t,e,i){let n=Bc(this.spec,t,i,t.doc.length).next();return n.done&&(n=Bc(this.spec,t,0,e).next()),n.done?null:n.value}prevMatchInRange(t,e,i){for(let n=1;;n++){let s=Math.max(e,i-1e4*n),r=Bc(this.spec,t,s,i),o=null;for(;!r.next().done;)o=r.value;if(o&&(s==e||o.from>s+10))return o;if(s==e)return null}}prevMatch(t,e,i){return this.prevMatchInRange(t,0,e)||this.prevMatchInRange(t,i,t.doc.length)}getReplacement(t){return this.spec.unquote(this.spec.replace.replace(/\\$([$&\\d+])/g,((e,i)=>\"$\"==i?\"$\":\"&\"==i?t.match[0]:\"0\"!=i&&+i<t.match.length?t.match[i]:e)))}matchAll(t,e){let i=Bc(this.spec,t,0,t.doc.length),n=[];for(;!i.next().done;){if(n.length>=e)return null;n.push(i.value)}return n}highlight(t,e,i,n){let s=Bc(this.spec,t,Math.max(0,e-250),Math.min(i+250,t.doc.length));for(;!s.next().done;)n(s.value.from,s.value.to)}}const Vc=ut.define(),Wc=ut.define(),zc=q.define({create:t=>new Hc(Xc(t).create(),null),update(t,e){for(let i of e.effects)i.is(Vc)?t=new Hc(i.value.create(),t.panel):i.is(Wc)&&(t=new Hc(t.query,i.value?Jc:null));return t},provide:t=>Fr.from(t,(t=>t.panel))});class Hc{constructor(t,e){this.query=t,this.panel=e}}const Fc=ii.mark({class:\"cm-searchMatch\"}),qc=ii.mark({class:\"cm-searchMatch cm-searchMatch-selected\"}),_c=Mi.fromClass(class{constructor(t){this.view=t,this.decorations=this.highlight(t.state.field(zc))}update(t){let e=t.state.field(zc);(e!=t.startState.field(zc)||t.docChanged||t.selectionSet||t.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:t,panel:e}){if(!e||!t.spec.valid)return ii.none;let{view:i}=this,n=new Pt;for(let e=0,s=i.visibleRanges,r=s.length;e<r;e++){let{from:o,to:l}=s[e];for(;e<r-1&&l>s[e+1].from-500;)l=s[++e].to;t.highlight(i.state,o,l,((t,e)=>{let s=i.state.selection.ranges.some((i=>i.from==t&&i.to==e));n.add(t,e,s?qc:Fc)}))}return n.finish()}},{decorations:t=>t.decorations});function jc(t){return e=>{let i=e.state.field(zc,!1);return i&&i.query.spec.valid?t(e,i):Zc(e)}}const Uc=jc(((t,{query:e})=>{let{to:i}=t.state.selection.main,n=e.nextMatch(t.state,i,i);return!!n&&(t.dispatch({selection:{anchor:n.from,head:n.to},scrollIntoView:!0,effects:su(t,n),userEvent:\"select.search\"}),!0)})),$c=jc(((t,{query:e})=>{let{state:i}=t,{from:n}=i.selection.main,s=e.prevMatch(i,n,n);return!!s&&(t.dispatch({selection:{anchor:s.from,head:s.to},scrollIntoView:!0,effects:su(t,s),userEvent:\"select.search\"}),!0)})),Qc=jc(((t,{query:e})=>{let i=e.matchAll(t.state,1e3);return!(!i||!i.length)&&(t.dispatch({selection:E.create(i.map((t=>E.range(t.from,t.to)))),userEvent:\"select.search.matches\"}),!0)})),Kc=jc(((t,{query:e})=>{let{state:i}=t,{from:n,to:s}=i.selection.main;if(i.readOnly)return!1;let r=e.nextMatch(i,n,n);if(!r)return!1;let o,l,a=[],h=[];if(r.from==n&&r.to==s&&(l=i.toText(e.getReplacement(r)),a.push({from:r.from,to:r.to,insert:l}),r=e.nextMatch(i,r.from,r.to),h.push(Ds.announce.of(i.phrase(\"replaced match on line $\",i.doc.lineAt(n).number)+\".\"))),r){let e=0==a.length||a[0].from>=r.to?0:r.to-r.from-l.length;o={anchor:r.from-e,head:r.to-e},h.push(su(t,r))}return t.dispatch({changes:a,selection:o,scrollIntoView:!!o,effects:h,userEvent:\"input.replace\"}),!0})),Gc=jc(((t,{query:e})=>{if(t.state.readOnly)return!1;let i=e.matchAll(t.state,1e9).map((t=>{let{from:i,to:n}=t;return{from:i,to:n,insert:e.getReplacement(t)}}));if(!i.length)return!1;let n=t.state.phrase(\"replaced $ matches\",i.length)+\".\";return t.dispatch({changes:i,effects:Ds.announce.of(n),userEvent:\"input.replace.all\"}),!0}));function Jc(t){return t.state.facet(Dc).createPanel(t)}function Xc(t,e){var i,n,s,r;let o=t.selection.main,l=o.empty||o.to>o.from+100?\"\":t.sliceDoc(o.from,o.to);if(e&&!l)return e;let a=t.facet(Dc);return new Tc({search:(null!==(i=null==e?void 0:e.literal)&&void 0!==i?i:a.literal)?l:l.replace(/\\n/g,\"\\\\n\"),caseSensitive:null!==(n=null==e?void 0:e.caseSensitive)&&void 0!==n?n:a.caseSensitive,literal:null!==(s=null==e?void 0:e.literal)&&void 0!==s?s:a.literal,wholeWord:null!==(r=null==e?void 0:e.wholeWord)&&void 0!==r?r:a.wholeWord})}const Zc=t=>{let e=t.state.field(zc,!1);if(e&&e.panel){let i=Vr(t,Jc);if(!i)return!1;let n=i.dom.querySelector(\"[main-field]\");if(n&&n!=t.root.activeElement){let i=Xc(t.state,e.query.spec);i.valid&&t.dispatch({effects:Vc.of(i)}),n.focus(),n.select()}}else t.dispatch({effects:[Wc.of(!0),e?Vc.of(Xc(t.state,e.query.spec)):ut.appendConfig.of(ou)]});return!0},Yc=t=>{let e=t.state.field(zc,!1);if(!e||!e.panel)return!1;let i=Vr(t,Jc);return i&&i.dom.contains(t.root.activeElement)&&t.focus(),t.dispatch({effects:Wc.of(!1)}),!0},tu=[{key:\"Mod-f\",run:Zc,scope:\"editor search-panel\"},{key:\"F3\",run:Uc,shift:$c,scope:\"editor search-panel\",preventDefault:!0},{key:\"Mod-g\",run:Uc,shift:$c,scope:\"editor search-panel\",preventDefault:!0},{key:\"Escape\",run:Yc,scope:\"editor search-panel\"},{key:\"Mod-Shift-l\",run:({state:t,dispatch:e})=>{let i=t.selection;if(i.ranges.length>1||i.main.empty)return!1;let{from:n,to:s}=i.main,r=[],o=0;for(let e=new ac(t.doc,t.sliceDoc(n,s));!e.next().done;){if(r.length>1e3)return!1;e.value.from==n&&(o=r.length),r.push(E.range(e.value.from,e.value.to))}return e(t.update({selection:E.create(r,o),userEvent:\"select.search.matches\"})),!0}},{key:\"Alt-g\",run:t=>{let e=Vr(t,gc);if(!e){let i=[vc.of(!0)];null==t.state.field(wc,!1)&&i.push(ut.appendConfig.of([wc,yc])),t.dispatch({effects:i}),e=Vr(t,gc)}return e&&e.dom.querySelector(\"input\").focus(),!0}},{key:\"Mod-d\",run:({state:t,dispatch:e})=>{let{ranges:i}=t.selection;if(i.some((t=>t.from===t.to)))return(({state:t,dispatch:e})=>{let{selection:i}=t,n=E.create(i.ranges.map((e=>t.wordAt(e.head)||E.cursor(e.head))),i.mainIndex);return!n.eq(i)&&(e(t.update({selection:n})),!0)})({state:t,dispatch:e});let n=t.sliceDoc(i[0].from,i[0].to);if(t.selection.ranges.some((e=>t.sliceDoc(e.from,e.to)!=n)))return!1;let s=function(t,e){let{main:i,ranges:n}=t.selection,s=t.wordAt(i.head),r=s&&s.from==i.from&&s.to==i.to;for(let i=!1,s=new ac(t.doc,e,n[n.length-1].to);;){if(s.next(),!s.done){if(i&&n.some((t=>t.from==s.value.from)))continue;if(r){let e=t.wordAt(s.value.from);if(!e||e.from!=s.value.from||e.to!=s.value.to)continue}return s.value}if(i)return null;s=new ac(t.doc,e,0,Math.max(0,n[n.length-1].from-1)),i=!0}}(t,n);return!!s&&(e(t.update({selection:t.selection.addRange(E.range(s.from,s.to),!1),effects:Ds.scrollIntoView(s.to)})),!0)},preventDefault:!0}];class eu{constructor(t){this.view=t;let e=this.query=t.state.field(zc).query.spec;function i(t,e,i){return rc(\"button\",{class:\"cm-button\",name:t,onclick:e,type:\"button\"},i)}this.commit=this.commit.bind(this),this.searchField=rc(\"input\",{value:e.search,placeholder:iu(t,\"Find\"),\"aria-label\":iu(t,\"Find\"),class:\"cm-textfield\",name:\"search\",form:\"\",\"main-field\":\"true\",onchange:this.commit,onkeyup:this.commit}),this.replaceField=rc(\"input\",{value:e.replace,placeholder:iu(t,\"Replace\"),\"aria-label\":iu(t,\"Replace\"),class:\"cm-textfield\",name:\"replace\",form:\"\",onchange:this.commit,onkeyup:this.commit}),this.caseField=rc(\"input\",{type:\"checkbox\",name:\"case\",form:\"\",checked:e.caseSensitive,onchange:this.commit}),this.reField=rc(\"input\",{type:\"checkbox\",name:\"re\",form:\"\",checked:e.regexp,onchange:this.commit}),this.wordField=rc(\"input\",{type:\"checkbox\",name:\"word\",form:\"\",checked:e.wholeWord,onchange:this.commit}),this.dom=rc(\"div\",{onkeydown:t=>this.keydown(t),class:\"cm-search\"},[this.searchField,i(\"next\",(()=>Uc(t)),[iu(t,\"next\")]),i(\"prev\",(()=>$c(t)),[iu(t,\"previous\")]),i(\"select\",(()=>Qc(t)),[iu(t,\"all\")]),rc(\"label\",null,[this.caseField,iu(t,\"match case\")]),rc(\"label\",null,[this.reField,iu(t,\"regexp\")]),rc(\"label\",null,[this.wordField,iu(t,\"by word\")]),...t.state.readOnly?[]:[rc(\"br\"),this.replaceField,i(\"replace\",(()=>Kc(t)),[iu(t,\"replace\")]),i(\"replaceAll\",(()=>Gc(t)),[iu(t,\"replace all\")])],rc(\"button\",{name:\"close\",onclick:()=>Yc(t),\"aria-label\":iu(t,\"close\"),type:\"button\"},[\"×\"])])}commit(){let t=new Tc({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});t.eq(this.query)||(this.query=t,this.view.dispatch({effects:Vc.of(t)}))}keydown(t){var e,i,n;e=this.view,i=t,n=\"search-panel\",Hs(Ws(e.state),i,e,n)?t.preventDefault():13==t.keyCode&&t.target==this.searchField?(t.preventDefault(),(t.shiftKey?$c:Uc)(this.view)):13==t.keyCode&&t.target==this.replaceField&&(t.preventDefault(),Kc(this.view))}update(t){for(let e of t.transactions)for(let t of e.effects)t.is(Vc)&&!t.value.eq(this.query)&&this.setQuery(t.value)}setQuery(t){this.query=t,this.searchField.value=t.search,this.replaceField.value=t.replace,this.caseField.checked=t.caseSensitive,this.reField.checked=t.regexp,this.wordField.checked=t.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(Dc).top}}function iu(t,e){return t.state.phrase(e)}const nu=/[\\s\\.,:;?!]/;function su(t,{from:e,to:i}){let n=t.state.doc.lineAt(e),s=t.state.doc.lineAt(i).to,r=Math.max(n.from,e-30),o=Math.min(s,i+30),l=t.state.sliceDoc(r,o);if(r!=n.from)for(let t=0;t<30;t++)if(!nu.test(l[t+1])&&nu.test(l[t])){l=l.slice(t);break}if(o!=s)for(let t=l.length-1;t>l.length-30;t--)if(!nu.test(l[t-1])&&nu.test(l[t])){l=l.slice(0,t);break}return Ds.announce.of(`${t.state.phrase(\"current match\")}. ${l} ${t.state.phrase(\"on line\")} ${n.number}.`)}const ru=Ds.baseTheme({\".cm-panel.cm-search\":{padding:\"2px 6px 4px\",position:\"relative\",\"& [name=close]\":{position:\"absolute\",top:\"0\",right:\"4px\",backgroundColor:\"inherit\",border:\"none\",font:\"inherit\",padding:0,margin:0},\"& input, & button, & label\":{margin:\".2em .6em .2em 0\"},\"& input[type=checkbox]\":{marginRight:\".2em\"},\"& label\":{fontSize:\"80%\",whiteSpace:\"pre\"}},\"&light .cm-searchMatch\":{backgroundColor:\"#ffff0054\"},\"&dark .cm-searchMatch\":{backgroundColor:\"#00ffff8a\"},\"&light .cm-searchMatch-selected\":{backgroundColor:\"#ff6a0054\"},\"&dark .cm-searchMatch-selected\":{backgroundColor:\"#ff00ff8a\"}}),ou=[zc,K.lowest(_c),ru];class lu{constructor(t,e,i){this.state=t,this.pos=e,this.explicit=i,this.abortListeners=[]}tokenBefore(t){let e=vl(this.state).resolveInner(this.pos,-1);for(;e&&t.indexOf(e.name)<0;)e=e.parent;return e?{from:e.from,to:this.pos,text:this.state.sliceDoc(e.from,this.pos),type:e.type}:null}matchBefore(t){let e=this.state.doc.lineAt(this.pos),i=Math.max(e.from,this.pos-250),n=e.text.slice(i-e.from,this.pos-e.from),s=n.search(fu(t,!1));return s<0?null:{from:i+s,to:this.pos,text:n.slice(s)}}get aborted(){return null==this.abortListeners}addEventListener(t,e){\"abort\"==t&&this.abortListeners&&this.abortListeners.push(e)}}function au(t){let e=Object.keys(t).join(\"\"),i=/\\w/.test(e);return i&&(e=e.replace(/\\w/g,\"\")),`[${i?\"\\\\w\":\"\"}${e.replace(/[^\\w\\s]/g,\"\\\\$&\")}]`}function hu(t){let e=t.map((t=>\"string\"==typeof t?{label:t}:t)),[i,n]=e.every((t=>/^\\w+$/.test(t.label)))?[/\\w*$/,/\\w+$/]:function(t){let e=Object.create(null),i=Object.create(null);for(let{label:n}of t){e[n[0]]=!0;for(let t=1;t<n.length;t++)i[n[t]]=!0}let n=au(e)+au(i)+\"*$\";return[new RegExp(\"^\"+n),new RegExp(n)]}(e);return t=>{let s=t.matchBefore(n);return s||t.explicit?{from:s?s.from:t.pos,options:e,validFor:i}:null}}class cu{constructor(t,e,i){this.completion=t,this.source=e,this.match=i}}function uu(t){return t.selection.main.head}function fu(t,e){var i;let{source:n}=t,s=e&&\"^\"!=n[0],r=\"$\"!=n[n.length-1];return s||r?new RegExp(`${s?\"^\":\"\"}(?:${n})${r?\"$\":\"\"}`,null!==(i=t.flags)&&void 0!==i?i:t.ignoreCase?\"i\":\"\"):t}const du=at.define();function pu(t,e){const i=e.completion.apply||e.completion.label;let n=e.source;var s,r,o,l;\"string\"==typeof i?t.dispatch(Object.assign(Object.assign({},(s=t.state,r=i,o=n.from,l=n.to,Object.assign(Object.assign({},s.changeByRange((t=>{if(t==s.selection.main)return{changes:{from:o,to:l,insert:r},range:E.cursor(o+r.length)};let e=l-o;return!t.empty||e&&s.sliceDoc(t.from-e,t.from)!=s.sliceDoc(o,l)?{range:t}:{changes:{from:t.from-e,to:t.from,insert:r},range:E.cursor(t.from-e+r.length)}}))),{userEvent:\"input.complete\"}))),{annotations:du.of(e.completion)})):i(t,e.completion,n.from,n.to)}const mu=new WeakMap;function gu(t){if(!Array.isArray(t))return t;let e=mu.get(t);return e||mu.set(t,e=hu(t)),e}class vu{constructor(t){this.pattern=t,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[];for(let e=0;e<t.length;){let i=w(t,e),n=b(i);this.chars.push(i);let s=t.slice(e,e+n),r=s.toUpperCase();this.folded.push(w(r==s?s.toLowerCase():r,0)),e+=n}this.astral=t.length!=this.chars.length}match(t){if(0==this.pattern.length)return[0];if(t.length<this.pattern.length)return null;let{chars:e,folded:i,any:n,precise:s,byWord:r}=this;if(1==e.length){let n=w(t,0);return n==e[0]?[0,0,b(n)]:n==i[0]?[-200,0,b(n)]:null}let o=t.indexOf(this.pattern);if(0==o)return[0,0,this.pattern.length];let l=e.length,a=0;if(o<0){for(let s=0,r=Math.min(t.length,200);s<r&&a<l;){let r=w(t,s);r!=e[a]&&r!=i[a]||(n[a++]=s),s+=b(r)}if(a<l)return null}let h=0,c=0,u=!1,f=0,d=-1,p=-1,m=/[a-z]/.test(t),g=!0;for(let n=0,a=Math.min(t.length,200),v=0;n<a&&c<l;){let a=w(t,n);o<0&&(h<l&&a==e[h]&&(s[h++]=n),f<l&&(a==e[f]||a==i[f]?(0==f&&(d=n),p=n+1,f++):f=0));let x,k=a<255?a>=48&&a<=57||a>=97&&a<=122?2:a>=65&&a<=90?1:0:(x=y(a))!=x.toLowerCase()?1:x!=x.toUpperCase()?2:0;(!n||1==k&&m||0==v&&0!=k)&&(e[c]==a||i[c]==a&&(u=!0)?r[c++]=n:r.length&&(g=!1)),v=k,n+=b(a)}return c==l&&0==r[0]&&g?this.result((u?-200:0)-100,r,t):f==l&&0==d?[-200-t.length,0,p]:o>-1?[-700-t.length,o,o+this.pattern.length]:f==l?[-900-t.length,d,p]:c==l?this.result((u?-200:0)-100-700+(g?0:-1100),r,t):2==e.length?null:this.result((n[0]?-700:0)-200-1100,n,t)}result(t,e,i){let n=[t-i.length],s=1;for(let t of e){let e=t+(this.astral?b(w(i,t)):1);s>1&&n[s-1]==t?n[s-1]=e:(n[s++]=t,n[s++]=e)}return n}}const wu=N.define({combine:t=>Ct(t,{activateOnTyping:!0,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,optionClass:()=>\"\",aboveCursor:!1,icons:!0,addToOptions:[],compareCompletions:(t,e)=>t.label.localeCompare(e.label),interactionDelay:75},{defaultKeymap:(t,e)=>t&&e,closeOnBlur:(t,e)=>t&&e,icons:(t,e)=>t&&e,optionClass:(t,e)=>i=>function(t,e){return t?e?t+\" \"+e:t:e}(t(i),e(i)),addToOptions:(t,e)=>t.concat(e)})});function yu(t,e,i){if(t<=i)return{from:0,to:t};if(e<0&&(e=0),e<=t>>1){let t=Math.floor(e/i);return{from:t*i,to:(t+1)*i}}let n=Math.floor((t-e)/i);return{from:t-(n+1)*i,to:t-n*i}}class bu{constructor(t,e){this.view=t,this.stateField=e,this.info=null,this.placeInfo={read:()=>this.measureInfo(),write:t=>this.positionInfo(t),key:this},this.space=null;let i=t.state.field(e),{options:n,selected:s}=i.open,r=t.state.facet(wu);this.optionContent=function(t){let e=t.addToOptions.slice();return t.icons&&e.push({render(t){let e=document.createElement(\"div\");return e.classList.add(\"cm-completionIcon\"),t.type&&e.classList.add(...t.type.split(/\\s+/g).map((t=>\"cm-completionIcon-\"+t))),e.setAttribute(\"aria-hidden\",\"true\"),e},position:20}),e.push({render(t,e,i){let n=document.createElement(\"span\");n.className=\"cm-completionLabel\";let{label:s}=t,r=0;for(let t=1;t<i.length;){let e=i[t++],o=i[t++];e>r&&n.appendChild(document.createTextNode(s.slice(r,e)));let l=n.appendChild(document.createElement(\"span\"));l.appendChild(document.createTextNode(s.slice(e,o))),l.className=\"cm-completionMatchedText\",r=o}return r<s.length&&n.appendChild(document.createTextNode(s.slice(r))),n},position:50},{render(t){if(!t.detail)return null;let e=document.createElement(\"span\");return e.className=\"cm-completionDetail\",e.textContent=t.detail,e},position:80}),e.sort(((t,e)=>t.position-e.position)).map((t=>t.render))}(r),this.optionClass=r.optionClass,this.range=yu(n.length,s,r.maxRenderedOptions),this.dom=document.createElement(\"div\"),this.dom.className=\"cm-tooltip-autocomplete\",this.dom.addEventListener(\"mousedown\",(e=>{for(let i,s=e.target;s&&s!=this.dom;s=s.parentNode)if(\"LI\"==s.nodeName&&(i=/-(\\d+)$/.exec(s.id))&&+i[1]<n.length)return pu(t,n[+i[1]]),void e.preventDefault()})),this.list=this.dom.appendChild(this.createListBox(n,i.id,this.range)),this.list.addEventListener(\"scroll\",(()=>{this.info&&this.view.requestMeasure(this.placeInfo)}))}mount(){this.updateSel()}update(t){var e,i,n;let s=t.state.field(this.stateField),r=t.startState.field(this.stateField);s!=r&&(this.updateSel(),(null===(e=s.open)||void 0===e?void 0:e.disabled)!=(null===(i=r.open)||void 0===i?void 0:i.disabled)&&this.dom.classList.toggle(\"cm-tooltip-autocomplete-disabled\",!!(null===(n=s.open)||void 0===n?void 0:n.disabled)))}positioned(t){this.space=t,this.info&&this.view.requestMeasure(this.placeInfo)}updateSel(){let t=this.view.state.field(this.stateField),e=t.open;if((e.selected>-1&&e.selected<this.range.from||e.selected>=this.range.to)&&(this.range=yu(e.options.length,e.selected,this.view.state.facet(wu).maxRenderedOptions),this.list.remove(),this.list=this.dom.appendChild(this.createListBox(e.options,t.id,this.range)),this.list.addEventListener(\"scroll\",(()=>{this.info&&this.view.requestMeasure(this.placeInfo)}))),this.updateSelectedOption(e.selected)){this.info&&(this.info.remove(),this.info=null);let{completion:i}=e.options[e.selected],{info:n}=i;if(!n)return;let s=\"string\"==typeof n?document.createTextNode(n):n(i);if(!s)return;\"then\"in s?s.then((e=>{e&&this.view.state.field(this.stateField,!1)==t&&this.addInfoPane(e)})).catch((t=>Si(this.view.state,t,\"completion info\"))):this.addInfoPane(s)}}addInfoPane(t){let e=this.info=document.createElement(\"div\");e.className=\"cm-tooltip cm-completionInfo\",e.appendChild(t),this.dom.appendChild(e),this.view.requestMeasure(this.placeInfo)}updateSelectedOption(t){let e=null;for(let i=this.list.firstChild,n=this.range.from;i;i=i.nextSibling,n++)n==t?i.hasAttribute(\"aria-selected\")||(i.setAttribute(\"aria-selected\",\"true\"),e=i):i.hasAttribute(\"aria-selected\")&&i.removeAttribute(\"aria-selected\");return e&&function(t,e){let i=t.getBoundingClientRect(),n=e.getBoundingClientRect();n.top<i.top?t.scrollTop-=i.top-n.top:n.bottom>i.bottom&&(t.scrollTop+=n.bottom-i.bottom)}(this.list,e),e}measureInfo(){let t=this.dom.querySelector(\"[aria-selected]\");if(!t||!this.info)return null;let e=this.dom.getBoundingClientRect(),i=this.info.getBoundingClientRect(),n=t.getBoundingClientRect(),s=this.space;if(!s){let t=this.dom.ownerDocument.defaultView||window;s={left:0,top:0,right:t.innerWidth,bottom:t.innerHeight}}if(n.top>Math.min(s.bottom,e.bottom)-10||n.bottom<Math.max(s.top,e.top)+10)return null;let r,o=this.view.textDirection==Vi.RTL,l=o,a=!1,h=\"\",c=\"\",u=e.left-s.left,f=s.right-e.right;if(l&&u<Math.min(i.width,f)?l=!1:!l&&f<Math.min(i.width,u)&&(l=!0),i.width<=(l?u:f))h=Math.max(s.top,Math.min(n.top,s.bottom-i.height))-e.top+\"px\",r=Math.min(400,l?u:f)+\"px\";else{a=!0,r=Math.min(400,(o?e.right:s.right-e.left)-30)+\"px\";let t=s.bottom-e.bottom;t>=i.height||t>e.top?h=n.bottom-e.top+\"px\":c=e.bottom-n.top+\"px\"}return{top:h,bottom:c,maxWidth:r,class:a?o?\"left-narrow\":\"right-narrow\":l?\"left\":\"right\"}}positionInfo(t){this.info&&(t?(this.info.style.top=t.top,this.info.style.bottom=t.bottom,this.info.style.maxWidth=t.maxWidth,this.info.className=\"cm-tooltip cm-completionInfo cm-completionInfo-\"+t.class):this.info.style.top=\"-1e6px\")}createListBox(t,e,i){const n=document.createElement(\"ul\");n.id=e,n.setAttribute(\"role\",\"listbox\"),n.setAttribute(\"aria-expanded\",\"true\"),n.setAttribute(\"aria-label\",this.view.state.phrase(\"Completions\"));for(let s=i.from;s<i.to;s++){let{completion:i,match:r}=t[s];const o=n.appendChild(document.createElement(\"li\"));o.id=e+\"-\"+s,o.setAttribute(\"role\",\"option\");let l=this.optionClass(i);l&&(o.className=l);for(let t of this.optionContent){let e=t(i,this.view.state,r);e&&o.appendChild(e)}}return i.from&&n.classList.add(\"cm-completionListIncompleteTop\"),i.to<t.length&&n.classList.add(\"cm-completionListIncompleteBottom\"),n}}function xu(t){return 100*(t.boost||0)+(t.apply?10:0)+(t.info?5:0)+(t.type?1:0)}class ku{constructor(t,e,i,n,s,r){this.options=t,this.attrs=e,this.tooltip=i,this.timestamp=n,this.selected=s,this.disabled=r}setSelected(t,e){return t==this.selected||t>=this.options.length?this:new ku(this.options,Au(e,t),this.tooltip,this.timestamp,t,this.disabled)}static build(t,e,i,n,s){let r=function(t,e){let i=[],n=0;for(let s of t)if(s.hasResult())if(!1===s.result.filter){let t=s.result.getMatch;for(let e of s.result.options){let r=[1e9-n++];if(t)for(let i of t(e))r.push(i);i.push(new cu(e,s,r))}}else{let t,n=new vu(e.sliceDoc(s.from,s.to));for(let e of s.result.options)(t=n.match(e.label))&&(null!=e.boost&&(t[0]+=e.boost),i.push(new cu(e,s,t)))}let s=[],r=null,o=e.facet(wu).compareCompletions;for(let t of i.sort(((t,e)=>e.match[0]-t.match[0]||o(t.completion,e.completion))))!r||r.label!=t.completion.label||r.detail!=t.completion.detail||null!=r.type&&null!=t.completion.type&&r.type!=t.completion.type||r.apply!=t.completion.apply?s.push(t):xu(t.completion)>xu(r)&&(s[s.length-1]=t),r=t.completion;return s}(t,e);if(!r.length)return n&&t.some((t=>1==t.state))?new ku(n.options,n.attrs,n.tooltip,n.timestamp,n.selected,!0):null;let o=e.facet(wu).selectOnOpen?0:-1;if(n&&n.selected!=o&&-1!=n.selected){let t=n.options[n.selected].completion;for(let e=0;e<r.length;e++)if(r[e].completion==t){o=e;break}}return new ku(r,Au(i,o),{pos:t.reduce(((t,e)=>e.hasResult()?Math.min(t,e.from):t),1e8),create:(l=Lu,t=>new bu(t,l)),above:s.aboveCursor},n?n.timestamp:Date.now(),o,!1);var l}map(t){return new ku(this.options,this.attrs,Object.assign(Object.assign({},this.tooltip),{pos:t.mapPos(this.tooltip.pos)}),this.timestamp,this.selected,this.disabled)}}class Su{constructor(t,e,i){this.active=t,this.id=e,this.open=i}static start(){return new Su(Ou,\"cm-ac-\"+Math.floor(2e6*Math.random()).toString(36),null)}update(t){let{state:e}=t,i=e.facet(wu),n=(i.override||e.languageDataAt(\"autocomplete\",uu(e)).map(gu)).map((e=>(this.active.find((t=>t.source==e))||new Du(e,this.active.some((t=>0!=t.state))?1:0)).update(t,i)));n.length==this.active.length&&n.every(((t,e)=>t==this.active[e]))&&(n=this.active);let s=this.open;t.selection||n.some((e=>e.hasResult()&&t.changes.touchesRange(e.from,e.to)))||!function(t,e){if(t==e)return!0;for(let i=0,n=0;;){for(;i<t.length&&!t[i].hasResult;)i++;for(;n<e.length&&!e[n].hasResult;)n++;let s=i==t.length,r=n==e.length;if(s||r)return s==r;if(t[i++].result!=e[n++].result)return!1}}(n,this.active)?s=ku.build(n,e,this.id,this.open,i):s&&s.disabled&&!n.some((t=>1==t.state))?s=null:s&&t.docChanged&&(s=s.map(t.changes)),!s&&n.every((t=>1!=t.state))&&n.some((t=>t.hasResult()))&&(n=n.map((t=>t.hasResult()?new Du(t.source,0):t)));for(let e of t.effects)e.is(Bu)&&(s=s&&s.setSelected(e.value,this.id));return n==this.active&&s==this.open?this:new Su(n,this.id,s)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:Cu}}const Cu={\"aria-autocomplete\":\"list\"};function Au(t,e){let i={\"aria-autocomplete\":\"list\",\"aria-haspopup\":\"listbox\",\"aria-controls\":t};return e>-1&&(i[\"aria-activedescendant\"]=t+\"-\"+e),i}const Ou=[];function Mu(t){return t.isUserEvent(\"input.type\")?\"input\":t.isUserEvent(\"delete.backward\")?\"delete\":null}class Du{constructor(t,e,i=-1){this.source=t,this.state=e,this.explicitPos=i}hasResult(){return!1}update(t,e){let i=Mu(t),n=this;i?n=n.handleUserEvent(t,i,e):t.docChanged?n=n.handleChange(t):t.selection&&0!=n.state&&(n=new Du(n.source,0));for(let e of t.effects)if(e.is(Pu))n=new Du(n.source,1,e.value?uu(t.state):-1);else if(e.is(Ru))n=new Du(n.source,0);else if(e.is(Eu))for(let t of e.value)t.source==n.source&&(n=t);return n}handleUserEvent(t,e,i){return\"delete\"!=e&&i.activateOnTyping?new Du(this.source,1):this.map(t.changes)}handleChange(t){return t.changes.touchesRange(uu(t.startState))?new Du(this.source,0):this.map(t.changes)}map(t){return t.empty||this.explicitPos<0?this:new Du(this.source,this.state,t.mapPos(this.explicitPos))}}class Tu extends Du{constructor(t,e,i,n,s){super(t,2,e),this.result=i,this.from=n,this.to=s}hasResult(){return!0}handleUserEvent(t,e,i){var n;let s=t.changes.mapPos(this.from),r=t.changes.mapPos(this.to,1),o=uu(t.state);if((this.explicitPos<0?o<=s:o<this.from)||o>r||\"delete\"==e&&uu(t.startState)==this.from)return new Du(this.source,\"input\"==e&&i.activateOnTyping?1:0);let l,a=this.explicitPos<0?-1:t.changes.mapPos(this.explicitPos);return function(t,e,i,n){if(!t)return!1;let s=e.sliceDoc(i,n);return\"function\"==typeof t?t(s,i,n,e):fu(t,!0).test(s)}(this.result.validFor,t.state,s,r)?new Tu(this.source,a,this.result,s,r):this.result.update&&(l=this.result.update(this.result,s,r,new lu(t.state,o,a>=0)))?new Tu(this.source,a,l,l.from,null!==(n=l.to)&&void 0!==n?n:uu(t.state)):new Du(this.source,1,a)}handleChange(t){return t.changes.touchesRange(this.from,this.to)?new Du(this.source,0):this.map(t.changes)}map(t){return t.empty?this:new Tu(this.source,this.explicitPos<0?-1:t.mapPos(this.explicitPos),this.result,t.mapPos(this.from),t.mapPos(this.to,1))}}const Pu=ut.define(),Ru=ut.define(),Eu=ut.define({map:(t,e)=>t.map((t=>t.map(e)))}),Bu=ut.define(),Lu=q.define({create:()=>Su.start(),update:(t,e)=>t.update(e),provide:t=>[Dr.from(t,(t=>t.tooltip)),Ds.contentAttributes.from(t,(t=>t.attrs))]});function Nu(t,e=\"option\"){return i=>{let n=i.state.field(Lu,!1);if(!n||!n.open||n.open.disabled||Date.now()-n.open.timestamp<i.state.facet(wu).interactionDelay)return!1;let s,r=1;\"page\"==e&&(s=function(t,e){let i=t.plugin(Ar);if(!i)return null;let n=i.manager.tooltips.indexOf(e);return n<0?null:i.manager.tooltipViews[n]}(i,n.open.tooltip))&&(r=Math.max(2,Math.floor(s.dom.offsetHeight/s.dom.querySelector(\"li\").offsetHeight)-1));let{length:o}=n.open.options,l=n.open.selected>-1?n.open.selected+r*(t?1:-1):t?0:o-1;return l<0?l=\"page\"==e?0:o-1:l>=o&&(l=\"page\"==e?o-1:0),i.dispatch({effects:Bu.of(l)}),!0}}class Iu{constructor(t,e){this.active=t,this.context=e,this.time=Date.now(),this.updates=[],this.done=void 0}}const Vu=Mi.fromClass(class{constructor(t){this.view=t,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.composing=0;for(let e of t.state.field(Lu).active)1==e.state&&this.startQuery(e)}update(t){let e=t.state.field(Lu);if(!t.selectionSet&&!t.docChanged&&t.startState.field(Lu)==e)return;let i=t.transactions.some((t=>(t.selection||t.docChanged)&&!Mu(t)));for(let e=0;e<this.running.length;e++){let n=this.running[e];if(i||n.updates.length+t.transactions.length>50&&Date.now()-n.time>1e3){for(let t of n.context.abortListeners)try{t()}catch(t){Si(this.view.state,t)}n.context.abortListeners=null,this.running.splice(e--,1)}else n.updates.push(...t.transactions)}if(this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),this.debounceUpdate=e.active.some((t=>1==t.state&&!this.running.some((e=>e.active.source==t.source))))?setTimeout((()=>this.startUpdate()),50):-1,0!=this.composing)for(let e of t.transactions)\"input\"==Mu(e)?this.composing=2:2==this.composing&&e.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1;let{state:t}=this.view,e=t.field(Lu);for(let t of e.active)1!=t.state||this.running.some((e=>e.active.source==t.source))||this.startQuery(t)}startQuery(t){let{state:e}=this.view,i=uu(e),n=new lu(e,i,t.explicitPos==i),s=new Iu(t,n);this.running.push(s),Promise.resolve(t.source(n)).then((t=>{s.context.aborted||(s.done=t||null,this.scheduleAccept())}),(t=>{this.view.dispatch({effects:Ru.of(null)}),Si(this.view.state,t)}))}scheduleAccept(){this.running.every((t=>void 0!==t.done))?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout((()=>this.accept()),50))}accept(){var t;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],i=this.view.state.facet(wu);for(let n=0;n<this.running.length;n++){let s=this.running[n];if(void 0===s.done)continue;if(this.running.splice(n--,1),s.done){let n=new Tu(s.active.source,s.active.explicitPos,s.done,s.done.from,null!==(t=s.done.to)&&void 0!==t?t:uu(s.updates.length?s.updates[0].startState:this.view.state));for(let t of s.updates)n=n.update(t,i);if(n.hasResult()){e.push(n);continue}}let r=this.view.state.field(Lu).active.find((t=>t.source==s.active.source));if(r&&1==r.state)if(null==s.done){let t=new Du(s.active.source,0);for(let e of s.updates)t=t.update(e,i);1!=t.state&&e.push(t)}else this.startQuery(r)}e.length&&this.view.dispatch({effects:Eu.of(e)})}},{eventHandlers:{blur(){let t=this.view.state.field(Lu,!1);t&&t.tooltip&&this.view.state.facet(wu).closeOnBlur&&this.view.dispatch({effects:Ru.of(null)})},compositionstart(){this.composing=1},compositionend(){3==this.composing&&setTimeout((()=>this.view.dispatch({effects:Pu.of(!1)})),20),this.composing=0}}}),Wu=Ds.baseTheme({\".cm-tooltip.cm-tooltip-autocomplete\":{\"& > ul\":{fontFamily:\"monospace\",whiteSpace:\"nowrap\",overflow:\"hidden auto\",maxWidth_fallback:\"700px\",maxWidth:\"min(700px, 95vw)\",minWidth:\"250px\",maxHeight:\"10em\",listStyle:\"none\",margin:0,padding:0,\"& > li\":{overflowX:\"hidden\",textOverflow:\"ellipsis\",cursor:\"pointer\",padding:\"1px 3px\",lineHeight:1.2}}},\"&light .cm-tooltip-autocomplete ul li[aria-selected]\":{background:\"#17c\",color:\"white\"},\"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]\":{background:\"#777\"},\"&dark .cm-tooltip-autocomplete ul li[aria-selected]\":{background:\"#347\",color:\"white\"},\"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]\":{background:\"#444\"},\".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after\":{content:'\"···\"',opacity:.5,display:\"block\",textAlign:\"center\"},\".cm-tooltip.cm-completionInfo\":{position:\"absolute\",padding:\"3px 9px\",width:\"max-content\",maxWidth:\"400px\",boxSizing:\"border-box\"},\".cm-completionInfo.cm-completionInfo-left\":{right:\"100%\"},\".cm-completionInfo.cm-completionInfo-right\":{left:\"100%\"},\".cm-completionInfo.cm-completionInfo-left-narrow\":{right:\"30px\"},\".cm-completionInfo.cm-completionInfo-right-narrow\":{left:\"30px\"},\"&light .cm-snippetField\":{backgroundColor:\"#00000022\"},\"&dark .cm-snippetField\":{backgroundColor:\"#ffffff22\"},\".cm-snippetFieldPosition\":{verticalAlign:\"text-top\",width:0,height:\"1.15em\",display:\"inline-block\",margin:\"0 -0.7px -.7em\",borderLeft:\"1.4px dotted #888\"},\".cm-completionMatchedText\":{textDecoration:\"underline\"},\".cm-completionDetail\":{marginLeft:\"0.5em\",fontStyle:\"italic\"},\".cm-completionIcon\":{fontSize:\"90%\",width:\".8em\",display:\"inline-block\",textAlign:\"center\",paddingRight:\".6em\",opacity:\"0.6\"},\".cm-completionIcon-function, .cm-completionIcon-method\":{\"&:after\":{content:\"'ƒ'\"}},\".cm-completionIcon-class\":{\"&:after\":{content:\"'○'\"}},\".cm-completionIcon-interface\":{\"&:after\":{content:\"'◌'\"}},\".cm-completionIcon-variable\":{\"&:after\":{content:\"'𝑥'\"}},\".cm-completionIcon-constant\":{\"&:after\":{content:\"'𝐶'\"}},\".cm-completionIcon-type\":{\"&:after\":{content:\"'𝑡'\"}},\".cm-completionIcon-enum\":{\"&:after\":{content:\"'∪'\"}},\".cm-completionIcon-property\":{\"&:after\":{content:\"'□'\"}},\".cm-completionIcon-keyword\":{\"&:after\":{content:\"'🔑︎'\"}},\".cm-completionIcon-namespace\":{\"&:after\":{content:\"'▢'\"}},\".cm-completionIcon-text\":{\"&:after\":{content:\"'abc'\",fontSize:\"50%\",verticalAlign:\"middle\"}}}),zu={brackets:[\"(\",\"[\",\"{\",\"'\",'\"'],before:\")]}:;>\",stringPrefixes:[]},Hu=ut.define({map(t,e){let i=e.mapPos(t,-1,k.TrackAfter);return null==i?void 0:i}}),Fu=ut.define({map:(t,e)=>e.mapPos(t)}),qu=new class extends At{};qu.startSide=1,qu.endSide=-1;const _u=q.define({create:()=>Tt.empty,update(t,e){if(e.selection){let i=e.state.doc.lineAt(e.selection.main.head).from,n=e.startState.doc.lineAt(e.startState.selection.main.head).from;i!=e.changes.mapPos(n,-1)&&(t=Tt.empty)}t=t.map(e.changes);for(let i of e.effects)i.is(Hu)?t=t.update({add:[qu.range(i.value,i.value+1)]}):i.is(Fu)&&(t=t.update({filter:t=>t!=i.value}));return t}});const ju=\"()[]{}<>\";function Uu(t){for(let e=0;e<ju.length;e+=2)if(ju.charCodeAt(e)==t)return ju.charAt(e+1);return y(t<128?t:t+1)}function $u(t,e){return t.languageDataAt(\"closeBrackets\",e)[0]||zu}const Qu=\"object\"==typeof navigator&&/Android\\b/.test(navigator.userAgent),Ku=Ds.inputHandler.of(((t,e,i,n)=>{if((Qu?t.composing:t.compositionStarted)||t.state.readOnly)return!1;let s=t.state.selection.main;if(n.length>2||2==n.length&&1==b(w(n,0))||e!=s.from||i!=s.to)return!1;let r=function(t,e){let i=$u(t,t.selection.main.head),n=i.brackets||zu.brackets;for(let s of n){let r=Uu(w(s,0));if(e==s)return r==s?tf(t,s,n.indexOf(s+s+s)>-1,i):Zu(t,s,r,i.before||zu.before);if(e==r&&Ju(t,t.selection.main.from))return Yu(t,s,r)}return null}(t.state,n);return!!r&&(t.dispatch(r),!0)})),Gu=[{key:\"Backspace\",run:({state:t,dispatch:e})=>{if(t.readOnly)return!1;let i=$u(t,t.selection.main.head).brackets||zu.brackets,n=null,s=t.changeByRange((e=>{if(e.empty){let n=function(t,e){let i=t.sliceString(e-2,e);return b(w(i,0))==i.length?i:i.slice(1)}(t.doc,e.head);for(let s of i)if(s==n&&Xu(t.doc,e.head)==Uu(w(s,0)))return{changes:{from:e.head-s.length,to:e.head+s.length},range:E.cursor(e.head-s.length)}}return{range:n=e}}));return n||e(t.update(s,{scrollIntoView:!0,userEvent:\"delete.backward\"})),!n}}];function Ju(t,e){let i=!1;return t.field(_u).between(0,t.doc.length,(t=>{t==e&&(i=!0)})),i}function Xu(t,e){let i=t.sliceString(e,e+2);return i.slice(0,b(w(i,0)))}function Zu(t,e,i,n){let s=null,r=t.changeByRange((r=>{if(!r.empty)return{changes:[{insert:e,from:r.from},{insert:i,from:r.to}],effects:Hu.of(r.to+e.length),range:E.range(r.anchor+e.length,r.head+e.length)};let o=Xu(t.doc,r.head);return!o||/\\s/.test(o)||n.indexOf(o)>-1?{changes:{insert:e+i,from:r.head},effects:Hu.of(r.head+e.length),range:E.cursor(r.head+e.length)}:{range:s=r}}));return s?null:t.update(r,{scrollIntoView:!0,userEvent:\"input.type\"})}function Yu(t,e,i){let n=null,s=t.selection.ranges.map((e=>e.empty&&Xu(t.doc,e.head)==i?E.cursor(e.head+i.length):n=e));return n?null:t.update({selection:E.create(s,t.selection.mainIndex),scrollIntoView:!0,effects:t.selection.ranges.map((({from:t})=>Fu.of(t)))})}function tf(t,e,i,n){let s=n.stringPrefixes||zu.stringPrefixes,r=null,o=t.changeByRange((n=>{if(!n.empty)return{changes:[{insert:e,from:n.from},{insert:e,from:n.to}],effects:Hu.of(n.to+e.length),range:E.range(n.anchor+e.length,n.head+e.length)};let o,l=n.head,a=Xu(t.doc,l);if(a==e){if(ef(t,l))return{changes:{insert:e+e,from:l},effects:Hu.of(l+e.length),range:E.cursor(l+e.length)};if(Ju(t,l)){let n=i&&t.sliceDoc(l,l+3*e.length)==e+e+e;return{range:E.cursor(l+e.length*(n?3:1)),effects:Fu.of(l)}}}else{if(i&&t.sliceDoc(l-2*e.length,l)==e+e&&(o=nf(t,l-2*e.length,s))>-1&&ef(t,o))return{changes:{insert:e+e+e+e,from:l},effects:Hu.of(l+e.length),range:E.cursor(l+e.length)};if(t.charCategorizer(l)(a)!=yt.Word&&nf(t,l,s)>-1&&!function(t,e,i,n){let s=vl(t).resolveInner(e,-1),r=n.reduce(((t,e)=>Math.max(t,e.length)),0);for(let o=0;o<5;o++){let o=t.sliceDoc(s.from,Math.min(s.to,s.from+i.length+r)),l=o.indexOf(i);if(!l||l>-1&&n.indexOf(o.slice(0,l))>-1){let e=s.firstChild;for(;e&&e.from==s.from&&e.to-e.from>i.length+l;){if(t.sliceDoc(e.to-i.length,e.to)==i)return!1;e=e.firstChild}return!0}let a=s.to==e&&s.parent;if(!a)break;s=a}return!1}(t,l,e,s))return{changes:{insert:e+e,from:l},effects:Hu.of(l+e.length),range:E.cursor(l+e.length)}}return{range:r=n}}));return r?null:t.update(o,{scrollIntoView:!0,userEvent:\"input.type\"})}function ef(t,e){let i=vl(t).resolveInner(e+1);return i.parent&&i.from==e}function nf(t,e,i){let n=t.charCategorizer(e);if(n(t.sliceDoc(e-1,e))!=yt.Word)return e;for(let s of i){let i=e-s.length;if(t.sliceDoc(i,e)==s&&n(t.sliceDoc(i-1,i))!=yt.Word)return i}return-1}function sf(t={}){return[Lu,wu.of(t),Vu,of,Wu]}const rf=[{key:\"Ctrl-Space\",run:t=>!!t.state.field(Lu,!1)&&(t.dispatch({effects:Pu.of(!0)}),!0)},{key:\"Escape\",run:t=>{let e=t.state.field(Lu,!1);return!(!e||!e.active.some((t=>0!=t.state)))&&(t.dispatch({effects:Ru.of(null)}),!0)}},{key:\"ArrowDown\",run:Nu(!0)},{key:\"ArrowUp\",run:Nu(!1)},{key:\"PageDown\",run:Nu(!0,\"page\")},{key:\"PageUp\",run:Nu(!1,\"page\")},{key:\"Enter\",run:t=>{let e=t.state.field(Lu,!1);return!(t.state.readOnly||!e||!e.open||e.open.selected<0||Date.now()-e.open.timestamp<t.state.facet(wu).interactionDelay)&&(e.open.disabled||pu(t,e.open.options[e.open.selected]),!0)}}],of=K.highest(Is.computeN([wu],(t=>t.facet(wu).defaultKeymap?[rf]:[])));class lf{constructor(t,e,i){this.from=t,this.to=e,this.diagnostic=i}}class af{constructor(t,e,i){this.diagnostics=t,this.panel=e,this.selected=i}static init(t,e,i){let n=t,s=i.facet(kf).markerFilter;s&&(n=s(n));let r=ii.set(n.map((t=>t.from==t.to||t.from==t.to-1&&i.doc.lineAt(t.from).to==t.from?ii.widget({widget:new Af(t),diagnostic:t}).range(t.from):ii.mark({attributes:{class:\"cm-lintRange cm-lintRange-\"+t.severity},diagnostic:t}).range(t.from,t.to))),!0);return new af(r,e,hf(r))}}function hf(t,e=null,i=0){let n=null;return t.between(i,1e9,((t,i,{spec:s})=>{if(!e||s.diagnostic==e)return n=new lf(t,i,s.diagnostic),!1})),n}function cf(t,e){return!(!t.effects.some((t=>t.is(ff)))&&!t.changes.touchesRange(e.pos))}function uf(t,e){return t.field(mf,!1)?e:e.concat(ut.appendConfig.of([mf,Ds.decorations.compute([mf],(t=>{let{selected:e,panel:i}=t.field(mf);return e&&i&&e.from!=e.to?ii.set([gf.range(e.from,e.to)]):ii.none})),Lr(vf,{hideOn:cf}),Tf]))}const ff=ut.define(),df=ut.define(),pf=ut.define(),mf=q.define({create:()=>new af(ii.none,null,null),update(t,e){if(e.docChanged){let i=t.diagnostics.map(e.changes),n=null;if(t.selected){let s=e.changes.mapPos(t.selected.from,1);n=hf(i,t.selected.diagnostic,s)||hf(i,null,s)}t=new af(i,t.panel,n)}for(let i of e.effects)i.is(ff)?t=af.init(i.value,t.panel,e.state):i.is(df)?t=new af(t.diagnostics,i.value?Mf.open:null,t.selected):i.is(pf)&&(t=new af(t.diagnostics,t.panel,i.value));return t},provide:t=>[Fr.from(t,(t=>t.panel)),Ds.decorations.from(t,(t=>t.diagnostics))]}),gf=ii.mark({class:\"cm-lintRange cm-lintRange-active\"});function vf(t,e,i){let{diagnostics:n}=t.state.field(mf),s=[],r=2e8,o=0;n.between(e-(i<0?1:0),e+(i>0?1:0),((t,n,{spec:l})=>{e>=t&&e<=n&&(t==n||(e>t||i>0)&&(e<n||i<0))&&(s.push(l.diagnostic),r=Math.min(t,r),o=Math.max(n,o))}));let l=t.state.facet(kf).tooltipFilter;return l&&(s=l(s)),s.length?{pos:r,end:o,above:t.state.doc.lineAt(r).to<o,create:()=>({dom:wf(t,s)})}:null}function wf(t,e){return rc(\"ul\",{class:\"cm-tooltip-lint\"},e.map((e=>Cf(t,e,!1))))}const yf=t=>{let e=t.state.field(mf,!1);return!(!e||!e.panel)&&(t.dispatch({effects:df.of(!1)}),!0)},bf=[{key:\"Mod-Shift-m\",run:t=>{let e=t.state.field(mf,!1);e&&e.panel||t.dispatch({effects:uf(t.state,[df.of(!0)])});let i=Vr(t,Mf.open);return i&&i.dom.querySelector(\".cm-panel-lint ul\").focus(),!0},preventDefault:!0},{key:\"F8\",run:t=>{let e=t.state.field(mf,!1);if(!e)return!1;let i=t.state.selection.main,n=e.diagnostics.iter(i.to+1);return!(!n.value&&(n=e.diagnostics.iter(0),!n.value||n.from==i.from&&n.to==i.to))&&(t.dispatch({selection:{anchor:n.from,head:n.to},scrollIntoView:!0}),!0)}}],xf=Mi.fromClass(class{constructor(t){this.view=t,this.timeout=-1,this.set=!0;let{delay:e}=t.state.facet(kf);this.lintTime=Date.now()+e,this.run=this.run.bind(this),this.timeout=setTimeout(this.run,e)}run(){let t=Date.now();if(t<this.lintTime-10)setTimeout(this.run,this.lintTime-t);else{this.set=!1;let{state:t}=this.view,{sources:e}=t.facet(kf);Promise.all(e.map((t=>Promise.resolve(t(this.view))))).then((e=>{let i=e.reduce(((t,e)=>t.concat(e)));this.view.state.doc==t.doc&&this.view.dispatch(function(t,e){return{effects:uf(t,[ff.of(e)])}}(this.view.state,i))}),(t=>{Si(this.view.state,t)}))}}update(t){let e=t.state.facet(kf);(t.docChanged||e!=t.startState.facet(kf))&&(this.lintTime=Date.now()+e.delay,this.set||(this.set=!0,this.timeout=setTimeout(this.run,e.delay)))}force(){this.set&&(this.lintTime=Date.now(),this.run())}destroy(){clearTimeout(this.timeout)}}),kf=N.define({combine:t=>Object.assign({sources:t.map((t=>t.source))},Ct(t.map((t=>t.config)),{delay:750,markerFilter:null,tooltipFilter:null})),enables:xf});function Sf(t){let e=[];if(t)t:for(let{name:i}of t){for(let t=0;t<i.length;t++){let n=i[t];if(/[a-zA-Z]/.test(n)&&!e.some((t=>t.toLowerCase()==n.toLowerCase()))){e.push(n);continue t}}e.push(\"\")}return e}function Cf(t,e,i){var n;let s=i?Sf(e.actions):[];return rc(\"li\",{class:\"cm-diagnostic cm-diagnostic-\"+e.severity},rc(\"span\",{class:\"cm-diagnosticText\"},e.renderMessage?e.renderMessage():e.message),null===(n=e.actions)||void 0===n?void 0:n.map(((i,n)=>{let r=n=>{n.preventDefault();let s=hf(t.state.field(mf).diagnostics,e);s&&i.apply(t,s.from,s.to)},{name:o}=i,l=s[n]?o.indexOf(s[n]):-1,a=l<0?o:[o.slice(0,l),rc(\"u\",o.slice(l,l+1)),o.slice(l+1)];return rc(\"button\",{type:\"button\",class:\"cm-diagnosticAction\",onclick:r,onmousedown:r,\"aria-label\":` Action: ${o}${l<0?\"\":` (access key \"${s[n]})\"`}.`},a)})),e.source&&rc(\"div\",{class:\"cm-diagnosticSource\"},e.source))}class Af extends ti{constructor(t){super(),this.diagnostic=t}eq(t){return t.diagnostic==this.diagnostic}toDOM(){return rc(\"span\",{class:\"cm-lintPoint cm-lintPoint-\"+this.diagnostic.severity})}}class Of{constructor(t,e){this.diagnostic=e,this.id=\"item_\"+Math.floor(4294967295*Math.random()).toString(16),this.dom=Cf(t,e,!0),this.dom.id=this.id,this.dom.setAttribute(\"role\",\"option\")}}class Mf{constructor(t){this.view=t,this.items=[];this.list=rc(\"ul\",{tabIndex:0,role:\"listbox\",\"aria-label\":this.view.state.phrase(\"Diagnostics\"),onkeydown:e=>{if(27==e.keyCode)yf(this.view),this.view.focus();else if(38==e.keyCode||33==e.keyCode)this.moveSelection((this.selectedIndex-1+this.items.length)%this.items.length);else if(40==e.keyCode||34==e.keyCode)this.moveSelection((this.selectedIndex+1)%this.items.length);else if(36==e.keyCode)this.moveSelection(0);else if(35==e.keyCode)this.moveSelection(this.items.length-1);else if(13==e.keyCode)this.view.focus();else{if(!(e.keyCode>=65&&e.keyCode<=90&&this.selectedIndex>=0))return;{let{diagnostic:i}=this.items[this.selectedIndex],n=Sf(i.actions);for(let s=0;s<n.length;s++)if(n[s].toUpperCase().charCodeAt(0)==e.keyCode){let e=hf(this.view.state.field(mf).diagnostics,i);e&&i.actions[s].apply(t,e.from,e.to)}}}e.preventDefault()},onclick:t=>{for(let e=0;e<this.items.length;e++)this.items[e].dom.contains(t.target)&&this.moveSelection(e)}}),this.dom=rc(\"div\",{class:\"cm-panel-lint\"},this.list,rc(\"button\",{type:\"button\",name:\"close\",\"aria-label\":this.view.state.phrase(\"close\"),onclick:()=>yf(this.view)},\"×\")),this.update()}get selectedIndex(){let t=this.view.state.field(mf).selected;if(!t)return-1;for(let e=0;e<this.items.length;e++)if(this.items[e].diagnostic==t.diagnostic)return e;return-1}update(){let{diagnostics:t,selected:e}=this.view.state.field(mf),i=0,n=!1,s=null;for(t.between(0,this.view.state.doc.length,((t,r,{spec:o})=>{let l,a=-1;for(let t=i;t<this.items.length;t++)if(this.items[t].diagnostic==o.diagnostic){a=t;break}a<0?(l=new Of(this.view,o.diagnostic),this.items.splice(i,0,l),n=!0):(l=this.items[a],a>i&&(this.items.splice(i,a-i),n=!0)),e&&l.diagnostic==e.diagnostic?l.dom.hasAttribute(\"aria-selected\")||(l.dom.setAttribute(\"aria-selected\",\"true\"),s=l):l.dom.hasAttribute(\"aria-selected\")&&l.dom.removeAttribute(\"aria-selected\"),i++}));i<this.items.length&&!(1==this.items.length&&this.items[0].diagnostic.from<0);)n=!0,this.items.pop();0==this.items.length&&(this.items.push(new Of(this.view,{from:-1,to:-1,severity:\"info\",message:this.view.state.phrase(\"No diagnostics\")})),n=!0),s?(this.list.setAttribute(\"aria-activedescendant\",s.id),this.view.requestMeasure({key:this,read:()=>({sel:s.dom.getBoundingClientRect(),panel:this.list.getBoundingClientRect()}),write:({sel:t,panel:e})=>{t.top<e.top?this.list.scrollTop-=e.top-t.top:t.bottom>e.bottom&&(this.list.scrollTop+=t.bottom-e.bottom)}})):this.selectedIndex<0&&this.list.removeAttribute(\"aria-activedescendant\"),n&&this.sync()}sync(){let t=this.list.firstChild;function e(){let e=t;t=e.nextSibling,e.remove()}for(let i of this.items)if(i.dom.parentNode==this.list){for(;t!=i.dom;)e();t=i.dom.nextSibling}else this.list.insertBefore(i.dom,t);for(;t;)e()}moveSelection(t){if(this.selectedIndex<0)return;let e=hf(this.view.state.field(mf).diagnostics,this.items[t].diagnostic);e&&this.view.dispatch({selection:{anchor:e.from,head:e.to},scrollIntoView:!0,effects:pf.of(e)})}static open(t){return new Mf(t)}}function Df(t){return function(t,e='viewBox=\"0 0 40 40\"'){return`url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" ${e}>${encodeURIComponent(t)}</svg>')`}(`<path d=\"m0 2.5 l2 -1.5 l1 0 l2 1.5 l1 0\" stroke=\"${t}\" fill=\"none\" stroke-width=\".7\"/>`,'width=\"6\" height=\"3\"')}const Tf=Ds.baseTheme({\".cm-diagnostic\":{padding:\"3px 6px 3px 8px\",marginLeft:\"-1px\",display:\"block\",whiteSpace:\"pre-wrap\"},\".cm-diagnostic-error\":{borderLeft:\"5px solid #d11\"},\".cm-diagnostic-warning\":{borderLeft:\"5px solid orange\"},\".cm-diagnostic-info\":{borderLeft:\"5px solid #999\"},\".cm-diagnosticAction\":{font:\"inherit\",border:\"none\",padding:\"2px 4px\",backgroundColor:\"#444\",color:\"white\",borderRadius:\"3px\",marginLeft:\"8px\"},\".cm-diagnosticSource\":{fontSize:\"70%\",opacity:.7},\".cm-lintRange\":{backgroundPosition:\"left bottom\",backgroundRepeat:\"repeat-x\",paddingBottom:\"0.7px\"},\".cm-lintRange-error\":{backgroundImage:Df(\"#d11\")},\".cm-lintRange-warning\":{backgroundImage:Df(\"orange\")},\".cm-lintRange-info\":{backgroundImage:Df(\"#999\")},\".cm-lintRange-active\":{backgroundColor:\"#ffdd9980\"},\".cm-tooltip-lint\":{padding:0,margin:0},\".cm-lintPoint\":{position:\"relative\",\"&:after\":{content:'\"\"',position:\"absolute\",bottom:0,left:\"-2px\",borderLeft:\"3px solid transparent\",borderRight:\"3px solid transparent\",borderBottom:\"4px solid #d11\"}},\".cm-lintPoint-warning\":{\"&:after\":{borderBottomColor:\"orange\"}},\".cm-lintPoint-info\":{\"&:after\":{borderBottomColor:\"#999\"}},\".cm-panel.cm-panel-lint\":{position:\"relative\",\"& ul\":{maxHeight:\"100px\",overflowY:\"auto\",\"& [aria-selected]\":{backgroundColor:\"#ddd\",\"& u\":{textDecoration:\"underline\"}},\"&:focus [aria-selected]\":{background_fallback:\"#bdf\",backgroundColor:\"Highlight\",color_fallback:\"white\",color:\"HighlightText\"},\"& u\":{textDecoration:\"none\"},padding:0,margin:0},\"& [name=close]\":{position:\"absolute\",top:\"0\",right:\"2px\",background:\"inherit\",border:\"none\",font:\"inherit\",padding:0,margin:0}}}),Pf=(()=>[oo(),ho,ar(),Qa(),la(),_s(),[Ys,tr],St.allowMultipleSelections.of(!0),St.transactionFilter.of((t=>{if(!t.docChanged||!t.isUserEvent(\"input.type\")&&!t.isUserEvent(\"input.complete\"))return t;let e=t.startState.languageDataAt(\"indentOnInput\",t.startState.selection.main.head);if(!e.length)return t;let i=t.newDoc,{head:n}=t.newSelection.main,s=i.lineAt(n);if(n>s.from+200)return t;let r=i.sliceString(s.from,n);if(!e.some((t=>t.test(r))))return t;let{state:o}=t,l=-1,a=[];for(let{head:t}of o.selection.ranges){let e=o.doc.lineAt(t);if(e.from==l)continue;l=e.from;let i=El(o,e.from);if(null==i)continue;let n=/^\\s*/.exec(e.text)[0],s=Rl(o,i);n!=s&&a.push({from:e.from,to:e.from+n.length,insert:s})}return a.length?[t,{changes:a,sequential:!0}]:t})),da(ga,{fallback:!0}),Aa(),[Ku,_u],sf(),vr(),br(),dr,kc(),Is.of([...Gu,...sc,...tu,...hh,...ta,...rf,...bf])])();class Rf{constructor(t,e,i,n,s,r,o,l,a,h=0,c){this.p=t,this.stack=e,this.state=i,this.reducePos=n,this.pos=s,this.score=r,this.buffer=o,this.bufferBase=l,this.curContext=a,this.lookAhead=h,this.parent=c}toString(){return`[${this.stack.filter(((t,e)=>e%3==0)).concat(this.state)}]@${this.pos}${this.score?\"!\"+this.score:\"\"}`}static start(t,e,i=0){let n=t.parser.context;return new Rf(t,[],e,i,i,0,[],0,n?new Ef(n,n.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(t,e){this.stack.push(this.state,e,this.bufferBase+this.buffer.length),this.state=t}reduce(t){let e=t>>19,i=65535&t,{parser:n}=this.p,s=n.dynamicPrecedence(i);if(s&&(this.score+=s),0==e)return this.pushState(n.getGoto(this.state,i,!0),this.reducePos),i<n.minRepeatTerm&&this.storeNode(i,this.reducePos,this.reducePos,4,!0),void this.reduceContext(i,this.reducePos);let r=this.stack.length-3*(e-1)-(262144&t?6:0),o=this.stack[r-2],l=this.stack[r-1],a=this.bufferBase+this.buffer.length-l;if(i<n.minRepeatTerm||131072&t){let t=n.stateFlag(this.state,1)?this.pos:this.reducePos;this.storeNode(i,o,t,a+4,!0)}if(262144&t)this.state=this.stack[r];else{let t=this.stack[r-3];this.state=n.getGoto(t,i,!0)}for(;this.stack.length>r;)this.stack.pop();this.reduceContext(i,o)}storeNode(t,e,i,n=4,s=!1){if(0==t&&(!this.stack.length||this.stack[this.stack.length-1]<this.buffer.length+this.bufferBase)){let t=this,n=this.buffer.length;if(0==n&&t.parent&&(n=t.bufferBase-t.parent.bufferBase,t=t.parent),n>0&&0==t.buffer[n-4]&&t.buffer[n-1]>-1){if(e==i)return;if(t.buffer[n-2]>=e)return void(t.buffer[n-2]=i)}}if(s&&this.pos!=i){let s=this.buffer.length;if(s>0&&0!=this.buffer[s-4])for(;s>0&&this.buffer[s-2]>i;)this.buffer[s]=this.buffer[s-4],this.buffer[s+1]=this.buffer[s-3],this.buffer[s+2]=this.buffer[s-2],this.buffer[s+3]=this.buffer[s-1],s-=4,n>4&&(n-=4);this.buffer[s]=t,this.buffer[s+1]=e,this.buffer[s+2]=i,this.buffer[s+3]=n}else this.buffer.push(t,e,i,n)}shift(t,e,i){let n=this.pos;if(131072&t)this.pushState(65535&t,this.pos);else if(0==(262144&t)){let s=t,{parser:r}=this.p;(i>this.pos||e<=r.maxNode)&&(this.pos=i,r.stateFlag(s,1)||(this.reducePos=i)),this.pushState(s,n),this.shiftContext(e,n),e<=r.maxNode&&this.buffer.push(e,n,i,4)}else this.pos=i,this.shiftContext(e,n),e<=this.p.parser.maxNode&&this.buffer.push(e,n,i,4)}apply(t,e,i){65536&t?this.reduce(t):this.shift(t,e,i)}useNode(t,e){let i=this.p.reused.length-1;(i<0||this.p.reused[i]!=t)&&(this.p.reused.push(t),i++);let n=this.pos;this.reducePos=this.pos=n+t.length,this.pushState(e,n),this.buffer.push(i,n,this.reducePos,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,t,this,this.p.stream.reset(this.pos-t.length)))}split(){let t=this,e=t.buffer.length;for(;e>0&&t.buffer[e-2]>t.reducePos;)e-=4;let i=t.buffer.slice(e),n=t.bufferBase+e;for(;t&&n==t.bufferBase;)t=t.parent;return new Rf(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,i,n,this.curContext,this.lookAhead,t)}recoverByDelete(t,e){let i=t<=this.p.parser.maxNode;i&&this.storeNode(t,this.pos,e,4),this.storeNode(0,this.pos,e,i?8:4),this.pos=this.reducePos=e,this.score-=190}canShift(t){for(let e=new Lf(this);;){let i=this.p.parser.stateSlot(e.state,4)||this.p.parser.hasAction(e.state,t);if(0==(65536&i))return!0;if(0==i)return!1;e.reduce(i)}}recoverByInsert(t){if(this.stack.length>=300)return[];let e=this.p.parser.nextStates(this.state);if(e.length>8||this.stack.length>=120){let i=[];for(let n,s=0;s<e.length;s+=2)(n=e[s+1])!=this.state&&this.p.parser.hasAction(n,t)&&i.push(e[s],n);if(this.stack.length<120)for(let t=0;i.length<8&&t<e.length;t+=2){let n=e[t+1];i.some(((t,e)=>1&e&&t==n))||i.push(e[t],n)}e=i}let i=[];for(let t=0;t<e.length&&i.length<4;t+=2){let n=e[t+1];if(n==this.state)continue;let s=this.split();s.pushState(n,this.pos),s.storeNode(0,s.pos,s.pos,4,!0),s.shiftContext(e[t],this.pos),s.score-=200,i.push(s)}return i}forceReduce(){let t=this.p.parser.stateSlot(this.state,5);if(0==(65536&t))return!1;let{parser:e}=this.p;if(!e.validAction(this.state,t)){let i=t>>19,n=65535&t,s=this.stack.length-3*i;if(s<0||e.getGoto(this.stack[s],n,!1)<0)return!1;this.storeNode(0,this.reducePos,this.reducePos,4,!0),this.score-=100}return this.reducePos=this.pos,this.reduce(t),!0}forceAll(){for(;!this.p.parser.stateFlag(this.state,2);)if(!this.forceReduce()){this.storeNode(0,this.pos,this.pos,4,!0);break}return this}get deadEnd(){if(3!=this.stack.length)return!1;let{parser:t}=this.p;return 65535==t.data[t.stateSlot(this.state,1)]&&!t.stateSlot(this.state,4)}restart(){this.state=this.stack[0],this.stack.length=0}sameState(t){if(this.state!=t.state||this.stack.length!=t.stack.length)return!1;for(let e=0;e<this.stack.length;e+=3)if(this.stack[e]!=t.stack[e])return!1;return!0}get parser(){return this.p.parser}dialectEnabled(t){return this.p.parser.dialect.flags[t]}shiftContext(t,e){this.curContext&&this.updateContext(this.curContext.tracker.shift(this.curContext.context,t,this,this.p.stream.reset(e)))}reduceContext(t,e){this.curContext&&this.updateContext(this.curContext.tracker.reduce(this.curContext.context,t,this,this.p.stream.reset(e)))}emitContext(){let t=this.buffer.length-1;(t<0||-3!=this.buffer[t])&&this.buffer.push(this.curContext.hash,this.reducePos,this.reducePos,-3)}emitLookAhead(){let t=this.buffer.length-1;(t<0||-4!=this.buffer[t])&&this.buffer.push(this.lookAhead,this.reducePos,this.reducePos,-4)}updateContext(t){if(t!=this.curContext.context){let e=new Ef(this.curContext.tracker,t);e.hash!=this.curContext.hash&&this.emitContext(),this.curContext=e}}setLookAhead(t){t>this.lookAhead&&(this.emitLookAhead(),this.lookAhead=t)}close(){this.curContext&&this.curContext.tracker.strict&&this.emitContext(),this.lookAhead>0&&this.emitLookAhead()}}class Ef{constructor(t,e){this.tracker=t,this.context=e,this.hash=t.strict?t.hash(e):0}}var Bf;!function(t){t[t.Insert=200]=\"Insert\",t[t.Delete=190]=\"Delete\",t[t.Reduce=100]=\"Reduce\",t[t.MaxNext=4]=\"MaxNext\",t[t.MaxInsertStackDepth=300]=\"MaxInsertStackDepth\",t[t.DampenInsertStackDepth=120]=\"DampenInsertStackDepth\"}(Bf||(Bf={}));class Lf{constructor(t){this.start=t,this.state=t.state,this.stack=t.stack,this.base=this.stack.length}reduce(t){let e=65535&t,i=t>>19;0==i?(this.stack==this.start.stack&&(this.stack=this.stack.slice()),this.stack.push(this.state,0,0),this.base+=3):this.base-=3*(i-1);let n=this.start.p.parser.getGoto(this.stack[this.base-3],e,!0);this.state=n}}class Nf{constructor(t,e,i){this.stack=t,this.pos=e,this.index=i,this.buffer=t.buffer,0==this.index&&this.maybeNext()}static create(t,e=t.bufferBase+t.buffer.length){return new Nf(t,e,e-t.bufferBase)}maybeNext(){let t=this.stack.parent;null!=t&&(this.index=this.stack.bufferBase-t.bufferBase,this.stack=t,this.buffer=t.buffer)}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}next(){this.index-=4,this.pos-=4,0==this.index&&this.maybeNext()}fork(){return new Nf(this.stack,this.pos,this.index)}}class If{constructor(){this.start=-1,this.value=-1,this.end=-1,this.extended=-1,this.lookAhead=0,this.mask=0,this.context=0}}const Vf=new If;class Wf{constructor(t,e){this.input=t,this.ranges=e,this.chunk=\"\",this.chunkOff=0,this.chunk2=\"\",this.chunk2Pos=0,this.next=-1,this.token=Vf,this.rangeIndex=0,this.pos=this.chunkPos=e[0].from,this.range=e[0],this.end=e[e.length-1].to,this.readNext()}resolveOffset(t,e){let i=this.range,n=this.rangeIndex,s=this.pos+t;for(;s<i.from;){if(!n)return null;let t=this.ranges[--n];s-=i.from-t.to,i=t}for(;e<0?s>i.to:s>=i.to;){if(n==this.ranges.length-1)return null;let t=this.ranges[++n];s+=t.from-i.to,i=t}return s}clipPos(t){if(t>=this.range.from&&t<this.range.to)return t;for(let e of this.ranges)if(e.to>t)return Math.max(t,e.from);return this.end}peek(t){let e,i,n=this.chunkOff+t;if(n>=0&&n<this.chunk.length)e=this.pos+t,i=this.chunk.charCodeAt(n);else{let n=this.resolveOffset(t,1);if(null==n)return-1;if(e=n,e>=this.chunk2Pos&&e<this.chunk2Pos+this.chunk2.length)i=this.chunk2.charCodeAt(e-this.chunk2Pos);else{let t=this.rangeIndex,n=this.range;for(;n.to<=e;)n=this.ranges[++t];this.chunk2=this.input.chunk(this.chunk2Pos=e),e+this.chunk2.length>n.to&&(this.chunk2=this.chunk2.slice(0,n.to-e)),i=this.chunk2.charCodeAt(0)}}return e>=this.token.lookAhead&&(this.token.lookAhead=e+1),i}acceptToken(t,e=0){let i=e?this.resolveOffset(e,-1):this.pos;if(null==i||i<this.token.start)throw new RangeError(\"Token end out of bounds\");this.token.value=t,this.token.end=i}getChunk(){if(this.pos>=this.chunk2Pos&&this.pos<this.chunk2Pos+this.chunk2.length){let{chunk:t,chunkPos:e}=this;this.chunk=this.chunk2,this.chunkPos=this.chunk2Pos,this.chunk2=t,this.chunk2Pos=e,this.chunkOff=this.pos-this.chunkPos}else{this.chunk2=this.chunk,this.chunk2Pos=this.chunkPos;let t=this.input.chunk(this.pos),e=this.pos+t.length;this.chunk=e>this.range.to?t.slice(0,this.range.to-this.pos):t,this.chunkPos=this.pos,this.chunkOff=0}}readNext(){return this.chunkOff>=this.chunk.length&&(this.getChunk(),this.chunkOff==this.chunk.length)?this.next=-1:this.next=this.chunk.charCodeAt(this.chunkOff)}advance(t=1){for(this.chunkOff+=t;this.pos+t>=this.range.to;){if(this.rangeIndex==this.ranges.length-1)return this.setDone();t-=this.range.to-this.pos,this.range=this.ranges[++this.rangeIndex],this.pos=this.range.from}return this.pos+=t,this.pos>=this.token.lookAhead&&(this.token.lookAhead=this.pos+1),this.readNext()}setDone(){return this.pos=this.chunkPos=this.end,this.range=this.ranges[this.rangeIndex=this.ranges.length-1],this.chunk=\"\",this.next=-1}reset(t,e){if(e?(this.token=e,e.start=t,e.lookAhead=t+1,e.value=e.extended=-1):this.token=Vf,this.pos!=t){if(this.pos=t,t==this.end)return this.setDone(),this;for(;t<this.range.from;)this.range=this.ranges[--this.rangeIndex];for(;t>=this.range.to;)this.range=this.ranges[++this.rangeIndex];t>=this.chunkPos&&t<this.chunkPos+this.chunk.length?this.chunkOff=t-this.chunkPos:(this.chunk=\"\",this.chunkOff=0),this.readNext()}return this}read(t,e){if(t>=this.chunkPos&&e<=this.chunkPos+this.chunk.length)return this.chunk.slice(t-this.chunkPos,e-this.chunkPos);if(t>=this.chunk2Pos&&e<=this.chunk2Pos+this.chunk2.length)return this.chunk2.slice(t-this.chunk2Pos,e-this.chunk2Pos);if(t>=this.range.from&&e<=this.range.to)return this.input.read(t,e);let i=\"\";for(let n of this.ranges){if(n.from>=e)break;n.to>t&&(i+=this.input.read(Math.max(n.from,t),Math.min(n.to,e)))}return i}}class zf{constructor(t,e){this.data=t,this.id=e}token(t,e){!function(t,e,i,n){let s=0,r=1<<n,{parser:o}=i.p,{dialect:l}=o;t:for(;0!=(r&t[s]);){let i=t[s+1];for(let n=s+3;n<i;n+=2)if((t[n+1]&r)>0){let i=t[n];if(l.allows(i)&&(-1==e.token.value||e.token.value==i||o.overrides(i,e.token.value))){e.acceptToken(i);break}}let n=e.next,a=0,h=t[s+2];if(!(e.next<0&&h>a&&65535==t[i+3*h-3]&&65535==t[i+3*h-3])){for(;a<h;){let r=a+h>>1,o=i+r+(r<<1),l=t[o],c=t[o+1]||65536;if(n<l)h=r;else{if(!(n>=c)){s=t[o+2],e.advance();continue t}a=r+1}}break}s=t[i+3*h-1]}}(this.data,t,e,this.id)}}zf.prototype.contextual=zf.prototype.fallback=zf.prototype.extend=!1;class Hf{constructor(t,e={}){this.token=t,this.contextual=!!e.contextual,this.fallback=!!e.fallback,this.extend=!!e.extend}}function Ff(t,e=Uint16Array){if(\"string\"!=typeof t)return t;let i=null;for(let n=0,s=0;n<t.length;){let r=0;for(;;){let e=t.charCodeAt(n++),i=!1;if(126==e){r=65535;break}e>=92&&e--,e>=34&&e--;let s=e-32;if(s>=46&&(s-=46,i=!0),r+=s,i)break;r*=46}i?i[s++]=r:i=new e(r)}return i}const qf=\"undefined\"!=typeof process&&process.env&&/\\bparse\\b/.test(process.env.LOG);let _f=null;var jf,Uf;function $f(t,e,i){let n=t.cursor(bo.IncludeAnonymous);for(n.moveTo(e);;)if(!(i<0?n.childBefore(e):n.childAfter(e)))for(;;){if((i<0?n.to<e:n.from>e)&&!n.type.isError)return i<0?Math.max(0,Math.min(n.to-1,e-25)):Math.min(t.length,Math.max(n.from+1,e+25));if(i<0?n.prevSibling():n.nextSibling())break;if(!n.parent())return i<0?0:t.length}}!function(t){t[t.Margin=25]=\"Margin\"}(jf||(jf={}));class Qf{constructor(t,e){this.fragments=t,this.nodeSet=e,this.i=0,this.fragment=null,this.safeFrom=-1,this.safeTo=-1,this.trees=[],this.start=[],this.index=[],this.nextFragment()}nextFragment(){let t=this.fragment=this.i==this.fragments.length?null:this.fragments[this.i++];if(t){for(this.safeFrom=t.openStart?$f(t.tree,t.from+t.offset,1)-t.offset:t.from,this.safeTo=t.openEnd?$f(t.tree,t.to+t.offset,-1)-t.offset:t.to;this.trees.length;)this.trees.pop(),this.start.pop(),this.index.pop();this.trees.push(t.tree),this.start.push(-t.offset),this.index.push(0),this.nextStart=this.safeFrom}else this.nextStart=1e9}nodeAt(t){if(t<this.nextStart)return null;for(;this.fragment&&this.safeTo<=t;)this.nextFragment();if(!this.fragment)return null;for(;;){let e=this.trees.length-1;if(e<0)return this.nextFragment(),null;let i=this.trees[e],n=this.index[e];if(n==i.children.length){this.trees.pop(),this.start.pop(),this.index.pop();continue}let s=i.children[n],r=this.start[e]+i.positions[n];if(r>t)return this.nextStart=r,null;if(s instanceof xo){if(r==t){if(r<this.safeFrom)return null;let t=r+s.length;if(t<=this.safeTo){let e=s.prop(po.lookAhead);if(!e||t+e<this.fragment.to)return s}}this.index[e]++,r+s.length>=Math.max(this.safeFrom,t)&&(this.trees.push(s),this.start.push(r),this.index.push(0))}else this.index[e]++,this.nextStart=r+s.length}}}class Kf{constructor(t,e){this.stream=e,this.tokens=[],this.mainToken=null,this.actions=[],this.tokens=t.tokenizers.map((t=>new If))}getActions(t){let e=0,i=null,{parser:n}=t.p,{tokenizers:s}=n,r=n.stateSlot(t.state,3),o=t.curContext?t.curContext.hash:0,l=0;for(let n=0;n<s.length;n++){if(0==(1<<n&r))continue;let a=s[n],h=this.tokens[n];if((!i||a.fallback)&&((a.contextual||h.start!=t.pos||h.mask!=r||h.context!=o)&&(this.updateCachedToken(h,a,t),h.mask=r,h.context=o),h.lookAhead>h.end+25&&(l=Math.max(h.lookAhead,l)),0!=h.value)){let n=e;if(h.extended>-1&&(e=this.addActions(t,h.extended,h.end,e)),e=this.addActions(t,h.value,h.end,e),!a.extend&&(i=h,e>n))break}}for(;this.actions.length>e;)this.actions.pop();return l&&t.setLookAhead(l),i||t.pos!=this.stream.end||(i=new If,i.value=t.p.parser.eofTerm,i.start=i.end=t.pos,e=this.addActions(t,i.value,i.end,e)),this.mainToken=i,this.actions}getMainToken(t){if(this.mainToken)return this.mainToken;let e=new If,{pos:i,p:n}=t;return e.start=i,e.end=Math.min(i+1,n.stream.end),e.value=i==n.stream.end?n.parser.eofTerm:0,e}updateCachedToken(t,e,i){let n=this.stream.clipPos(i.pos);if(e.token(this.stream.reset(n,t),i),t.value>-1){let{parser:e}=i.p;for(let n=0;n<e.specialized.length;n++)if(e.specialized[n]==t.value){let s=e.specializers[n](this.stream.read(t.start,t.end),i);if(s>=0&&i.p.parser.dialect.allows(s>>1)){0==(1&s)?t.value=s>>1:t.extended=s>>1;break}}}else t.value=0,t.end=this.stream.clipPos(n+1)}putAction(t,e,i,n){for(let e=0;e<n;e+=3)if(this.actions[e]==t)return n;return this.actions[n++]=t,this.actions[n++]=e,this.actions[n++]=i,n}addActions(t,e,i,n){let{state:s}=t,{parser:r}=t.p,{data:o}=r;for(let t=0;t<2;t++)for(let l=r.stateSlot(s,t?2:1);;l+=3){if(65535==o[l]){if(1!=o[l+1]){0==n&&2==o[l+1]&&(n=this.putAction(Yf(o,l+2),e,i,n));break}l=Yf(o,l+2)}o[l]==e&&(n=this.putAction(Yf(o,l+1),e,i,n))}return n}}!function(t){t[t.Distance=5]=\"Distance\",t[t.MaxRemainingPerStep=3]=\"MaxRemainingPerStep\",t[t.MinBufferLengthPrune=500]=\"MinBufferLengthPrune\",t[t.ForceReduceLimit=10]=\"ForceReduceLimit\",t[t.CutDepth=15e3]=\"CutDepth\",t[t.CutTo=9e3]=\"CutTo\"}(Uf||(Uf={}));class Gf{constructor(t,e,i,n){this.parser=t,this.input=e,this.ranges=n,this.recovering=0,this.nextStackID=9812,this.minStackPos=0,this.reused=[],this.stoppedAt=null,this.stream=new Wf(e,n),this.tokens=new Kf(t,this.stream),this.topTerm=t.top[1];let{from:s}=n[0];this.stacks=[Rf.start(this,t.top[0],s)],this.fragments=i.length&&this.stream.end-s>4*t.bufferLength?new Qf(i,t.nodeSet):null}get parsedPos(){return this.minStackPos}advance(){let t,e,i=this.stacks,n=this.minStackPos,s=this.stacks=[];for(let r=0;r<i.length;r++){let o=i[r];for(;;){if(this.tokens.mainToken=null,o.pos>n)s.push(o);else{if(this.advanceStack(o,s,i))continue;{t||(t=[],e=[]),t.push(o);let i=this.tokens.getMainToken(o);e.push(i.value,i.end)}}break}}if(!s.length){let e=t&&function(t){let e=null;for(let i of t){let t=i.p.stoppedAt;(i.pos==i.p.stream.end||null!=t&&i.pos>t)&&i.p.parser.stateFlag(i.state,2)&&(!e||e.score<i.score)&&(e=i)}return e}(t);if(e)return this.stackToTree(e);if(this.parser.strict)throw qf&&t&&console.log(\"Stuck with token \"+(this.tokens.mainToken?this.parser.getName(this.tokens.mainToken.value):\"none\")),new SyntaxError(\"No parse at \"+n);this.recovering||(this.recovering=5)}if(this.recovering&&t){let i=null!=this.stoppedAt&&t[0].pos>this.stoppedAt?t[0]:this.runRecovery(t,e,s);if(i)return this.stackToTree(i.forceAll())}if(this.recovering){let t=1==this.recovering?1:3*this.recovering;if(s.length>t)for(s.sort(((t,e)=>e.score-t.score));s.length>t;)s.pop();s.some((t=>t.reducePos>n))&&this.recovering--}else if(s.length>1)t:for(let t=0;t<s.length-1;t++){let e=s[t];for(let i=t+1;i<s.length;i++){let n=s[i];if(e.sameState(n)||e.buffer.length>500&&n.buffer.length>500){if(!((e.score-n.score||e.buffer.length-n.buffer.length)>0)){s.splice(t--,1);continue t}s.splice(i--,1)}}}this.minStackPos=s[0].pos;for(let t=1;t<s.length;t++)s[t].pos<this.minStackPos&&(this.minStackPos=s[t].pos);return null}stopAt(t){if(null!=this.stoppedAt&&this.stoppedAt<t)throw new RangeError(\"Can't move stoppedAt forward\");this.stoppedAt=t}advanceStack(t,e,i){let n=t.pos,{parser:s}=this,r=qf?this.stackID(t)+\" -> \":\"\";if(null!=this.stoppedAt&&n>this.stoppedAt)return t.forceReduce()?t:null;if(this.fragments){let e=t.curContext&&t.curContext.tracker.strict,i=e?t.curContext.hash:0;for(let o=this.fragments.nodeAt(n);o;){let n=this.parser.nodeSet.types[o.type.id]==o.type?s.getGoto(t.state,o.type.id):-1;if(n>-1&&o.length&&(!e||(o.prop(po.contextHash)||0)==i))return t.useNode(o,n),qf&&console.log(r+this.stackID(t)+` (via reuse of ${s.getName(o.type.id)})`),!0;if(!(o instanceof xo)||0==o.children.length||o.positions[0]>0)break;let l=o.children[0];if(!(l instanceof xo&&0==o.positions[0]))break;o=l}}let o=s.stateSlot(t.state,4);if(o>0)return t.reduce(o),qf&&console.log(r+this.stackID(t)+` (via always-reduce ${s.getName(65535&o)})`),!0;if(t.stack.length>=15e3)for(;t.stack.length>9e3&&t.forceReduce(););let l=this.tokens.getActions(t);for(let o=0;o<l.length;){let a=l[o++],h=l[o++],c=l[o++],u=o==l.length||!i,f=u?t:t.split();if(f.apply(a,h,c),qf&&console.log(r+this.stackID(f)+` (via ${0==(65536&a)?\"shift\":`reduce of ${s.getName(65535&a)}`} for ${s.getName(h)} @ ${n}${f==t?\"\":\", split\"})`),u)return!0;f.pos>n?e.push(f):i.push(f)}return!1}advanceFully(t,e){let i=t.pos;for(;;){if(!this.advanceStack(t,null,null))return!1;if(t.pos>i)return Jf(t,e),!0}}runRecovery(t,e,i){let n=null,s=!1;for(let r=0;r<t.length;r++){let o=t[r],l=e[r<<1],a=e[1+(r<<1)],h=qf?this.stackID(o)+\" -> \":\"\";if(o.deadEnd){if(s)continue;if(s=!0,o.restart(),qf&&console.log(h+this.stackID(o)+\" (restarted)\"),this.advanceFully(o,i))continue}let c=o.split(),u=h;for(let t=0;c.forceReduce()&&t<10;t++){if(qf&&console.log(u+this.stackID(c)+\" (via force-reduce)\"),this.advanceFully(c,i))break;qf&&(u=this.stackID(c)+\" -> \")}for(let t of o.recoverByInsert(l))qf&&console.log(h+this.stackID(t)+\" (via recover-insert)\"),this.advanceFully(t,i);this.stream.end>o.pos?(a==o.pos&&(a++,l=0),o.recoverByDelete(l,a),qf&&console.log(h+this.stackID(o)+` (via recover-delete ${this.parser.getName(l)})`),Jf(o,i)):(!n||n.score<o.score)&&(n=o)}return n}stackToTree(t){return t.close(),xo.build({buffer:Nf.create(t),nodeSet:this.parser.nodeSet,topID:this.topTerm,maxBufferLength:this.parser.bufferLength,reused:this.reused,start:this.ranges[0].from,length:t.pos-this.ranges[0].from,minRepeatType:this.parser.minRepeatTerm})}stackID(t){let e=(_f||(_f=new WeakMap)).get(t);return e||_f.set(t,e=String.fromCodePoint(this.nextStackID++)),e+t}}function Jf(t,e){for(let i=0;i<e.length;i++){let n=e[i];if(n.pos==t.pos&&n.sameState(t))return void(e[i].score<t.score&&(e[i]=t))}e.push(t)}class Xf{constructor(t,e,i){this.source=t,this.flags=e,this.disabled=i}allows(t){return!this.disabled||0==this.disabled[t]}}class Zf extends Wo{constructor(t){if(super(),this.wrappers=[],14!=t.version)throw new RangeError(`Parser version (${t.version}) doesn't match runtime version (14)`);let e=t.nodeNames.split(\" \");this.minRepeatTerm=e.length;for(let i=0;i<t.repeatNodeCount;i++)e.push(\"\");let i=Object.keys(t.topRules).map((e=>t.topRules[e][1])),n=[];for(let t=0;t<e.length;t++)n.push([]);function s(t,e,i){n[t].push([e,e.deserialize(String(i))])}if(t.nodeProps)for(let e of t.nodeProps){let t=e[0];\"string\"==typeof t&&(t=po[t]);for(let i=1;i<e.length;){let n=e[i++];if(n>=0)s(n,t,e[i++]);else{let r=e[i+-n];for(let o=-n;o>0;o--)s(e[i++],t,r);i++}}}this.nodeSet=new vo(e.map(((e,s)=>go.define({name:s>=this.minRepeatTerm?void 0:e,id:s,props:n[s],top:i.indexOf(s)>-1,error:0==s,skipped:t.skippedNodes&&t.skippedNodes.indexOf(s)>-1})))),t.propSources&&(this.nodeSet=this.nodeSet.extend(...t.propSources)),this.strict=!1,this.bufferLength=co;let r=Ff(t.tokenData);this.context=t.context,this.specializerSpecs=t.specialized||[],this.specialized=new Uint16Array(this.specializerSpecs.length);for(let t=0;t<this.specializerSpecs.length;t++)this.specialized[t]=this.specializerSpecs[t].term;this.specializers=this.specializerSpecs.map(ed),this.states=Ff(t.states,Uint32Array),this.data=Ff(t.stateData),this.goto=Ff(t.goto),this.maxTerm=t.maxTerm,this.tokenizers=t.tokenizers.map((t=>\"number\"==typeof t?new zf(r,t):t)),this.topRules=t.topRules,this.dialects=t.dialects||{},this.dynamicPrecedences=t.dynamicPrecedences||null,this.tokenPrecTable=t.tokenPrec,this.termNames=t.termNames||null,this.maxNode=this.nodeSet.types.length-1,this.dialect=this.parseDialect(),this.top=this.topRules[Object.keys(this.topRules)[0]]}createParse(t,e,i){let n=new Gf(this,t,e,i);for(let s of this.wrappers)n=s(n,t,e,i);return n}getGoto(t,e,i=!1){let n=this.goto;if(e>=n[0])return-1;for(let s=n[e+1];;){let e=n[s++],r=1&e,o=n[s++];if(r&&i)return o;for(let i=s+(e>>1);s<i;s++)if(n[s]==t)return o;if(r)return-1}}hasAction(t,e){let i=this.data;for(let n=0;n<2;n++)for(let s,r=this.stateSlot(t,n?2:1);;r+=3){if(65535==(s=i[r])){if(1!=i[r+1]){if(2==i[r+1])return Yf(i,r+2);break}s=i[r=Yf(i,r+2)]}if(s==e||0==s)return Yf(i,r+1)}return 0}stateSlot(t,e){return this.states[6*t+e]}stateFlag(t,e){return(this.stateSlot(t,0)&e)>0}validAction(t,e){if(e==this.stateSlot(t,4))return!0;for(let i=this.stateSlot(t,1);;i+=3){if(65535==this.data[i]){if(1!=this.data[i+1])return!1;i=Yf(this.data,i+2)}if(e==Yf(this.data,i+1))return!0}}nextStates(t){let e=[];for(let i=this.stateSlot(t,1);;i+=3){if(65535==this.data[i]){if(1!=this.data[i+1])break;i=Yf(this.data,i+2)}if(0==(1&this.data[i+2])){let t=this.data[i+1];e.some(((e,i)=>1&i&&e==t))||e.push(this.data[i],t)}}return e}overrides(t,e){let i=td(this.data,this.tokenPrecTable,e);return i<0||td(this.data,this.tokenPrecTable,t)<i}configure(t){let e=Object.assign(Object.create(Zf.prototype),this);if(t.props&&(e.nodeSet=this.nodeSet.extend(...t.props)),t.top){let i=this.topRules[t.top];if(!i)throw new RangeError(`Invalid top rule name ${t.top}`);e.top=i}return t.tokenizers&&(e.tokenizers=this.tokenizers.map((e=>{let i=t.tokenizers.find((t=>t.from==e));return i?i.to:e}))),t.specializers&&(e.specializers=this.specializers.slice(),e.specializerSpecs=this.specializerSpecs.map(((i,n)=>{let s=t.specializers.find((t=>t.from==i.external));if(!s)return i;let r=Object.assign(Object.assign({},i),{external:s.to});return e.specializers[n]=ed(r),r}))),t.contextTracker&&(e.context=t.contextTracker),t.dialect&&(e.dialect=this.parseDialect(t.dialect)),null!=t.strict&&(e.strict=t.strict),t.wrap&&(e.wrappers=e.wrappers.concat(t.wrap)),null!=t.bufferLength&&(e.bufferLength=t.bufferLength),e}hasWrappers(){return this.wrappers.length>0}getName(t){return this.termNames?this.termNames[t]:String(t<=this.maxNode&&this.nodeSet.types[t].name||t)}get eofTerm(){return this.maxNode+1}get topNode(){return this.nodeSet.types[this.top[1]]}dynamicPrecedence(t){let e=this.dynamicPrecedences;return null==e?0:e[t]||0}parseDialect(t){let e=Object.keys(this.dialects),i=e.map((()=>!1));if(t)for(let n of t.split(\" \")){let t=e.indexOf(n);t>=0&&(i[t]=!0)}let n=null;for(let t=0;t<e.length;t++)if(!i[t])for(let i,s=this.dialects[e[t]];65535!=(i=this.data[s++]);)(n||(n=new Uint8Array(this.maxTerm+1)))[i]=1;return new Xf(t,i,n)}static deserialize(t){return new Zf(t)}}function Yf(t,e){return t[e]|t[e+1]<<16}function td(t,e,i){for(let n,s=e;65535!=(n=t[s]);s++)if(n==i)return s-e;return-1}function ed(t){if(t.external){let e=t.extend?1:0;return(i,n)=>t.external(i,n)<<1|e}return t.get}function id(t){return t>=65&&t<=90||t>=97&&t<=122||t>=48&&t<=57}function nd(t,e,i){for(let n=!1;;){if(t.next<0)return;if(t.next==e&&!n)return void t.advance();n=i&&!n&&92==t.next,t.advance()}}function sd(t,e){for(;95==t.next||id(t.next);)null!=e&&(e+=String.fromCharCode(t.next)),t.advance();return e}function rd(t,e){for(;48==t.next||49==t.next;)t.advance();e&&t.next==e&&t.advance()}function od(t,e){for(;;){if(46==t.next){if(e)break;e=!0}else if(t.next<48||t.next>57)break;t.advance()}if(69==t.next||101==t.next)for(t.advance(),43!=t.next&&45!=t.next||t.advance();t.next>=48&&t.next<=57;)t.advance()}function ld(t){for(;!(t.next<0||10==t.next);)t.advance()}function ad(t,e){for(let i=0;i<e.length;i++)if(e.charCodeAt(i)==t)return!0;return!1}const hd=\" \\t\\r\\n\";function cd(t,e,i){let n=Object.create(null);n.true=n.false=5,n.null=n.unknown=6;for(let e of t.split(\" \"))e&&(n[e]=20);for(let t of e.split(\" \"))t&&(n[t]=21);for(let t of(i||\"\").split(\" \"))t&&(n[t]=24);return n}const ud={backslashEscapes:!1,hashComments:!1,spaceAfterDashes:!1,slashComments:!1,doubleQuotedStrings:!1,doubleDollarQuotedStrings:!1,unquotedBitLiterals:!1,treatBitsAsBytes:!1,charSetCasts:!1,operatorChars:\"*+-%<>!=&|~^/\",specialVar:\"?\",identifierQuotes:'\"',words:cd(\"absolute action add after all allocate alter and any are as asc assertion at authorization before begin between both breadth by call cascade cascaded case cast catalog check close collate collation column commit condition connect connection constraint constraints constructor continue corresponding count create cross cube current current_date current_default_transform_group current_transform_group_for_type current_path current_role current_time current_timestamp current_user cursor cycle data day deallocate declare default deferrable deferred delete depth deref desc describe descriptor deterministic diagnostics disconnect distinct do domain drop dynamic each else elseif end end-exec equals escape except exception exec execute exists exit external fetch first for foreign found from free full function general get global go goto grant group grouping handle having hold hour identity if immediate in indicator initially inner inout input insert intersect into is isolation join key language last lateral leading leave left level like limit local localtime localtimestamp locator loop map match method minute modifies module month names natural nesting new next no none not of old on only open option or order ordinality out outer output overlaps pad parameter partial path prepare preserve primary prior privileges procedure public read reads recursive redo ref references referencing relative release repeat resignal restrict result return returns revoke right role rollback rollup routine row rows savepoint schema scroll search second section select session session_user set sets signal similar size some space specific specifictype sql sqlexception sqlstate sqlwarning start state static system_user table temporary then timezone_hour timezone_minute to trailing transaction translation treat trigger under undo union unique unnest until update usage user using value values view when whenever where while with without work write year zone \",\"array binary bit boolean char character clob date decimal double float int integer interval large national nchar nclob numeric object precision real smallint time timestamp varchar varying \")};function fd(t){return new Hf((e=>{var i;let{next:n}=e;if(e.advance(),ad(n,hd)){for(;ad(e.next,hd);)e.advance();e.acceptToken(36)}else if(36==n&&36==e.next&&t.doubleDollarQuotedStrings)!function(t){for(;;){if(t.next<0||t.peek(1)<0)return;if(36==t.next&&36==t.peek(1))return void t.advance(2);t.advance()}}(e),e.acceptToken(3);else if(39==n||34==n&&t.doubleQuotedStrings)nd(e,n,t.backslashEscapes),e.acceptToken(3);else if(35==n&&t.hashComments||47==n&&47==e.next&&t.slashComments)ld(e),e.acceptToken(1);else if(45!=n||45!=e.next||t.spaceAfterDashes&&32!=e.peek(1))if(47==n&&42==e.next){e.advance();for(let t=-1,i=1;!(e.next<0);)if(e.advance(),42==t&&47==e.next){if(i--,!i){e.advance();break}t=-1}else 47==t&&42==e.next?(i++,t=-1):t=e.next;e.acceptToken(2)}else if(101!=n&&69!=n||39!=e.next)if(110!=n&&78!=n||39!=e.next||!t.charSetCasts)if(95==n&&t.charSetCasts)for(let i=0;;i++){if(39==e.next&&i>1){e.advance(),nd(e,39,t.backslashEscapes),e.acceptToken(3);break}if(!id(e.next))break;e.advance()}else if(40==n)e.acceptToken(7);else if(41==n)e.acceptToken(8);else if(123==n)e.acceptToken(9);else if(125==n)e.acceptToken(10);else if(91==n)e.acceptToken(11);else if(93==n)e.acceptToken(12);else if(59==n)e.acceptToken(13);else if(t.unquotedBitLiterals&&48==n&&98==e.next)e.advance(),rd(e),e.acceptToken(22);else if(98!=n&&66!=n||39!=e.next&&34!=e.next){if(48==n&&(120==e.next||88==e.next)||(120==n||88==n)&&39==e.next){let t=39==e.next;for(e.advance();(s=e.next)>=48&&s<=57||s>=97&&s<=102||s>=65&&s<=70;)e.advance();t&&39==e.next&&e.advance(),e.acceptToken(4)}else if(46==n&&e.next>=48&&e.next<=57)od(e,!0),e.acceptToken(4);else if(46==n)e.acceptToken(14);else if(n>=48&&n<=57)od(e,!1),e.acceptToken(4);else if(ad(n,t.operatorChars)){for(;ad(e.next,t.operatorChars);)e.advance();e.acceptToken(15)}else if(ad(n,t.specialVar))e.next==n&&e.advance(),function(t){if(39==t.next||34==t.next||96==t.next){let e=t.next;t.advance(),nd(t,e,!1)}else sd(t)}(e),e.acceptToken(17);else if(ad(n,t.identifierQuotes))nd(e,n,!1),e.acceptToken(19);else if(58==n||44==n)e.acceptToken(16);else if(id(n)){let s=sd(e,String.fromCharCode(n));e.acceptToken(46==e.next?18:null!==(i=t.words[s.toLowerCase()])&&void 0!==i?i:18)}}else{const i=e.next;e.advance(),t.treatBitsAsBytes?(nd(e,i,t.backslashEscapes),e.acceptToken(23)):(rd(e,i),e.acceptToken(22))}else e.advance(),nd(e,39,t.backslashEscapes),e.acceptToken(3);else e.advance(),nd(e,39,!0);else ld(e),e.acceptToken(1);var s}))}const dd=fd(ud),pd=Zf.deserialize({version:14,states:\"%vQ]QQOOO#wQRO'#DSO$OQQO'#CwO%eQQO'#CxO%lQQO'#CyO%sQQO'#CzOOQQ'#DS'#DSOOQQ'#C}'#C}O'UQRO'#C{OOQQ'#Cv'#CvOOQQ'#C|'#C|Q]QQOOQOQQOOO'`QQO'#DOO(xQRO,59cO)PQQO,59cO)UQQO'#DSOOQQ,59d,59dO)cQQO,59dOOQQ,59e,59eO)jQQO,59eOOQQ,59f,59fO)qQQO,59fOOQQ-E6{-E6{OOQQ,59b,59bOOQQ-E6z-E6zOOQQ,59j,59jOOQQ-E6|-E6|O+VQRO1G.}O+^QQO,59cOOQQ1G/O1G/OOOQQ1G/P1G/POOQQ1G/Q1G/QP+kQQO'#C}O+rQQO1G.}O)PQQO,59cO,PQQO'#Cw\",stateData:\",[~OtOSPOSQOS~ORUOSUOTUOUUOVROXSOZTO]XO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O^]ORvXSvXTvXUvXVvXXvXZvX]vX_vX`vXavXbvXcvXdvXevXfvXgvXhvX~OsvX~P!jOa_Ob_Oc_O~ORUOSUOTUOUUOVROXSOZTO^tO_UO`UOa`Ob`Oc`OdUOeUOfUOgUOhUO~OWaO~P$ZOYcO~P$ZO[eO~P$ZORUOSUOTUOUUOVROXSOZTO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O]hOsoX~P%zOajObjOcjO~O^]ORkaSkaTkaUkaVkaXkaZka]ka_ka`kaakabkackadkaekafkagkahka~Oska~P'kO^]O~OWvXYvX[vX~P!jOWnO~P$ZOYoO~P$ZO[pO~P$ZO^]ORkiSkiTkiUkiVkiXkiZki]ki_ki`kiakibkickidkiekifkigkihki~Oski~P)xOWkaYka[ka~P'kO]hO~P$ZOWkiYki[ki~P)xOasObsOcsO~O\",goto:\"#hwPPPPPPPPPPPPPPPPPPPPPPPPPPx||||!Y!^!d!xPPP#[TYOZeUORSTWZbdfqT[OZQZORiZSWOZQbRQdSQfTZgWbdfqQ^PWk^lmrQl_Qm`RrseVORSTWZbdfq\",nodeNames:\"⚠ LineComment BlockComment String Number Bool Null ( ) [ ] { } ; . Operator Punctuation SpecialVar Identifier QuotedIdentifier Keyword Type Bits Bytes Builtin Script Statement CompositeIdentifier Parens Braces Brackets Statement\",maxTerm:38,skippedNodes:[0,1,2],repeatNodeCount:3,tokenData:\"RORO\",tokenizers:[0,dd],topRules:{Script:[0,25]},tokenPrec:0});function md(t){let e=t.cursor().moveTo(t.from,-1);for(;/Comment/.test(e.name);)e.moveTo(e.from,-1);return e.node}function gd(t,e){let i=t.sliceString(e.from,e.to),n=/^([`'\"])(.*)\\1$/.exec(i);return n?n[2]:i}function vd(t){return t&&(\"Identifier\"==t.name||\"QuotedIdentifier\"==t.name)}function wd(t,e){if(\"CompositeIdentifier\"==e.name){let i=[];for(let n=e.firstChild;n;n=n.nextSibling)vd(n)&&i.push(gd(t,n));return i}return[gd(t,e)]}function yd(t,e){for(let i=[];;){if(!e||\".\"!=e.name)return i;let n=md(e);if(!vd(n))return i;i.unshift(gd(t,n)),e=md(n)}}function bd(t,e){let i=vl(t).resolveInner(e,-1),n=function(t,e){let i;for(let t=e;!i;t=t.parent){if(!t)return null;\"Statement\"==t.name&&(i=t)}let n=null;for(let e=i.firstChild,s=!1,r=null;e;e=e.nextSibling){let i=\"Keyword\"==e.name?t.sliceString(e.from,e.to).toLowerCase():null,o=null;if(s)if(\"as\"==i&&r&&vd(e.nextSibling))o=gd(t,e.nextSibling);else{if(i&&xd.has(i))break;r&&vd(e)&&(o=gd(t,e))}else s=\"from\"==i;o&&(n||(n=Object.create(null)),n[o]=wd(t,r)),r=/Identifier$/.test(e.name)?e:null}return n}(t.doc,i);return\"Identifier\"==i.name||\"QuotedIdentifier\"==i.name||\"Keyword\"==i.name?{from:i.from,quoted:\"QuotedIdentifier\"==i.name?t.doc.sliceString(i.from,i.from+1):null,parents:yd(t.doc,md(i)),aliases:n}:\".\"==i.name?{from:e,quoted:null,parents:yd(t.doc,i),aliases:n}:{from:e,quoted:null,parents:[],empty:!0,aliases:n}}const xd=new Set(\"where group having order union intersect except all distinct limit offset fetch for\".split(\" \"));const kd=/^\\w*$/,Sd=/^[`'\"]?\\w*[`'\"]?$/;class Cd{constructor(){this.list=[],this.children=void 0}child(t){let e=this.children||(this.children=Object.create(null));return e[t]||(e[t]=new Cd)}childCompletions(t){return this.children?Object.keys(this.children).filter((t=>t)).map((e=>({label:e,type:t}))):[]}}function Ad(t,e){let i=Object.keys(t).map((i=>({label:e?i.toUpperCase():i,type:21==t[i]?\"type\":20==t[i]?\"keyword\":\"variable\",boost:-1})));return n=[\"QuotedIdentifier\",\"SpecialVar\",\"String\",\"LineComment\",\"BlockComment\",\".\"],s=hu(i),t=>{for(let e=vl(t.state).resolveInner(t.pos,-1);e;e=e.parent)if(n.indexOf(e.name)>-1)return null;return s(t)};var n,s}let Od=pd.configure({props:[Ll.add({Statement:Hl()}),ql.add({Statement:t=>({from:t.firstChild.to,to:t.to}),BlockComment:t=>({from:t.from+2,to:t.to-2})}),jo({Keyword:ul.keyword,Type:ul.typeName,Builtin:ul.standard(ul.name),Bits:ul.number,Bytes:ul.string,Bool:ul.bool,Null:ul.null,Number:ul.number,String:ul.string,Identifier:ul.name,QuotedIdentifier:ul.special(ul.string),SpecialVar:ul.special(ul.name),LineComment:ul.lineComment,BlockComment:ul.blockComment,Operator:ul.operator,\"Semi Punctuation\":ul.punctuation,\"( )\":ul.paren,\"{ }\":ul.brace,\"[ ]\":ul.squareBracket})]});class Md{constructor(t,e){this.dialect=t,this.language=e}get extension(){return this.language.extension}static define(t){let e=function(t,e,i,n){let s={};for(let e in ud)s[e]=(t.hasOwnProperty(e)?t:ud)[e];return e&&(s.words=cd(e,i||\"\",n)),s}(t,t.keywords,t.types,t.builtin),i=gl.define({name:\"sql\",parser:Od.configure({tokenizers:[{from:dd,to:fd(e)}]}),languageData:{commentTokens:{line:\"--\",block:{open:\"/*\",close:\"*/\"}},closeBrackets:{brackets:[\"(\",\"[\",\"{\",\"'\",'\"',\"`\"]}}});return new Md(e,i)}}function Dd(t,e=!1){return Ad(t.dialect.words,e)}function Td(t,e=!1){return t.language.data.of({autocomplete:Dd(t,e)})}function Pd(t){return t.schema?function(t,e,i,n){let s=new Cd,r=s.child(n||\"\");for(let e in t){let i=e.indexOf(\".\");(i>-1?s.child(e.slice(0,i)):r).child(i>-1?e.slice(i+1):e).list=t[e].map((t=>\"string\"==typeof t?{label:t,type:\"property\"}:t))}r.list=(e||r.childCompletions(\"type\")).concat(i?r.child(i).list:[]);for(let t in s.children){let e=s.child(t);e.list.length||(e.list=e.childCompletions(\"type\"))}return s.list=r.list.concat(s.childCompletions(\"type\")),t=>{let{parents:e,from:n,quoted:o,empty:l,aliases:a}=bd(t.state,t.pos);if(l&&!t.explicit)return null;a&&1==e.length&&(e=a[e[0]]||e);let h=s;for(let t of e){for(;!h.children||!h.children[t];)if(h==s)h=r;else{if(h!=r||!i)return null;h=h.child(i)}h=h.child(t)}let c=o&&t.state.sliceDoc(t.pos,t.pos+1)==o,u=h.list;return h==s&&a&&(u=u.concat(Object.keys(a).map((t=>({label:t,type:\"constant\"}))))),{from:n,to:c?t.pos+1:void 0,options:(f=o,d=u,f?d.map((t=>Object.assign(Object.assign({},t),{label:f+t.label+f,apply:void 0}))):d),validFor:o?Sd:kd};var f,d}}(t.schema,t.tables,t.defaultTable,t.defaultSchema):()=>null}function Rd(t){return t.schema?(t.dialect||Bd).language.data.of({autocomplete:Pd(t)}):[]}function Ed(t={}){let e=t.dialect||Bd;return new Ml(e.language,[Rd(t),Td(e,!!t.upperCaseKeywords)])}const Bd=Md.define({}),Ld=Md.define({keywords:\"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where\",types:\"null integer real text blob\",builtin:\"\",operatorChars:\"*+-%<>!=&|/~\",identifierQuotes:'`\"',specialVar:\"@:?$\"});return t.editorFromTextArea=function(t,e={}){let i=new Ds({doc:t.value,extensions:[Is.of([{key:\"Shift-Enter\",run:function(){return t.value=i.state.doc.toString(),t.form.submit(),!0}},{key:\"Meta-Enter\",run:function(){return t.value=i.state.doc.toString(),t.form.submit(),!0}}]),Pf,Ds.lineWrapping,Ed({dialect:Ld,schema:e.schema,tables:e.tables,defaultTableName:e.defaultTableName,defaultSchemaName:e.defaultSchemaName})]}),n=i.contentDOM.closest(\".cm-editor\");return new ResizeObserver((function(){i.requestMeasure()})).observe(n,{attributes:!0}),t.parentNode.insertBefore(i.dom,t),t.style.display=\"none\",t.form&&t.form.addEventListener(\"submit\",(()=>{t.value=i.state.doc.toString()})),i},t}({});\n"
  },
  {
    "path": "datasette/static/cm-editor-6.0.1.js",
    "content": "import { EditorView, basicSetup } from \"codemirror\";\nimport { keymap } from \"@codemirror/view\";\nimport { sql, SQLDialect } from \"@codemirror/lang-sql\";\n\n// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231\nconst SQLite = SQLDialect.define({\n  // Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries\n  // https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:\n  keywords:\n    \"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where\",\n  // https://www.sqlite.org/datatype3.html\n  types: \"null integer real text blob\",\n  builtin: \"\",\n  operatorChars: \"*+-%<>!=&|/~\",\n  identifierQuotes: '`\"',\n  specialVar: \"@:?$\",\n});\n\n// Utility function from https://codemirror.net/docs/migration/\nexport function editorFromTextArea(textarea, conf = {}) {\n  // This could also be configured with a set of tables and columns for better autocomplete:\n  // https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables\n  let view = new EditorView({\n    doc: textarea.value,\n    extensions: [\n      keymap.of([\n        {\n          key: \"Shift-Enter\",\n          run: function () {\n            textarea.value = view.state.doc.toString();\n            textarea.form.submit();\n            return true;\n          },\n        },\n        {\n          key: \"Meta-Enter\",\n          run: function () {\n            textarea.value = view.state.doc.toString();\n            textarea.form.submit();\n            return true;\n          },\n        },\n      ]),\n      // This has to be after the keymap or else the basicSetup keys will prevent\n      // Meta-Enter from running\n      basicSetup,\n      EditorView.lineWrapping,\n      sql({\n        dialect: SQLite,\n        schema: conf.schema,\n        tables: conf.tables,\n        defaultTableName: conf.defaultTableName,\n        defaultSchemaName: conf.defaultSchemaName,\n      }),\n    ],\n  });\n\n  // Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.\n  // Using CSS resize: both and scheduling a measurement when the element changes.\n  let editorDOM = view.contentDOM.closest(\".cm-editor\");\n  let observer = new ResizeObserver(function () {\n    view.requestMeasure();\n  });\n  observer.observe(editorDOM, { attributes: true });\n\n  textarea.parentNode.insertBefore(view.dom, textarea);\n  textarea.style.display = \"none\";\n  if (textarea.form) {\n    textarea.form.addEventListener(\"submit\", () => {\n      textarea.value = view.state.doc.toString();\n    });\n  }\n  return view;\n}\n"
  },
  {
    "path": "datasette/static/column-chooser.js",
    "content": "class ColumnChooser extends HTMLElement {\n  constructor() {\n    super();\n    this.attachShadow({ mode: \"open\" });\n\n    // State\n    this._items = [];\n    this._checked = new Set();\n    this._savedItems = null;\n    this._savedChecked = null;\n    this._onApply = null;\n\n    // Drag state\n    this._ghost = null;\n    this._dragSrcIdx = null;\n    this._dropTargetIdx = null;\n    this._dropPosition = null;\n    this._ghostOffX = 0;\n    this._ghostOffY = 0;\n    this._autoScrollRAF = null;\n    this._lastPointerY = 0;\n    this._lastPointerX = 0;\n    this._SCROLL_ZONE = 72;\n    this._SCROLL_SPEED = 0.4;\n\n    // Bound handlers\n    this._onMove = this._onMove.bind(this);\n    this._onUp = this._onUp.bind(this);\n\n    this.shadowRoot.innerHTML = `\n      <style>\n        :host {\n          --ink: #0f0f0f;\n          --paper: #f5f3ef;\n          --muted: #6b6b6b;\n          --rule: #e2dfd8;\n          --accent: #1a56db;\n          --accent-light: #e8effd;\n          --card: #ffffff;\n        }\n\n        * { box-sizing: border-box; margin: 0; padding: 0; }\n\n        dialog {\n          border: none;\n          border-radius: var(--modal-border-radius, 0.75rem);\n          padding: 0;\n          margin: auto;\n          width: 100%;\n          max-width: 420px;\n          max-height: min(640px, calc(100vh - 32px));\n          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));\n          animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;\n          overflow: hidden;\n          font-family: system-ui, -apple-system, sans-serif;\n          background: var(--card);\n          -webkit-user-select: none;\n          -webkit-touch-callout: none;\n          -webkit-tap-highlight-color: transparent;\n        }\n\n        dialog[open] {\n          display: flex;\n          flex-direction: column;\n          height: min(640px, calc(100vh - 32px));\n        }\n\n        dialog::backdrop {\n          background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));\n          backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n          -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n          animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;\n        }\n\n        @keyframes slideIn {\n          from {\n            opacity: 0;\n            transform: translateY(-20px) scale(0.95);\n          }\n          to {\n            opacity: 1;\n            transform: translateY(0) scale(1);\n          }\n        }\n\n        @keyframes fadeIn {\n          from { opacity: 0; }\n          to { opacity: 1; }\n        }\n\n        .modal-header {\n          padding: 20px 24px 16px;\n          border-bottom: 1px solid var(--rule);\n          display: flex;\n          align-items: center;\n          justify-content: space-between;\n          flex-shrink: 0;\n        }\n\n        .modal-title {\n          font-size: 1rem;\n          font-weight: 600;\n        }\n\n        .modal-meta {\n          font-family: ui-monospace, monospace;\n          font-size: 0.7rem;\n          color: var(--muted);\n          background: var(--paper);\n          padding: 3px 9px;\n          border-radius: 20px;\n        }\n\n        .list-toolbar {\n          padding: 6px 24px;\n          border-bottom: 1px solid var(--rule);\n          display: flex;\n          gap: 12px;\n          flex-shrink: 0;\n        }\n\n        .list-toolbar button {\n          background: var(--accent-light);\n          border: 1px solid var(--rule);\n          border-radius: 4px;\n          font-family: inherit;\n          font-size: 0.75rem;\n          color: var(--accent);\n          cursor: pointer;\n          padding: 3px 10px;\n          transition: background 0.12s, color 0.12s;\n        }\n        .list-toolbar button:hover { background: var(--accent); color: white; }\n\n        .list-wrap {\n          flex: 1;\n          overflow-y: auto;\n          overflow-x: hidden;\n          position: relative;\n          overscroll-behavior: contain;\n          -webkit-overflow-scrolling: touch;\n        }\n\n        .list-wrap::before,\n        .list-wrap::after {\n          content: '';\n          position: sticky;\n          display: block;\n          left: 0; right: 0;\n          height: 20px;\n          pointer-events: none;\n          z-index: 5;\n          transition: opacity 0.2s;\n        }\n        .list-wrap::before {\n          top: 0;\n          background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);\n        }\n        .list-wrap::after {\n          bottom: 0;\n          background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);\n          margin-top: -20px;\n        }\n\n        .scroll-zone {\n          position: absolute;\n          left: 0; right: 0;\n          height: 72px;\n          pointer-events: none;\n          z-index: 10;\n        }\n        .scroll-zone-top { top: 0; }\n        .scroll-zone-bot { bottom: 0; }\n\n        .drag-list {\n          list-style: none;\n          padding: 4px 0;\n        }\n\n        .drag-item {\n          display: flex;\n          align-items: center;\n          background: white;\n          border-bottom: 1px solid var(--rule);\n          user-select: none;\n          -webkit-user-select: none;\n          -webkit-touch-callout: none;\n          position: relative;\n          transition: background 0.08s;\n        }\n\n        .drag-item:last-child { border-bottom: none; }\n\n        .drag-handle {\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          width: 48px;\n          height: 48px;\n          flex-shrink: 0;\n          cursor: grab;\n          color: #c8c4bc;\n          touch-action: none;\n          transition: color 0.15s;\n        }\n\n        .drag-handle:hover { color: var(--accent); }\n        .drag-handle svg { pointer-events: none; display: block; }\n\n        .drag-item-content {\n          display: flex;\n          align-items: center;\n          flex: 1;\n          min-width: 0;\n          cursor: pointer;\n        }\n\n        .drag-item-check {\n          display: flex;\n          align-items: center;\n          width: 32px;\n          height: 48px;\n          flex-shrink: 0;\n        }\n\n        .drag-item-check input[type=\"checkbox\"] {\n          width: 16px;\n          height: 16px;\n          accent-color: var(--accent);\n          cursor: pointer;\n        }\n\n        .drag-item-label {\n          flex: 1;\n          font-size: 0.9rem;\n          line-height: 48px;\n          padding-right: 16px;\n          white-space: nowrap;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          cursor: default;\n        }\n\n        .drag-item.is-dragging {\n          opacity: 0;\n        }\n\n        .drop-indicator {\n          position: absolute;\n          left: 48px;\n          right: 0;\n          height: 2px;\n          background: var(--accent);\n          border-radius: 99px;\n          pointer-events: none;\n          z-index: 20;\n          display: none;\n        }\n        .drop-indicator.top { top: -1px; display: block; }\n        .drop-indicator.bottom { bottom: -1px; display: block; }\n\n        .drag-ghost {\n          position: fixed;\n          pointer-events: none;\n          z-index: 9999;\n          background: white;\n          border-radius: 6px;\n          box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);\n          display: flex;\n          align-items: center;\n          border: 1.5px solid var(--accent-light);\n          opacity: 0.97;\n          will-change: transform;\n          font-family: system-ui, -apple-system, sans-serif;\n        }\n\n        .scroll-pulse {\n          position: absolute;\n          left: 50%;\n          transform: translateX(-50%);\n          width: 32px;\n          height: 32px;\n          border-radius: 50%;\n          background: var(--accent);\n          opacity: 0;\n          pointer-events: none;\n          z-index: 10;\n          transition: opacity 0.15s;\n        }\n        .scroll-pulse.top { top: 8px; }\n        .scroll-pulse.bot { bottom: 8px; }\n        .scroll-pulse.active {\n          opacity: 0.18;\n          animation: pulse 0.8s ease-in-out infinite;\n        }\n\n        @keyframes pulse {\n          0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }\n          50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }\n        }\n\n        .modal-footer {\n          padding: 14px 20px;\n          border-top: 1px solid var(--rule);\n          display: flex;\n          align-items: center;\n          gap: 10px;\n          flex-shrink: 0;\n          background: var(--paper);\n        }\n\n        .footer-info {\n          flex: 1;\n          font-family: ui-monospace, monospace;\n          font-size: 0.68rem;\n          color: var(--muted);\n        }\n\n        .btn {\n          border: none;\n          border-radius: 5px;\n          padding: 9px 20px;\n          font-size: 0.85rem;\n          font-weight: 500;\n          cursor: pointer;\n          touch-action: manipulation;\n          font-family: inherit;\n          transition: background 0.12s;\n        }\n\n        .btn-primary {\n          background: var(--accent);\n          color: white;\n        }\n        .btn-primary:hover { background: #1448c0; }\n\n        .btn-ghost {\n          background: transparent;\n          color: var(--muted);\n          border: 1px solid var(--rule);\n        }\n        .btn-ghost:hover { background: var(--rule); color: var(--ink); }\n\n        .list-wrap::-webkit-scrollbar { width: 5px; }\n        .list-wrap::-webkit-scrollbar-track { background: transparent; }\n        .list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }\n\n        input, textarea { -webkit-user-select: auto; user-select: auto; }\n      </style>\n\n      <dialog aria-labelledby=\"modalTitle\">\n          <div class=\"modal-header\">\n            <span class=\"modal-title\" id=\"modalTitle\">Choose columns</span>\n            <span class=\"modal-meta\" id=\"selectedCount\"></span>\n          </div>\n          <div class=\"list-toolbar\">\n            <button id=\"selectAllBtn\">Select all</button>\n            <button id=\"deselectAllBtn\">Deselect all</button>\n          </div>\n          <div class=\"list-wrap\" id=\"listWrap\">\n            <div class=\"scroll-pulse top\" id=\"pulseTop\"></div>\n            <div class=\"scroll-pulse bot\" id=\"pulseBot\"></div>\n            <ul class=\"drag-list\" id=\"dragList\"></ul>\n          </div>\n          <div class=\"modal-footer\">\n            <span class=\"footer-info\" id=\"footerInfo\"></span>\n            <button class=\"btn btn-ghost\" id=\"cancelBtn\">Cancel</button>\n            <button class=\"btn btn-primary\" id=\"applyBtn\">Apply</button>\n          </div>\n      </dialog>\n    `;\n\n    // DOM refs\n    this._dialog = this.shadowRoot.querySelector(\"dialog\");\n    this._listWrap = this.shadowRoot.getElementById(\"listWrap\");\n    this._dragList = this.shadowRoot.getElementById(\"dragList\");\n    this._pulseTop = this.shadowRoot.getElementById(\"pulseTop\");\n    this._pulseBot = this.shadowRoot.getElementById(\"pulseBot\");\n    this._selectAllBtn = this.shadowRoot.getElementById(\"selectAllBtn\");\n    this._deselectAllBtn = this.shadowRoot.getElementById(\"deselectAllBtn\");\n    this._cancelBtn = this.shadowRoot.getElementById(\"cancelBtn\");\n    this._applyBtn = this.shadowRoot.getElementById(\"applyBtn\");\n    this._countEl = this.shadowRoot.getElementById(\"selectedCount\");\n    this._footerEl = this.shadowRoot.getElementById(\"footerInfo\");\n\n    // Event listeners\n    this._selectAllBtn.addEventListener(\"click\", () => this._selectAll());\n    this._deselectAllBtn.addEventListener(\"click\", () => this._deselectAll());\n    this._cancelBtn.addEventListener(\"click\", () => this._close());\n    this._applyBtn.addEventListener(\"click\", () => this._apply());\n    this._dialog.addEventListener(\"click\", (e) => {\n      if (e.target === this._dialog) this._close();\n    });\n    this._dialog.addEventListener(\"cancel\", (e) => {\n      e.preventDefault();\n      this._close();\n    });\n  }\n\n  /**\n   * Open the column chooser dialog.\n   * @param {Object} opts\n   * @param {string[]} opts.columns - All available column names, in display order.\n   * @param {string[]} opts.selected - Column names that should be pre-checked.\n   * @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked.\n   */\n  open({ columns, selected = [], onApply }) {\n    this._items = [...columns];\n    this._checked = new Set(selected);\n    this._onApply = onApply || null;\n\n    // Save state for cancel/restore\n    this._savedItems = [...this._items];\n    this._savedChecked = new Set(this._checked);\n\n    this._render();\n    this._dialog.showModal();\n  }\n\n  // ── Internal methods ──\n\n  _close() {\n    this._items = this._savedItems ? [...this._savedItems] : this._items;\n    this._checked = this._savedChecked\n      ? new Set(this._savedChecked)\n      : this._checked;\n    this._dialog.close();\n  }\n\n  _selectAll() {\n    this._items.forEach((col) => this._checked.add(col));\n    this._dragList.querySelectorAll('input[type=\"checkbox\"]').forEach((cb) => {\n      cb.checked = true;\n    });\n    this._updateCounts();\n  }\n\n  _deselectAll() {\n    this._checked.clear();\n    this._dragList.querySelectorAll('input[type=\"checkbox\"]').forEach((cb) => {\n      cb.checked = false;\n    });\n    this._updateCounts();\n  }\n\n  _apply() {\n    const selected = this._items.filter((col) => this._checked.has(col));\n    this._dialog.close();\n    if (this._onApply) {\n      this._onApply(selected);\n    }\n  }\n\n  _render() {\n    this._dragList.innerHTML = \"\";\n    this._items.forEach((col, i) => {\n      const li = document.createElement(\"li\");\n      li.className = \"drag-item\";\n      li.dataset.idx = i;\n      li.innerHTML = `\n        <span class=\"drag-handle\" aria-label=\"Drag to reorder\">\n          <svg width=\"12\" height=\"18\" viewBox=\"0 0 12 18\" fill=\"currentColor\">\n            <circle cx=\"3.5\" cy=\"3.5\" r=\"1.8\"/>\n            <circle cx=\"8.5\" cy=\"3.5\" r=\"1.8\"/>\n            <circle cx=\"3.5\" cy=\"9\" r=\"1.8\"/>\n            <circle cx=\"8.5\" cy=\"9\" r=\"1.8\"/>\n            <circle cx=\"3.5\" cy=\"14.5\" r=\"1.8\"/>\n            <circle cx=\"8.5\" cy=\"14.5\" r=\"1.8\"/>\n          </svg>\n        </span>\n        <label class=\"drag-item-content\">\n          <span class=\"drag-item-check\">\n            <input type=\"checkbox\" ${this._checked.has(col) ? \"checked\" : \"\"}>\n          </span>\n          <span class=\"drag-item-label\">${col}</span>\n        </label>\n        <div class=\"drop-indicator\"></div>\n      `;\n\n      li.querySelector(\"input\").addEventListener(\"change\", (e) => {\n        e.target.checked ? this._checked.add(col) : this._checked.delete(col);\n        this._updateCounts();\n      });\n\n      li.querySelector(\".drag-handle\").addEventListener(\"pointerdown\", (e) =>\n        this._startDrag(e, i),\n      );\n      this._dragList.appendChild(li);\n    });\n\n    this._updateCounts();\n  }\n\n  _updateCounts() {\n    const n = this._checked.size;\n    this._countEl.textContent = `${n} of ${this._items.length} selected`;\n    this._footerEl.textContent = `${this._items.length} columns`;\n  }\n\n  // ── Drag engine ──\n\n  _startDrag(e, idx) {\n    e.preventDefault();\n    this._dragSrcIdx = idx;\n\n    const srcEl = this._dragList.children[idx];\n    const rect = srcEl.getBoundingClientRect();\n\n    this._ghostOffX = e.clientX - rect.left;\n    this._ghostOffY = e.clientY - rect.top;\n\n    // Build ghost inside shadow DOM\n    this._ghost = document.createElement(\"div\");\n    this._ghost.className = \"drag-ghost\";\n    this._ghost.style.width = rect.width + \"px\";\n    this._ghost.style.height = rect.height + \"px\";\n    this._ghost.innerHTML = srcEl.innerHTML;\n    this._ghost.querySelector(\".drop-indicator\")?.remove();\n    const h = this._ghost.querySelector(\".drag-handle\");\n    if (h) h.style.color = \"var(--accent)\";\n    this.shadowRoot.appendChild(this._ghost);\n\n    srcEl.classList.add(\"is-dragging\");\n    this._positionGhost(e.clientX, e.clientY);\n\n    document.addEventListener(\"pointermove\", this._onMove);\n    document.addEventListener(\"pointerup\", this._onUp);\n    document.addEventListener(\"pointercancel\", this._onUp);\n  }\n\n  _positionGhost(cx, cy) {\n    this._ghost.style.left = cx - this._ghostOffX + \"px\";\n    this._ghost.style.top = cy - this._ghostOffY + \"px\";\n  }\n\n  _onMove(e) {\n    this._lastPointerX = e.clientX;\n    this._lastPointerY = e.clientY;\n    this._positionGhost(e.clientX, e.clientY);\n    this._updateDropTarget(e.clientY);\n    this._updateAutoScroll(e.clientY);\n  }\n\n  _onUp() {\n    document.removeEventListener(\"pointermove\", this._onMove);\n    document.removeEventListener(\"pointerup\", this._onUp);\n    document.removeEventListener(\"pointercancel\", this._onUp);\n\n    this._stopAutoScroll();\n\n    const noMove =\n      this._dropTargetIdx === null || this._dropTargetIdx === this._dragSrcIdx;\n    this._clearDropIndicators();\n\n    let dest = null;\n    if (!noMove) {\n      const moved = this._items.splice(this._dragSrcIdx, 1)[0];\n      dest = this._dropTargetIdx;\n      if (this._dropPosition === \"after\") dest++;\n      if (dest > this._dragSrcIdx) dest--;\n      this._items.splice(dest, 0, moved);\n    }\n\n    this._dragSrcIdx = null;\n    this._dropTargetIdx = null;\n    this._dropPosition = null;\n\n    const g = this._ghost;\n    this._ghost = null;\n\n    if (noMove) {\n      if (g) g.remove();\n      this._render();\n      return;\n    }\n\n    this._render();\n\n    if (g && dest !== null) {\n      const landedEl = this._dragList.children[dest];\n      if (landedEl) {\n        landedEl.style.opacity = \"0\";\n        const r = landedEl.getBoundingClientRect();\n        g.getBoundingClientRect();\n        g.style.transition =\n          \"left 0.15s cubic-bezier(0.22, 1, 0.36, 1), top 0.15s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.15s, opacity 0.1s 0.1s\";\n        g.style.left = r.left + \"px\";\n        g.style.top = r.top + \"px\";\n        g.style.boxShadow = \"0 1px 4px rgba(0,0,0,0.08)\";\n        g.style.opacity = \"0\";\n        setTimeout(() => {\n          g.remove();\n          if (landedEl) landedEl.style.opacity = \"\";\n        }, 160);\n      } else {\n        g.remove();\n      }\n    } else if (g) {\n      g.remove();\n    }\n  }\n\n  _updateDropTarget(clientY) {\n    this._clearDropIndicators();\n    const listItems = [\n      ...this._dragList.querySelectorAll(\".drag-item:not(.is-dragging)\"),\n    ];\n    if (!listItems.length) return;\n\n    let best = null,\n      bestDist = Infinity;\n    listItems.forEach((li) => {\n      const r = li.getBoundingClientRect();\n      const mid = r.top + r.height / 2;\n      const dist = Math.abs(clientY - mid);\n      if (dist < bestDist) {\n        bestDist = dist;\n        best = li;\n      }\n    });\n\n    if (!best) return;\n    const r = best.getBoundingClientRect();\n    const mid = r.top + r.height / 2;\n    const above = clientY < mid;\n    const indic = best.querySelector(\".drop-indicator\");\n\n    this._dropTargetIdx = parseInt(best.dataset.idx);\n    this._dropPosition = above ? \"before\" : \"after\";\n\n    if (indic) {\n      indic.className = \"drop-indicator \" + (above ? \"top\" : \"bottom\");\n    }\n  }\n\n  _clearDropIndicators() {\n    this._dragList.querySelectorAll(\".drop-indicator\").forEach((el) => {\n      el.className = \"drop-indicator\";\n    });\n  }\n\n  _updateAutoScroll(clientY) {\n    const rect = this._listWrap.getBoundingClientRect();\n    const relY = clientY - rect.top;\n    const distTop = relY;\n    const distBot = rect.height - relY;\n\n    const inTop = distTop < this._SCROLL_ZONE && distTop >= 0;\n    const inBot = distBot < this._SCROLL_ZONE && distBot >= 0;\n\n    this._pulseTop.classList.toggle(\"active\", inTop);\n    this._pulseBot.classList.toggle(\"active\", inBot);\n\n    if ((inTop || inBot) && !this._autoScrollRAF) {\n      let lastTime = null;\n      const loop = (ts) => {\n        if (!this._ghost) {\n          this._stopAutoScroll();\n          return;\n        }\n        if (lastTime !== null) {\n          const dt = ts - lastTime;\n          const rect2 = this._listWrap.getBoundingClientRect();\n          const relY2 = this._lastPointerY - rect2.top;\n          const dTop = relY2;\n          const dBot = rect2.height - relY2;\n\n          if (dTop < this._SCROLL_ZONE && dTop >= 0) {\n            const factor = 1 - dTop / this._SCROLL_ZONE;\n            this._listWrap.scrollTop -= this._SCROLL_SPEED * dt * factor * 2.5;\n          } else if (dBot < this._SCROLL_ZONE && dBot >= 0) {\n            const factor = 1 - dBot / this._SCROLL_ZONE;\n            this._listWrap.scrollTop += this._SCROLL_SPEED * dt * factor * 2.5;\n          } else {\n            this._stopAutoScroll();\n            return;\n          }\n          this._updateDropTarget(this._lastPointerY);\n        }\n        lastTime = ts;\n        this._autoScrollRAF = requestAnimationFrame(loop);\n      };\n      this._autoScrollRAF = requestAnimationFrame(loop);\n    }\n\n    if (!inTop && !inBot) this._stopAutoScroll();\n  }\n\n  _stopAutoScroll() {\n    if (this._autoScrollRAF) {\n      cancelAnimationFrame(this._autoScrollRAF);\n      this._autoScrollRAF = null;\n    }\n    this._pulseTop.classList.remove(\"active\");\n    this._pulseBot.classList.remove(\"active\");\n  }\n}\n\ncustomElements.define(\"column-chooser\", ColumnChooser);\n"
  },
  {
    "path": "datasette/static/datasette-manager.js",
    "content": "// Custom events for use with the native CustomEvent API\nconst DATASETTE_EVENTS = {\n  INIT: \"datasette_init\", // returns datasette manager instance in evt.detail\n};\n\n// Datasette \"core\" -> Methods/APIs that are foundational\n// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into\n// literal DOM selectors, they'll have an easier time using these addresses.\nconst DOM_SELECTORS = {\n  /** Should have one match */\n  jsonExportLink: \".export-links a[href*=json]\",\n\n  /** Event listeners that go outside of the main table, e.g. existing scroll listener */\n  tableWrapper: \".table-wrapper\",\n  table: \"table.rows-and-columns\",\n  aboveTablePanel: \".above-table-panel\",\n\n  // These could have multiple matches\n  /** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */\n  tableHeaders: `table.rows-and-columns th`,\n\n  /** Used to add \"where\"  clauses to query using direct manipulation */\n  filterRows: \".filter-row\",\n  /** Used to show top available enum values for a column (\"facets\") */\n  facetResults: \".facet-results [data-column]\",\n};\n\n/**\n * Monolith class for interacting with Datasette JS API\n * Imported with DEFER, runs after main document parsed\n * For now, manually synced with datasette/version.py\n */\nconst datasetteManager = {\n  VERSION: window.datasetteVersion,\n\n  // TODO: Should order of registration matter more?\n\n  // Should plugins be allowed to clobber others or is it last-in takes priority?\n  // Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?\n  plugins: new Map(),\n\n  registerPlugin: (name, pluginMetadata) => {\n    if (datasetteManager.plugins.has(name)) {\n      console.warn(`Warning -> plugin ${name} was redefined`);\n    }\n    datasetteManager.plugins.set(name, pluginMetadata);\n\n    // If the plugin participates in the panel... update the panel.\n    if (pluginMetadata.makeAboveTablePanelConfigs) {\n      datasetteManager.renderAboveTablePanel();\n    }\n  },\n\n  /**\n   * New DOM elements are created on each click, so the data is not stale.\n   *\n   * Items\n   *  - must provide label (text)\n   *  - might provide href (string) or an onclick ((evt) => void)\n   *\n   * columnMeta is metadata stored on the column header (TH) as a DOMStringMap\n   * - column: string\n   * - columnNotNull: boolean\n   * - columnType: sqlite datatype enum (text, number, etc)\n   * - isPk: boolean\n   */\n  makeColumnActions: (columnMeta) => {\n    let columnActions = [];\n\n    // Accept function that returns list of columnActions with keys\n    // Required: label (text)\n    // Optional: onClick or href\n    datasetteManager.plugins.forEach((plugin) => {\n      if (plugin.makeColumnActions) {\n        // Plugins can provide multiple columnActions if they want\n        // If multiple try to create entry with same label, the last one deletes the others\n        columnActions.push(...plugin.makeColumnActions(columnMeta));\n      }\n    });\n\n    // TODO: Validate columnAction configs and give informative error message if missing keys.\n    return columnActions;\n  },\n\n  /**\n   * In MVP, each plugin can only have 1 instance.\n   * In future, panels could be repeated. We omit that for now since so many plugins depend on\n   * shared URL state, so having multiple instances of plugin at same time is problematic.\n   * Currently, we never destroy any panels, we just hide them.\n   *\n   * TODO: nicer panel css, show panel selection state.\n   * TODO: does this hook need to take any arguments?\n   */\n  renderAboveTablePanel: () => {\n    const aboveTablePanel = document.querySelector(\n      DOM_SELECTORS.aboveTablePanel,\n    );\n\n    if (!aboveTablePanel) {\n      console.warn(\n        \"This page does not have a table, the renderAboveTablePanel cannot be used.\",\n      );\n      return;\n    }\n\n    let aboveTablePanelWrapper = aboveTablePanel.querySelector(\".panels\");\n\n    // First render: create wrappers. Otherwise, reuse previous.\n    if (!aboveTablePanelWrapper) {\n      aboveTablePanelWrapper = document.createElement(\"div\");\n      aboveTablePanelWrapper.classList.add(\"tab-contents\");\n      const panelNav = document.createElement(\"div\");\n      panelNav.classList.add(\"tab-controls\");\n\n      // Temporary: css for minimal amount of breathing room.\n      panelNav.style.display = \"flex\";\n      panelNav.style.gap = \"8px\";\n      panelNav.style.marginTop = \"4px\";\n      panelNav.style.marginBottom = \"20px\";\n\n      aboveTablePanel.appendChild(panelNav);\n      aboveTablePanel.appendChild(aboveTablePanelWrapper);\n    }\n\n    datasetteManager.plugins.forEach((plugin, pluginName) => {\n      const { makeAboveTablePanelConfigs } = plugin;\n\n      if (makeAboveTablePanelConfigs) {\n        const controls = aboveTablePanel.querySelector(\".tab-controls\");\n        const contents = aboveTablePanel.querySelector(\".tab-contents\");\n\n        // Each plugin can make multiple panels\n        const configs = makeAboveTablePanelConfigs();\n\n        configs.forEach((config, i) => {\n          const nodeContentId = `${pluginName}_${config.id}_panel-content`;\n\n          // quit if we've already registered this plugin\n          // TODO: look into whether plugins should be allowed to ask\n          // parent to re-render, or if they should manage that internally.\n          if (document.getElementById(nodeContentId)) {\n            return;\n          }\n\n          // Add tab control button\n          const pluginControl = document.createElement(\"button\");\n          pluginControl.textContent = config.label;\n          pluginControl.onclick = () => {\n            contents.childNodes.forEach((node) => {\n              if (node.id === nodeContentId) {\n                node.style.display = \"block\";\n              } else {\n                node.style.display = \"none\";\n              }\n            });\n          };\n          controls.appendChild(pluginControl);\n\n          // Add plugin content area\n          const pluginNode = document.createElement(\"div\");\n          pluginNode.id = nodeContentId;\n          config.render(pluginNode);\n          pluginNode.style.display = \"none\"; // Default to hidden unless you're ifrst\n\n          contents.appendChild(pluginNode);\n        });\n\n        // Let first node be selected by default\n        if (contents.childNodes.length) {\n          contents.childNodes[0].style.display = \"block\";\n        }\n      }\n    });\n  },\n\n  /** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */\n  selectors: DOM_SELECTORS,\n\n  // Future API ideas\n  // Fetch page's data in array, and cache so plugins could reuse it\n  // Provide knowledge of what datasette JS or server-side via traditional console autocomplete\n  // State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage\n  // UI Hooks: command + k, tab manager hook\n  // Should we notify plugins that have dependencies\n  // when all dependencies were fulfilled? (leaflet, codemirror, etc)\n  // https://github.com/simonw/datasette-leaflet -> this way\n  // multiple plugins can all request the same copy of leaflet.\n};\n\nconst initializeDatasette = () => {\n  // Hide the global behind __ prefix. Ideally they should be listening for the\n  // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.\n\n  window.__DATASETTE__ = datasetteManager;\n  console.debug(\"Datasette Manager Created!\");\n\n  const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {\n    detail: datasetteManager,\n  });\n\n  document.dispatchEvent(initDatasetteEvent);\n};\n\n/**\n * Main function\n * Fires AFTER the document has been parsed\n */\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n  initializeDatasette();\n});\n"
  },
  {
    "path": "datasette/static/json-format-highlight-1.0.1.js",
    "content": "/*\nhttps://github.com/luyilin/json-format-highlight\nFrom https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js\nMIT Licensed\n*/\n(function (global, factory) {\n  typeof exports === \"object\" && typeof module !== \"undefined\"\n    ? (module.exports = factory())\n    : typeof define === \"function\" && define.amd\n      ? define(factory)\n      : (global.jsonFormatHighlight = factory());\n})(this, function () {\n  \"use strict\";\n\n  var defaultColors = {\n    keyColor: \"dimgray\",\n    numberColor: \"lightskyblue\",\n    stringColor: \"lightcoral\",\n    trueColor: \"lightseagreen\",\n    falseColor: \"#f66578\",\n    nullColor: \"cornflowerblue\",\n  };\n\n  function index(json, colorOptions) {\n    if (colorOptions === void 0) colorOptions = {};\n\n    if (!json) {\n      return;\n    }\n    if (typeof json !== \"string\") {\n      json = JSON.stringify(json, null, 2);\n    }\n    var colors = Object.assign({}, defaultColors, colorOptions);\n    json = json.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n    return json.replace(\n      /(\"(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\\"])*\"(\\s*:)?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+]?\\d+)?)/g,\n      function (match) {\n        var color = colors.numberColor;\n        if (/^\"/.test(match)) {\n          color = /:$/.test(match) ? colors.keyColor : colors.stringColor;\n        } else {\n          color = /true/.test(match)\n            ? colors.trueColor\n            : /false/.test(match)\n              ? colors.falseColor\n              : /null/.test(match)\n                ? colors.nullColor\n                : color;\n        }\n        return '<span style=\"color: ' + color + '\">' + match + \"</span>\";\n      },\n    );\n  }\n\n  return index;\n});\n"
  },
  {
    "path": "datasette/static/mobile-column-actions.js",
    "content": "var MOBILE_COLUMN_BREAKPOINT = 576;\nvar MOBILE_COLUMN_DIALOG_ID = \"mobile-column-actions-dialog\";\nvar MOBILE_COLUMN_DIALOG_TITLE_ID = \"mobile-column-actions-title\";\n\nfunction mobileColumnHeaders(manager) {\n  return Array.from(\n    document.querySelectorAll(manager.selectors.tableHeaders),\n  ).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== \"1\");\n}\n\nfunction mobileColumnMetaText(th) {\n  var parts = [];\n  if (th.dataset.columnType) {\n    parts.push(th.dataset.columnType);\n  }\n  if (th.dataset.isPk === \"1\") {\n    parts.push(\"pk\");\n  }\n  if (th.dataset.columnNotNull === \"1\") {\n    parts.push(\"not null\");\n  }\n  return parts.join(\", \");\n}\n\nfunction createMobileColumnActionNode(itemConfig, closeDialog) {\n  var actionNode;\n  if (itemConfig.href) {\n    actionNode = document.createElement(\"a\");\n    actionNode.href = itemConfig.href;\n  } else {\n    actionNode = document.createElement(\"button\");\n    actionNode.type = \"button\";\n  }\n  actionNode.textContent = itemConfig.label;\n\n  if (itemConfig.onClick) {\n    actionNode.addEventListener(\"click\", function (ev) {\n      try {\n        itemConfig.onClick.call(actionNode, ev);\n      } finally {\n        closeDialog({ restoreFocus: false });\n      }\n    });\n  }\n\n  return actionNode;\n}\n\nfunction initMobileColumnActions(manager) {\n  var triggerButton = document.querySelector(\".column-actions-mobile\");\n  if (!triggerButton) {\n    return;\n  }\n\n  if (\n    !window.URLSearchParams ||\n    !window.HTMLDialogElement ||\n    !manager.columnActions\n  ) {\n    triggerButton.style.display = \"none\";\n    return;\n  }\n\n  if (!mobileColumnHeaders(manager).length) {\n    triggerButton.style.display = \"none\";\n    return;\n  }\n\n  var dialog = document.createElement(\"dialog\");\n  dialog.className = \"mobile-column-actions-dialog\";\n  dialog.id = MOBILE_COLUMN_DIALOG_ID;\n  dialog.setAttribute(\"aria-labelledby\", MOBILE_COLUMN_DIALOG_TITLE_ID);\n  dialog.innerHTML = `\n    <div class=\"modal-header\">\n      <span class=\"modal-title\" id=\"${MOBILE_COLUMN_DIALOG_TITLE_ID}\">Column actions</span>\n      <span class=\"modal-meta\"></span>\n    </div>\n    <div class=\"list-wrap mobile-column-list\"></div>\n    <div class=\"modal-footer\">\n      <span class=\"footer-info\">Tap a column to reveal actions.</span>\n      <button type=\"button\" class=\"btn btn-ghost mobile-column-actions-done\">Done</button>\n    </div>\n  `;\n  document.body.appendChild(dialog);\n\n  triggerButton.setAttribute(\"aria-haspopup\", \"dialog\");\n  triggerButton.setAttribute(\"aria-controls\", MOBILE_COLUMN_DIALOG_ID);\n  triggerButton.setAttribute(\"aria-expanded\", \"false\");\n\n  var countEl = dialog.querySelector(\".modal-meta\");\n  var listWrap = dialog.querySelector(\".mobile-column-list\");\n  var doneButton = dialog.querySelector(\".mobile-column-actions-done\");\n  var expandedSectionId = null;\n  var shouldRestoreFocus = true;\n\n  function updateExpandedSection() {\n    Array.from(dialog.querySelectorAll(\".col-header\")).forEach((button) => {\n      var controlsId = button.getAttribute(\"aria-controls\");\n      var actionList = dialog.querySelector(\"#\" + controlsId);\n      var isExpanded = controlsId === expandedSectionId;\n      button.setAttribute(\"aria-expanded\", isExpanded ? \"true\" : \"false\");\n      actionList.hidden = !isExpanded;\n      actionList.classList.toggle(\"expanded\", isExpanded);\n    });\n  }\n\n  function scrollExpandedSectionIntoView(section) {\n    var sectionTop = section.offsetTop;\n    var sectionBottom = sectionTop + section.offsetHeight;\n    var visibleTop = listWrap.scrollTop;\n    var visibleBottom = visibleTop + listWrap.clientHeight;\n    var sectionHeight = section.offsetHeight;\n\n    if (sectionTop < visibleTop) {\n      listWrap.scrollTop = sectionTop;\n      return;\n    }\n\n    if (sectionBottom <= visibleBottom) {\n      return;\n    }\n\n    if (sectionHeight <= listWrap.clientHeight) {\n      listWrap.scrollTop = sectionBottom - listWrap.clientHeight;\n    } else {\n      listWrap.scrollTop = sectionTop;\n    }\n  }\n\n  function closeDialog(options) {\n    options = options || {};\n    shouldRestoreFocus = options.restoreFocus !== false;\n    if (dialog.open) {\n      dialog.close();\n    } else {\n      triggerButton.setAttribute(\"aria-expanded\", \"false\");\n      if (shouldRestoreFocus) {\n        triggerButton.focus();\n      }\n    }\n  }\n\n  function renderDialog() {\n    var headers = mobileColumnHeaders(manager);\n    if (!headers.length) {\n      closeDialog({ restoreFocus: false });\n      triggerButton.style.display = \"none\";\n      return false;\n    }\n\n    if (\n      !headers.some(\n        (_th, index) => `mobile-column-actions-${index}` === expandedSectionId,\n      )\n    ) {\n      expandedSectionId = null;\n    }\n\n    countEl.textContent = `${headers.length} column${\n      headers.length === 1 ? \"\" : \"s\"\n    }`;\n    listWrap.innerHTML = \"\";\n\n    if (manager.columnActions.shouldShowShowAllColumns()) {\n      var topActions = document.createElement(\"div\");\n      topActions.className = \"mobile-column-top-actions\";\n\n      var showAllColumns = document.createElement(\"a\");\n      showAllColumns.className = \"btn btn-ghost mobile-column-top-action\";\n      showAllColumns.href = manager.columnActions.showAllColumnsUrl();\n      showAllColumns.textContent = \"Show all columns\";\n\n      topActions.appendChild(showAllColumns);\n      listWrap.appendChild(topActions);\n    }\n\n    headers.forEach((th, index) => {\n      var sectionId = `mobile-column-actions-${index}`;\n      var actionState = manager.columnActions.buildColumnActionState(th, {\n        includeChooseColumns: false,\n        includeShowAllColumns: false,\n      });\n      var section = document.createElement(\"section\");\n      section.className = \"mobile-column-section\";\n\n      var headerButton = document.createElement(\"button\");\n      headerButton.type = \"button\";\n      headerButton.className = \"col-header\";\n      headerButton.setAttribute(\"aria-controls\", sectionId);\n      headerButton.setAttribute(\"aria-expanded\", \"false\");\n\n      var headerText = document.createElement(\"span\");\n      headerText.className = \"mobile-column-header-text\";\n\n      var name = document.createElement(\"span\");\n      name.className = \"mobile-column-name\";\n      name.textContent = th.dataset.column;\n      headerText.appendChild(name);\n\n      var metaText = mobileColumnMetaText(th);\n      if (metaText) {\n        var meta = document.createElement(\"span\");\n        meta.className = \"mobile-column-meta\";\n        meta.textContent = metaText;\n        headerText.appendChild(meta);\n      }\n\n      var chevron = document.createElement(\"span\");\n      chevron.className = \"mobile-column-chevron\";\n      chevron.setAttribute(\"aria-hidden\", \"true\");\n      chevron.textContent = \"▾\";\n\n      headerButton.appendChild(headerText);\n      headerButton.appendChild(chevron);\n      headerButton.addEventListener(\"click\", function () {\n        expandedSectionId = expandedSectionId === sectionId ? null : sectionId;\n        updateExpandedSection();\n        if (expandedSectionId === sectionId) {\n          scrollExpandedSectionIntoView(section);\n        }\n      });\n\n      var actionContainer = document.createElement(\"div\");\n      actionContainer.id = sectionId;\n      actionContainer.className = \"col-actions\";\n      actionContainer.hidden = true;\n\n      if (actionState.columnDescription) {\n        var description = document.createElement(\"p\");\n        description.className = \"mobile-column-description\";\n        description.textContent = actionState.columnDescription;\n        actionContainer.appendChild(description);\n      }\n\n      if (actionState.actionItems.length) {\n        var actionList = document.createElement(\"ul\");\n        actionState.actionItems.forEach((itemConfig) => {\n          var actionItem = document.createElement(\"li\");\n          actionItem.appendChild(\n            createMobileColumnActionNode(itemConfig, closeDialog),\n          );\n          actionList.appendChild(actionItem);\n        });\n        actionContainer.appendChild(actionList);\n      } else {\n        var noActions = document.createElement(\"p\");\n        noActions.className = \"mobile-column-no-actions\";\n        noActions.textContent = \"No actions available\";\n        actionContainer.appendChild(noActions);\n      }\n\n      section.appendChild(headerButton);\n      section.appendChild(actionContainer);\n      listWrap.appendChild(section);\n    });\n\n    updateExpandedSection();\n    return true;\n  }\n\n  function openDialog() {\n    if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) {\n      return;\n    }\n    if (!renderDialog()) {\n      return;\n    }\n    if (!dialog.open) {\n      dialog.showModal();\n    }\n    triggerButton.setAttribute(\"aria-expanded\", \"true\");\n    var focusTarget =\n      dialog.querySelector(\".mobile-column-top-action\") ||\n      dialog.querySelector(\".col-header\") ||\n      doneButton;\n    focusTarget.focus();\n  }\n\n  triggerButton.addEventListener(\"click\", function () {\n    if (dialog.open) {\n      closeDialog();\n    } else {\n      openDialog();\n    }\n  });\n\n  doneButton.addEventListener(\"click\", function () {\n    closeDialog();\n  });\n\n  dialog.addEventListener(\"click\", function (ev) {\n    if (ev.target === dialog) {\n      closeDialog();\n    }\n  });\n\n  dialog.addEventListener(\"cancel\", function (ev) {\n    ev.preventDefault();\n    closeDialog();\n  });\n\n  dialog.addEventListener(\"close\", function () {\n    triggerButton.setAttribute(\"aria-expanded\", \"false\");\n    if (shouldRestoreFocus) {\n      triggerButton.focus();\n    }\n  });\n\n  window.addEventListener(\"resize\", function () {\n    if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {\n      closeDialog({ restoreFocus: false });\n    }\n  });\n}\n\ndocument.addEventListener(\"datasette_init\", function (evt) {\n  initMobileColumnActions(evt.detail);\n});\n"
  },
  {
    "path": "datasette/static/navigation-search.js",
    "content": "class NavigationSearch extends HTMLElement {\n  constructor() {\n    super();\n    this.attachShadow({ mode: \"open\" });\n    this.selectedIndex = -1;\n    this.matches = [];\n    this.debounceTimer = null;\n\n    this.render();\n    this.setupEventListeners();\n  }\n\n  render() {\n    this.shadowRoot.innerHTML = `\n            <style>\n                :host {\n                    display: contents;\n                }\n\n                dialog {\n                    border: none;\n                    border-radius: var(--modal-border-radius, 0.75rem);\n                    padding: 0;\n                    max-width: 90vw;\n                    width: 600px;\n                    max-height: 80vh;\n                    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));\n                    animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;\n                }\n\n                dialog::backdrop {\n                    background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));\n                    backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n                    -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));\n                    animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;\n                }\n\n                @keyframes slideIn {\n                    from {\n                        opacity: 0;\n                        transform: translateY(-20px) scale(0.95);\n                    }\n                    to {\n                        opacity: 1;\n                        transform: translateY(0) scale(1);\n                    }\n                }\n\n                @keyframes fadeIn {\n                    from { opacity: 0; }\n                    to { opacity: 1; }\n                }\n\n                .search-container {\n                    display: flex;\n                    flex-direction: column;\n                    height: 100%;\n                }\n\n                .search-input-wrapper {\n                    padding: 1.25rem;\n                    border-bottom: 1px solid #e5e7eb;\n                }\n\n                .search-input {\n                    width: 100%;\n                    padding: 0.75rem 1rem;\n                    font-size: 1rem;\n                    border: 2px solid #e5e7eb;\n                    border-radius: 0.5rem;\n                    outline: none;\n                    transition: border-color 0.2s;\n                    box-sizing: border-box;\n                }\n\n                .search-input:focus {\n                    border-color: #2563eb;\n                }\n\n                .results-container {\n                    overflow-y: auto;\n                    height: calc(80vh - 180px);\n                    padding: 0.5rem;\n                }\n\n                .result-item {\n                    padding: 0.875rem 1rem;\n                    cursor: pointer;\n                    border-radius: 0.5rem;\n                    transition: background-color 0.15s;\n                    display: flex;\n                    align-items: center;\n                    gap: 0.75rem;\n                }\n\n                .result-item:hover {\n                    background-color: #f3f4f6;\n                }\n\n                .result-item.selected {\n                    background-color: #dbeafe;\n                }\n\n                .result-name {\n                    font-weight: 500;\n                    color: #111827;\n                }\n\n                .result-url {\n                    font-size: 0.875rem;\n                    color: #6b7280;\n                }\n\n                .no-results {\n                    padding: 2rem;\n                    text-align: center;\n                    color: #6b7280;\n                }\n\n                .hint-text {\n                    padding: 0.75rem 1.25rem;\n                    font-size: 0.875rem;\n                    color: #6b7280;\n                    border-top: 1px solid #e5e7eb;\n                    display: flex;\n                    gap: 1rem;\n                    flex-wrap: wrap;\n                }\n\n                .hint-text kbd {\n                    background: #f3f4f6;\n                    padding: 0.125rem 0.375rem;\n                    border-radius: 0.25rem;\n                    font-size: 0.75rem;\n                    border: 1px solid #d1d5db;\n                    font-family: monospace;\n                }\n\n                /* Mobile optimizations */\n                @media (max-width: 640px) {\n                    dialog {\n                        width: 95vw;\n                        max-height: 85vh;\n                        border-radius: 0.5rem;\n                    }\n\n                    .search-input-wrapper {\n                        padding: 1rem;\n                    }\n\n                    .search-input {\n                        font-size: 16px; /* Prevents zoom on iOS */\n                    }\n\n                    .result-item {\n                        padding: 1rem 0.75rem;\n                    }\n\n                    .hint-text {\n                        font-size: 0.8rem;\n                        padding: 0.5rem 1rem;\n                    }\n                }\n            </style>\n\n            <dialog>\n                <div class=\"search-container\">\n                    <div class=\"search-input-wrapper\">\n                        <input \n                            type=\"text\" \n                            class=\"search-input\" \n                            placeholder=\"Search...\"\n                            aria-label=\"Search navigation\"\n                            autocomplete=\"off\"\n                            spellcheck=\"false\"\n                        >\n                    </div>\n                    <div class=\"results-container\" role=\"listbox\"></div>\n                    <div class=\"hint-text\">\n                        <span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>\n                        <span><kbd>Enter</kbd> Select</span>\n                        <span><kbd>Esc</kbd> Close</span>\n                    </div>\n                </div>\n            </dialog>\n        `;\n  }\n\n  setupEventListeners() {\n    const dialog = this.shadowRoot.querySelector(\"dialog\");\n    const input = this.shadowRoot.querySelector(\".search-input\");\n    const resultsContainer =\n      this.shadowRoot.querySelector(\".results-container\");\n\n    // Global keyboard listener for \"/\"\n    document.addEventListener(\"keydown\", (e) => {\n      if (e.key === \"/\" && !this.isInputFocused() && !dialog.open) {\n        e.preventDefault();\n        this.openMenu();\n      }\n    });\n\n    // Input event\n    input.addEventListener(\"input\", (e) => {\n      this.handleSearch(e.target.value);\n    });\n\n    // Keyboard navigation\n    input.addEventListener(\"keydown\", (e) => {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        this.moveSelection(1);\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        this.moveSelection(-1);\n      } else if (e.key === \"Enter\") {\n        e.preventDefault();\n        this.selectCurrentItem();\n      } else if (e.key === \"Escape\") {\n        this.closeMenu();\n      }\n    });\n\n    // Click on result item\n    resultsContainer.addEventListener(\"click\", (e) => {\n      const item = e.target.closest(\".result-item\");\n      if (item) {\n        const index = parseInt(item.dataset.index);\n        this.selectItem(index);\n      }\n    });\n\n    // Close on backdrop click\n    dialog.addEventListener(\"click\", (e) => {\n      if (e.target === dialog) {\n        this.closeMenu();\n      }\n    });\n\n    // Initial load\n    this.loadInitialData();\n  }\n\n  isInputFocused() {\n    const activeElement = document.activeElement;\n    return (\n      activeElement &&\n      (activeElement.tagName === \"INPUT\" ||\n        activeElement.tagName === \"TEXTAREA\" ||\n        activeElement.isContentEditable)\n    );\n  }\n\n  loadInitialData() {\n    const itemsAttr = this.getAttribute(\"items\");\n    if (itemsAttr) {\n      try {\n        this.allItems = JSON.parse(itemsAttr);\n        this.matches = this.allItems;\n      } catch (e) {\n        console.error(\"Failed to parse items attribute:\", e);\n        this.allItems = [];\n        this.matches = [];\n      }\n    }\n  }\n\n  handleSearch(query) {\n    clearTimeout(this.debounceTimer);\n\n    this.debounceTimer = setTimeout(() => {\n      const url = this.getAttribute(\"url\");\n\n      if (url) {\n        // Fetch from API\n        this.fetchResults(url, query);\n      } else {\n        // Filter local items\n        this.filterLocalItems(query);\n      }\n    }, 200);\n  }\n\n  async fetchResults(url, query) {\n    try {\n      const searchUrl = `${url}?q=${encodeURIComponent(query)}`;\n      const response = await fetch(searchUrl);\n      const data = await response.json();\n      this.matches = data.matches || [];\n      this.selectedIndex = this.matches.length > 0 ? 0 : -1;\n      this.renderResults();\n    } catch (e) {\n      console.error(\"Failed to fetch search results:\", e);\n      this.matches = [];\n      this.renderResults();\n    }\n  }\n\n  filterLocalItems(query) {\n    if (!query.trim()) {\n      this.matches = [];\n    } else {\n      const lowerQuery = query.toLowerCase();\n      this.matches = (this.allItems || []).filter(\n        (item) =>\n          item.name.toLowerCase().includes(lowerQuery) ||\n          item.url.toLowerCase().includes(lowerQuery),\n      );\n    }\n    this.selectedIndex = this.matches.length > 0 ? 0 : -1;\n    this.renderResults();\n  }\n\n  renderResults() {\n    const container = this.shadowRoot.querySelector(\".results-container\");\n    const input = this.shadowRoot.querySelector(\".search-input\");\n\n    if (this.matches.length === 0) {\n      const message = input.value.trim()\n        ? \"No results found\"\n        : \"Start typing to search...\";\n      container.innerHTML = `<div class=\"no-results\">${message}</div>`;\n      return;\n    }\n\n    container.innerHTML = this.matches\n      .map(\n        (match, index) => `\n            <div \n                class=\"result-item ${\n                  index === this.selectedIndex ? \"selected\" : \"\"\n                }\" \n                data-index=\"${index}\"\n                role=\"option\"\n                aria-selected=\"${index === this.selectedIndex}\"\n            >\n                <div>\n                    <div class=\"result-name\">${this.escapeHtml(\n                      match.name,\n                    )}</div>\n                    <div class=\"result-url\">${this.escapeHtml(match.url)}</div>\n                </div>\n            </div>\n        `,\n      )\n      .join(\"\");\n\n    // Scroll selected item into view\n    if (this.selectedIndex >= 0) {\n      const selectedItem = container.children[this.selectedIndex];\n      if (selectedItem) {\n        selectedItem.scrollIntoView({ block: \"nearest\" });\n      }\n    }\n  }\n\n  moveSelection(direction) {\n    const newIndex = this.selectedIndex + direction;\n    if (newIndex >= 0 && newIndex < this.matches.length) {\n      this.selectedIndex = newIndex;\n      this.renderResults();\n    }\n  }\n\n  selectCurrentItem() {\n    if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {\n      this.selectItem(this.selectedIndex);\n    }\n  }\n\n  selectItem(index) {\n    const match = this.matches[index];\n    if (match) {\n      // Dispatch custom event\n      this.dispatchEvent(\n        new CustomEvent(\"select\", {\n          detail: match,\n          bubbles: true,\n          composed: true,\n        }),\n      );\n\n      // Navigate to URL\n      window.location.href = match.url;\n\n      this.closeMenu();\n    }\n  }\n\n  openMenu() {\n    const dialog = this.shadowRoot.querySelector(\"dialog\");\n    const input = this.shadowRoot.querySelector(\".search-input\");\n\n    dialog.showModal();\n    input.value = \"\";\n    input.focus();\n\n    // Reset state - start with no items shown\n    this.matches = [];\n    this.selectedIndex = -1;\n    this.renderResults();\n  }\n\n  closeMenu() {\n    const dialog = this.shadowRoot.querySelector(\"dialog\");\n    dialog.close();\n  }\n\n  escapeHtml(text) {\n    const div = document.createElement(\"div\");\n    div.textContent = text;\n    return div.innerHTML;\n  }\n}\n\n// Register the custom element\ncustomElements.define(\"navigation-search\", NavigationSearch);\n"
  },
  {
    "path": "datasette/static/table.js",
    "content": "var DROPDOWN_HTML = `<div class=\"dropdown-menu\">\n<div class=\"hook\"></div>\n<ul class=\"dropdown-actions\"></ul>\n<p class=\"dropdown-column-type\"></p>\n<p class=\"dropdown-column-description\"></p>\n</div>`;\n\nvar DROPDOWN_ICON_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n  <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n  <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n</svg>`;\n\nvar SET_COLUMN_TYPE_DIALOG_ID = \"set-column-type-dialog\";\nvar setColumnTypeDialogState = null;\n\nfunction getParams() {\n  return new URLSearchParams(location.search);\n}\n\nfunction paramsToUrl(params) {\n  var s = params.toString();\n  return s ? \"?\" + s : location.pathname;\n}\n\nfunction sortDescUrl(column) {\n  var params = getParams();\n  params.set(\"_sort_desc\", column);\n  params.delete(\"_sort\");\n  params.delete(\"_next\");\n  return paramsToUrl(params);\n}\n\nfunction sortAscUrl(column) {\n  var params = getParams();\n  params.set(\"_sort\", column);\n  params.delete(\"_sort_desc\");\n  params.delete(\"_next\");\n  return paramsToUrl(params);\n}\n\nfunction facetUrl(column) {\n  var params = getParams();\n  params.append(\"_facet\", column);\n  return paramsToUrl(params);\n}\n\nfunction hideColumnUrl(column) {\n  var params = getParams();\n  params.append(\"_nocol\", column);\n  return paramsToUrl(params);\n}\n\nfunction showAllColumnsUrl() {\n  var params = getParams();\n  params.delete(\"_nocol\");\n  params.delete(\"_col\");\n  return paramsToUrl(params);\n}\n\nfunction notBlankUrl(column) {\n  var params = getParams();\n  params.set(`${column}__notblank`, \"1\");\n  return paramsToUrl(params);\n}\n\nfunction getDisplayedFacets() {\n  return Array.from(document.querySelectorAll(\".facet-info\")).map(\n    (el) => el.dataset.column,\n  );\n}\n\nfunction getColumnClassName(th) {\n  return Array.from(th.classList).find((className) =>\n    className.startsWith(\"col-\"),\n  );\n}\n\nfunction getColumnCells(th) {\n  var table = th.closest(\"table\");\n  var columnClassName = getColumnClassName(th);\n  if (!table || !columnClassName) {\n    return [];\n  }\n  return Array.from(table.querySelectorAll(\"td.\" + columnClassName));\n}\n\nfunction getColumnMeta(th) {\n  return {\n    columnName: th.dataset.column,\n    columnNotNull: th.dataset.columnNotNull === \"1\",\n    columnType: th.dataset.columnType,\n    isPk: th.dataset.isPk === \"1\",\n  };\n}\n\nfunction getColumnTypeText(th) {\n  var columnType = th.dataset.columnType;\n  if (!columnType) {\n    return null;\n  }\n  var notNull = th.dataset.columnNotNull === \"1\" ? \" NOT NULL\" : \"\";\n  return `Type: ${columnType.toUpperCase()}${notNull}`;\n}\n\nfunction getSetColumnTypeData() {\n  return window._setColumnTypeData || null;\n}\n\nfunction getSetColumnTypeConfig(column) {\n  var data = getSetColumnTypeData();\n  if (!data || !data.columns) {\n    return null;\n  }\n  return data.columns[column] || null;\n}\n\nfunction canSetColumnType() {\n  return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch);\n}\n\nfunction setColumnTypeActionLabel(column) {\n  var columnConfig = getSetColumnTypeConfig(column);\n  if (!columnConfig) {\n    return null;\n  }\n  return columnConfig.current\n    ? `Custom type: ${columnConfig.current.type}`\n    : \"Set custom type\";\n}\n\nfunction createSetColumnTypeOption(value, name, description, checked) {\n  var label = document.createElement(\"label\");\n  label.className = \"set-column-type-option\";\n\n  var input = document.createElement(\"input\");\n  input.type = \"radio\";\n  input.name = \"set-column-type-choice\";\n  input.value = value;\n  input.checked = checked;\n\n  var content = document.createElement(\"span\");\n  content.className = \"set-column-type-option-content\";\n\n  var title = document.createElement(\"span\");\n  title.className = \"set-column-type-option-name\";\n  title.textContent = name;\n\n  var detail = document.createElement(\"span\");\n  detail.className = \"set-column-type-option-description\";\n  detail.textContent = description;\n\n  content.appendChild(title);\n  content.appendChild(detail);\n  label.appendChild(input);\n  label.appendChild(content);\n  return label;\n}\n\nfunction setSetColumnTypeDialogBusy(state, isBusy) {\n  state.isBusy = isBusy;\n  state.saveButton.disabled = isBusy;\n  state.cancelButton.disabled = isBusy;\n  Array.from(\n    state.optionsWrap.querySelectorAll('input[name=\"set-column-type-choice\"]'),\n  ).forEach(function (input) {\n    input.disabled = isBusy;\n  });\n  state.saveButton.textContent = isBusy ? \"Saving...\" : \"Save\";\n}\n\nfunction clearSetColumnTypeDialogError(state) {\n  state.error.hidden = true;\n  state.error.textContent = \"\";\n}\n\nfunction showSetColumnTypeDialogError(state, message) {\n  state.error.hidden = false;\n  state.error.textContent = message;\n}\n\nfunction ensureSetColumnTypeDialog() {\n  if (setColumnTypeDialogState) {\n    return setColumnTypeDialogState;\n  }\n  if (!window.HTMLDialogElement) {\n    return null;\n  }\n\n  var dialog = document.createElement(\"dialog\");\n  dialog.id = SET_COLUMN_TYPE_DIALOG_ID;\n  dialog.className = \"set-column-type-dialog\";\n  dialog.setAttribute(\"aria-labelledby\", \"set-column-type-title\");\n  dialog.innerHTML = `\n    <div class=\"modal-header\">\n      <span class=\"modal-title\" id=\"set-column-type-title\">Set custom type</span>\n      <span class=\"modal-meta\"></span>\n    </div>\n    <p class=\"set-column-type-status\"></p>\n    <p class=\"set-column-type-error\" hidden></p>\n    <div class=\"set-column-type-options\"></div>\n    <div class=\"modal-footer\">\n      <span class=\"footer-info\"></span>\n      <button type=\"button\" class=\"btn btn-ghost set-column-type-cancel\">Cancel</button>\n      <button type=\"button\" class=\"btn btn-primary set-column-type-save\">Save</button>\n    </div>\n  `;\n  document.body.appendChild(dialog);\n\n  setColumnTypeDialogState = {\n    dialog: dialog,\n    meta: dialog.querySelector(\".modal-meta\"),\n    status: dialog.querySelector(\".set-column-type-status\"),\n    error: dialog.querySelector(\".set-column-type-error\"),\n    optionsWrap: dialog.querySelector(\".set-column-type-options\"),\n    footerInfo: dialog.querySelector(\".footer-info\"),\n    cancelButton: dialog.querySelector(\".set-column-type-cancel\"),\n    saveButton: dialog.querySelector(\".set-column-type-save\"),\n    currentColumn: null,\n    currentConfig: null,\n    isBusy: false,\n  };\n\n  setColumnTypeDialogState.cancelButton.addEventListener(\"click\", function () {\n    if (!setColumnTypeDialogState.isBusy) {\n      dialog.close();\n    }\n  });\n\n  dialog.addEventListener(\"click\", function (ev) {\n    if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {\n      dialog.close();\n    }\n  });\n\n  dialog.addEventListener(\"cancel\", function (ev) {\n    if (setColumnTypeDialogState.isBusy) {\n      ev.preventDefault();\n    }\n  });\n\n  dialog.addEventListener(\"close\", function () {\n    clearSetColumnTypeDialogError(setColumnTypeDialogState);\n    setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);\n  });\n\n  setColumnTypeDialogState.saveButton.addEventListener(\"click\", async function () {\n    var state = setColumnTypeDialogState;\n    var selected = state.dialog.querySelector(\n      'input[name=\"set-column-type-choice\"]:checked',\n    );\n    var selectedType = selected ? selected.value : \"\";\n    var currentType = state.currentConfig.current\n      ? state.currentConfig.current.type\n      : \"\";\n\n    if (selectedType === currentType) {\n      state.dialog.close();\n      return;\n    }\n\n    clearSetColumnTypeDialogError(state);\n    setSetColumnTypeDialogBusy(state, true);\n\n    var payload = {\n      column: state.currentColumn,\n      column_type: selectedType ? { type: selectedType } : null,\n    };\n\n    try {\n      var response = await fetch(getSetColumnTypeData().path, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Accept: \"application/json\",\n        },\n        body: JSON.stringify(payload),\n      });\n      var data = await response.json();\n      if (!response.ok || data.ok === false) {\n        var message = (data.errors || [\"Request failed\"]).join(\" \");\n        throw new Error(message);\n      }\n      location.reload();\n    } catch (error) {\n      setSetColumnTypeDialogBusy(state, false);\n      showSetColumnTypeDialogError(state, error.message || \"Request failed\");\n    }\n  });\n\n  return setColumnTypeDialogState;\n}\n\nfunction openSetColumnTypeDialog(th) {\n  var column = th.dataset.column;\n  var columnConfig = getSetColumnTypeConfig(column);\n  if (!columnConfig) {\n    return;\n  }\n\n  var state = ensureSetColumnTypeDialog();\n  if (!state) {\n    return;\n  }\n\n  clearSetColumnTypeDialogError(state);\n  setSetColumnTypeDialogBusy(state, false);\n  state.currentColumn = column;\n  state.currentConfig = columnConfig;\n  state.status.textContent = `Column: ${column}`;\n  state.meta.textContent = getColumnTypeText(th) || \"Type unavailable\";\n  state.footerInfo.textContent = columnConfig.current\n    ? `Current custom type: ${columnConfig.current.type}`\n    : \"No custom type set.\";\n  state.optionsWrap.innerHTML = \"\";\n\n  var currentType = columnConfig.current ? columnConfig.current.type : \"\";\n  state.optionsWrap.appendChild(\n    createSetColumnTypeOption(\n      \"\",\n      \"No custom type\",\n      \"Use standard Datasette rendering without a custom type.\",\n      currentType === \"\",\n    ),\n  );\n\n  columnConfig.options.forEach(function (option) {\n    state.optionsWrap.appendChild(\n      createSetColumnTypeOption(\n        option.name,\n        option.name,\n        option.description,\n        option.name === currentType,\n      ),\n    );\n  });\n\n  if (!columnConfig.options.length) {\n    var emptyState = document.createElement(\"p\");\n    emptyState.className = \"set-column-type-empty\";\n    emptyState.textContent =\n      \"No registered custom types are compatible with this SQLite type.\";\n    state.optionsWrap.appendChild(emptyState);\n  }\n\n  if (!state.dialog.open) {\n    state.dialog.showModal();\n  }\n  var selectedOption = state.dialog.querySelector(\n    'input[name=\"set-column-type-choice\"]:checked',\n  );\n  if (selectedOption) {\n    selectedOption.focus();\n  } else {\n    state.saveButton.focus();\n  }\n}\n\nfunction canChooseColumns() {\n  return !!(\n    document.querySelector(\"column-chooser\") && window._columnChooserData\n  );\n}\n\nfunction shouldShowShowAllColumns() {\n  var params = getParams();\n  return params.getAll(\"_nocol\").length || params.getAll(\"_col\").length;\n}\n\nfunction hasMultipleVisibleColumns(manager) {\n  return (\n    Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(\n      (th) => th.dataset.column && th.dataset.isLinkColumn !== \"1\",\n    ).length > 1\n  );\n}\n\nfunction buildColumnActionItems(manager, th, options) {\n  options = options || {};\n  var params = getParams();\n  var column = th.dataset.column;\n  var columnActions = [];\n  var isSortable = !!th.querySelector(\"a\");\n  var isFirstColumn = th.parentElement.querySelector(\"th:first-of-type\") === th;\n  var isSinglePk =\n    th.dataset.isPk === \"1\" &&\n    document.querySelectorAll('th[data-is-pk=\"1\"]').length === 1;\n  var hasBlankValues = getColumnCells(th).some(\n    (el) => el.innerText.trim() === \"\",\n  );\n\n  if (isSortable && params.get(\"_sort\") !== column) {\n    columnActions.push({\n      label: \"Sort ascending\",\n      href: sortAscUrl(column),\n    });\n  }\n\n  if (isSortable && params.get(\"_sort_desc\") !== column) {\n    columnActions.push({\n      label: \"Sort descending\",\n      href: sortDescUrl(column),\n    });\n  }\n\n  if (\n    DATASETTE_ALLOW_FACET &&\n    !isFirstColumn &&\n    !getDisplayedFacets().includes(column) &&\n    !isSinglePk\n  ) {\n    columnActions.push({\n      label: \"Facet by this\",\n      href: facetUrl(column),\n    });\n  }\n\n  if (options.includeChooseColumns && canChooseColumns()) {\n    columnActions.push({\n      label: \"Choose columns\",\n      href: \"#\",\n      onClick:\n        options.onChooseColumns ||\n        function (ev) {\n          ev.preventDefault();\n          openColumnChooser();\n        },\n    });\n  }\n\n  if (canSetColumnType() && getSetColumnTypeConfig(column)) {\n    columnActions.push({\n      label: setColumnTypeActionLabel(column),\n      href: \"#\",\n      onClick:\n        options.onSetColumnType ||\n        function (ev) {\n          ev.preventDefault();\n          window.setTimeout(function () {\n            openSetColumnTypeDialog(th);\n          }, 0);\n        },\n    });\n  }\n\n  if (th.dataset.isPk !== \"1\" && hasMultipleVisibleColumns(manager)) {\n    columnActions.push({\n      label: \"Hide this column\",\n      href: hideColumnUrl(column),\n    });\n  }\n\n  if (options.includeShowAllColumns && shouldShowShowAllColumns()) {\n    columnActions.push({\n      label: \"Show all columns\",\n      href: showAllColumnsUrl(),\n    });\n  }\n\n  if (params.get(`${column}__notblank`) !== \"1\" && hasBlankValues) {\n    columnActions.push({\n      label: \"Show not-blank rows\",\n      href: notBlankUrl(column),\n    });\n  }\n\n  return columnActions.concat(manager.makeColumnActions(getColumnMeta(th)));\n}\n\nfunction buildColumnActionState(manager, th, options) {\n  return {\n    column: th.dataset.column,\n    columnDescription: th.dataset.columnDescription || null,\n    columnMeta: getColumnMeta(th),\n    columnTypeText: getColumnTypeText(th),\n    actionItems: buildColumnActionItems(manager, th, options),\n  };\n}\n\nfunction initializeColumnActions(manager) {\n  manager.columnActions = {\n    buildColumnActionState: function (th, options) {\n      return buildColumnActionState(manager, th, options);\n    },\n    buildColumnActionItems: function (th, options) {\n      return buildColumnActionItems(manager, th, options);\n    },\n    canChooseColumns: canChooseColumns,\n    facetUrl: facetUrl,\n    getColumnMeta: getColumnMeta,\n    getColumnTypeText: getColumnTypeText,\n    hideColumnUrl: hideColumnUrl,\n    notBlankUrl: notBlankUrl,\n    shouldShowShowAllColumns: shouldShowShowAllColumns,\n    showAllColumnsUrl: showAllColumnsUrl,\n    sortAscUrl: sortAscUrl,\n    sortDescUrl: sortDescUrl,\n  };\n}\n\nfunction renderActionLink(itemConfig) {\n  var newLink = document.createElement(\"a\");\n  newLink.textContent = itemConfig.label;\n  newLink.href = itemConfig.href || \"#\";\n  if (itemConfig.onClick) {\n    newLink.addEventListener(\"click\", itemConfig.onClick);\n  }\n  return newLink;\n}\n\n/** Main initialization function for Datasette Table interactions */\nconst initDatasetteTable = function (manager) {\n  // Feature detection\n  if (!window.URLSearchParams) {\n    return;\n  }\n  function closeMenu() {\n    menu.style.display = \"none\";\n    menu.classList.remove(\"anim-scale-in\");\n  }\n\n  const tableWrapper = document.querySelector(manager.selectors.tableWrapper);\n  if (tableWrapper) {\n    tableWrapper.addEventListener(\"scroll\", closeMenu);\n  }\n  document.body.addEventListener(\"click\", (ev) => {\n    /* was this click outside the menu? */\n    var target = ev.target;\n    while (target && target != menu) {\n      target = target.parentNode;\n    }\n    if (!target) {\n      closeMenu();\n    }\n  });\n\n  function onTableHeaderClick(ev) {\n    ev.preventDefault();\n    ev.stopPropagation();\n    menu.innerHTML = DROPDOWN_HTML;\n    var th = ev.target;\n    while (th.nodeName != \"TH\") {\n      th = th.parentNode;\n    }\n    var rect = th.getBoundingClientRect();\n    var menuTop = rect.bottom + window.scrollY;\n    var menuLeft = rect.left + window.scrollX;\n    var actionState = manager.columnActions.buildColumnActionState(th, {\n      includeChooseColumns: true,\n      includeShowAllColumns: true,\n      onChooseColumns: function (ev) {\n        ev.preventDefault();\n        closeMenu();\n        openColumnChooser();\n      },\n      onSetColumnType: function (ev) {\n        ev.preventDefault();\n        closeMenu();\n        window.setTimeout(function () {\n          openSetColumnTypeDialog(th);\n        }, 0);\n      },\n    });\n    var menuList = menu.querySelector(\"ul.dropdown-actions\");\n    menuList.innerHTML = \"\";\n    actionState.actionItems.forEach((itemConfig) => {\n      var menuItem = document.createElement(\"li\");\n      menuItem.appendChild(renderActionLink(itemConfig));\n      menuList.appendChild(menuItem);\n    });\n\n    var columnTypeP = menu.querySelector(\".dropdown-column-type\");\n    if (actionState.columnTypeText) {\n      columnTypeP.style.display = \"block\";\n      columnTypeP.innerText = actionState.columnTypeText;\n    } else {\n      columnTypeP.style.display = \"none\";\n    }\n\n    var columnDescriptionP = menu.querySelector(\".dropdown-column-description\");\n    if (actionState.columnDescription) {\n      columnDescriptionP.innerText = actionState.columnDescription;\n      columnDescriptionP.style.display = \"block\";\n    } else {\n      columnDescriptionP.style.display = \"none\";\n    }\n    menu.style.position = \"absolute\";\n    menu.style.top = menuTop + 6 + \"px\";\n    menu.style.left = menuLeft + \"px\";\n    menu.style.display = \"block\";\n    menu.classList.add(\"anim-scale-in\");\n\n    // Measure width of menu and adjust position if too far right\n    const menuWidth = menu.offsetWidth;\n    const windowWidth = window.innerWidth;\n    if (menuLeft + menuWidth > windowWidth) {\n      menu.style.left = windowWidth - menuWidth - 20 + \"px\";\n    }\n    // Align menu .hook arrow with the column cog icon\n    const hook = menu.querySelector(\".hook\");\n    const icon = th.querySelector(\".dropdown-menu-icon\");\n    const iconRect = icon.getBoundingClientRect();\n    const hookLeft = iconRect.left - menuLeft + 1 + \"px\";\n    hook.style.left = hookLeft;\n    // Move the whole menu right if the hook is too far right\n    const menuRect = menu.getBoundingClientRect();\n    if (iconRect.right > menuRect.right) {\n      menu.style.left = iconRect.right - menuWidth + \"px\";\n      // And move hook tip as well\n      hook.style.left = menuWidth - 13 + \"px\";\n    }\n  }\n\n  var svg = document.createElement(\"div\");\n  svg.innerHTML = DROPDOWN_ICON_SVG;\n  svg = svg.querySelector(\"*\");\n  svg.classList.add(\"dropdown-menu-icon\");\n  var menu = document.createElement(\"div\");\n  menu.innerHTML = DROPDOWN_HTML;\n  menu = menu.querySelector(\"*\");\n  menu.style.position = \"absolute\";\n  menu.style.display = \"none\";\n  document.body.appendChild(menu);\n\n  var ths = Array.from(\n    document.querySelectorAll(manager.selectors.tableHeaders),\n  );\n  ths.forEach((th) => {\n    if (!th.querySelector(\"a\")) {\n      return;\n    }\n    var icon = svg.cloneNode(true);\n    icon.addEventListener(\"click\", onTableHeaderClick);\n    th.appendChild(icon);\n  });\n};\n\n/* Add x buttons to the filter rows */\nfunction addButtonsToFilterRows(manager) {\n  var x = \"✖\";\n  var rows = Array.from(\n    document.querySelectorAll(manager.selectors.filterRow),\n  ).filter((el) => el.querySelector(\".filter-op\"));\n  rows.forEach((row) => {\n    var a = document.createElement(\"a\");\n    a.setAttribute(\"href\", \"#\");\n    a.setAttribute(\"aria-label\", \"Remove this filter\");\n    a.style.textDecoration = \"none\";\n    a.innerText = x;\n    a.addEventListener(\"click\", (ev) => {\n      ev.preventDefault();\n      let row = ev.target.closest(\"div\");\n      row.querySelector(\"select\").value = \"\";\n      row.querySelector(\".filter-op select\").value = \"exact\";\n      row.querySelector(\"input.filter-value\").value = \"\";\n      ev.target.closest(\"a\").style.display = \"none\";\n    });\n    row.appendChild(a);\n    var column = row.querySelector(\"select\");\n    if (!column.value) {\n      a.style.display = \"none\";\n    }\n  });\n}\n\n/* Set up datalist autocomplete for filter values */\nfunction initAutocompleteForFilterValues(manager) {\n  function createDataLists() {\n    var facetResults = document.querySelectorAll(\n      manager.selectors.facetResults,\n    );\n    Array.from(facetResults).forEach(function (facetResult) {\n      // Use link text from all links in the facet result\n      var links = Array.from(\n        facetResult.querySelectorAll(\"li:not(.facet-truncated) a\"),\n      );\n      // Create a datalist element\n      var datalist = document.createElement(\"datalist\");\n      datalist.id = \"datalist-\" + facetResult.dataset.column;\n      // Create an option element for each link text\n      links.forEach(function (link) {\n        var option = document.createElement(\"option\");\n        option.label = link.innerText;\n        option.value = link.dataset.facetValue;\n        datalist.appendChild(option);\n      });\n      // Add the datalist to the facet result\n      facetResult.appendChild(datalist);\n    });\n  }\n  createDataLists();\n  // When any select with name=_filter_column changes, update the datalist\n  document.body.addEventListener(\"change\", function (event) {\n    if (event.target.name === \"_filter_column\") {\n      event.target\n        .closest(manager.selectors.filterRow)\n        .querySelector(\".filter-value\")\n        .setAttribute(\"list\", \"datalist-\" + event.target.value);\n    }\n  });\n}\n\n/** Open the column-chooser web component */\nfunction openColumnChooser() {\n  var chooser = document.querySelector(\"column-chooser\");\n  var data = window._columnChooserData;\n  if (!chooser || !data) return;\n\n  var nonPkColumns = data.allColumns.filter(function (col) {\n    return data.primaryKeys.indexOf(col) === -1;\n  });\n  var selected = data.selectedColumns.filter(function (col) {\n    return data.primaryKeys.indexOf(col) === -1;\n  });\n\n  chooser.open({\n    columns: nonPkColumns,\n    selected: selected,\n    onApply: function (cols) {\n      var params = new URLSearchParams(location.search);\n      params.delete(\"_col\");\n      params.delete(\"_nocol\");\n      params.delete(\"_next\");\n\n      if (cols.length === nonPkColumns.length) {\n        // Check if order matches original - if so, no params needed\n        var orderMatches = cols.every(function (col, i) {\n          return col === nonPkColumns[i];\n        });\n        if (!orderMatches) {\n          cols.forEach(function (col) {\n            params.append(\"_col\", col);\n          });\n        }\n      } else {\n        cols.forEach(function (col) {\n          params.append(\"_col\", col);\n        });\n      }\n      var qs = params.toString();\n      location.href = qs ? \"?\" + qs : location.pathname;\n    },\n  });\n}\n\n// Ensures Table UI is initialized only after the Manager is ready.\ndocument.addEventListener(\"datasette_init\", function (evt) {\n  const { detail: manager } = evt;\n\n  initializeColumnActions(manager);\n\n  // Main table\n  initDatasetteTable(manager);\n\n  // Other UI functions with interactive JS needs\n  addButtonsToFilterRows(manager);\n  initAutocompleteForFilterValues(manager);\n});\n"
  },
  {
    "path": "datasette/templates/_action_menu.html",
    "content": "{% if action_links %}\n<div class=\"page-action-menu\">\n<details class=\"actions-menu-links details-menu\">\n    <summary>\n        <div class=\"icon-text\">\n            <svg class=\"icon\" aria-labelledby=\"actions-menu-links-title\" role=\"img\" style=\"color: #fff\" xmlns=\"http://www.w3.org/2000/svg\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <title id=\"actions-menu-links-title\">{{ action_title }}</title>\n                <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n                <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n            </svg>\n            <span>{{ action_title }}</span>\n        </div>\n    </summary>\n    <div class=\"dropdown-menu\">\n        <div class=\"hook\"></div>\n        <ul>\n            {% for link in action_links %}\n            <li><a href=\"{{ link.href }}\">{{ link.label }}\n            {% if link.description %}\n                <p class=\"dropdown-description\">{{ link.description }}</p>\n            {% endif %}</a>\n            </li>\n            {% endfor %}\n        </ul>\n    </div>\n</details>\n</div>\n{% endif %}"
  },
  {
    "path": "datasette/templates/_close_open_menus.html",
    "content": "<script>\ndocument.body.addEventListener('click', (ev) => {\n    /* Close any open details elements that this click is outside of */\n    var target = ev.target;\n    var detailsClickedWithin = null;\n    while (target && target.tagName != 'DETAILS') {\n        target = target.parentNode;\n    }\n    if (target && target.tagName == 'DETAILS') {\n        detailsClickedWithin = target;\n    }\n    Array.from(document.querySelectorAll('details.details-menu')).filter(\n        (details) => details.open && details != detailsClickedWithin\n    ).forEach(details => details.open = false);\n});\n</script>\n"
  },
  {
    "path": "datasette/templates/_codemirror.html",
    "content": "<script src=\"{{ base_url }}-/static/sql-formatter-2.3.3.min.js\" defer></script>\n<script src=\"{{ base_url }}-/static/cm-editor-6.0.1.bundle.js\"></script>\n<style>\n  .cm-editor {\n    resize: both;\n    overflow: hidden;\n    width: 80%;\n    border: 1px solid #ddd;\n  }\n  /* Fix autocomplete icon positioning. The icon element gets border-box sizing set due to\n     the global reset, but this causes overlapping icon and text. Markup:\n     `<div class=\"cm-completionIcon cm-completionIcon-keyword\" aria-hidden=\"true\"></div>` */\n  .cm-completionIcon {\n    box-sizing: content-box;\n  }\n</style>\n"
  },
  {
    "path": "datasette/templates/_codemirror_foot.html",
    "content": "<script>\n  {% if table_columns %}\n  const schema = {{ table_columns|tojson(2) }};\n  {% else %}\n  const schema = {};\n  {% endif %}\n\n  window.addEventListener(\"DOMContentLoaded\", () => {\n    const sqlFormat = document.querySelector(\"button#sql-format\");\n    const readOnly = document.querySelector(\"pre#sql-query\");\n    const sqlInput = document.querySelector(\"textarea#sql-editor\");\n    if (sqlFormat && !readOnly) {\n      sqlFormat.hidden = false;\n    }\n    if (sqlInput) {\n      var editor = (window.editor = cm.editorFromTextArea(sqlInput, {\n        schema,\n      }));\n      if (sqlFormat) {\n        sqlFormat.addEventListener(\"click\", (ev) => {\n          const formatted = sqlFormatter.format(editor.state.doc.toString());\n          editor.dispatch({\n            changes: {\n              from: 0,\n              to: editor.state.doc.length,\n              insert: formatted,\n            },\n          });\n        });\n      }\n    }\n    if (sqlFormat && readOnly) {\n      const formatted = sqlFormatter.format(readOnly.innerHTML);\n      if (formatted != readOnly.innerHTML) {\n        sqlFormat.hidden = false;\n        sqlFormat.addEventListener(\"click\", (ev) => {\n          readOnly.innerHTML = formatted;\n        });\n      }\n    }\n  });\n</script>\n"
  },
  {
    "path": "datasette/templates/_crumbs.html",
    "content": "{% macro nav(request, database=None, table=None) -%}\n{% if crumb_items is defined %}\n  {% set items=crumb_items(request=request, database=database, table=table) %}\n  {% if items %}\n    <p class=\"crumbs\">\n      {% for item in items %}\n        <a href=\"{{ item.href }}\">{{ item.label }}</a>\n        {% if not loop.last %}\n          /\n        {% endif %}\n      {% endfor %}\n    </p>\n  {% endif %}\n{% endif %}\n{%- endmacro %}\n"
  },
  {
    "path": "datasette/templates/_debug_common_functions.html",
    "content": "<script>\n// Common utility functions for debug pages\n\n// Populate form from URL parameters on page load\nfunction populateFormFromURL() {\n    const params = new URLSearchParams(window.location.search);\n\n    const action = params.get('action');\n    if (action) {\n        const actionField = document.getElementById('action');\n        if (actionField) {\n            actionField.value = action;\n        }\n    }\n\n    const parent = params.get('parent');\n    if (parent) {\n        const parentField = document.getElementById('parent');\n        if (parentField) {\n            parentField.value = parent;\n        }\n    }\n\n    const child = params.get('child');\n    if (child) {\n        const childField = document.getElementById('child');\n        if (childField) {\n            childField.value = child;\n        }\n    }\n\n    const pageSize = params.get('page_size');\n    if (pageSize) {\n        const pageSizeField = document.getElementById('page_size');\n        if (pageSizeField) {\n            pageSizeField.value = pageSize;\n        }\n    }\n\n    return params;\n}\n\n// HTML escape function\nfunction escapeHtml(text) {\n    if (text === null || text === undefined) return '';\n    const div = document.createElement('div');\n    div.textContent = text;\n    return div.innerHTML;\n}\n</script>\n"
  },
  {
    "path": "datasette/templates/_description_source_license.html",
    "content": "{% if metadata.get(\"description_html\") or metadata.get(\"description\") %}\n    <div class=\"metadata-description\">\n        {% if metadata.get(\"description_html\") %}\n            {{ metadata.description_html|safe }}\n        {% else %}\n            {{ metadata.description }}\n        {% endif %}\n    </div>\n{% endif %}\n{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}\n    <p>\n    {% if metadata.license or metadata.license_url %}Data license:\n        {% if metadata.license_url %}\n            <a href=\"{{ metadata.license_url }}\">{{ metadata.license or metadata.license_url }}</a>\n        {% else %}\n            {{ metadata.license }}\n        {% endif %}\n    {% endif %}\n    {% if metadata.source or metadata.source_url %}{% if metadata.license or metadata.license_url %}&middot;{% endif %}\n        Data source: {% if metadata.source_url %}\n            <a href=\"{{ metadata.source_url }}\">\n        {% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}\n    {% endif %}\n    {% if metadata.about or metadata.about_url %}{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}&middot;{% endif %}\n        About: {% if metadata.about_url %}\n            <a href=\"{{ metadata.about_url }}\">\n        {% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}\n    {% endif %}\n    </p>\n{% endif %}\n"
  },
  {
    "path": "datasette/templates/_facet_results.html",
    "content": "<div class=\"facet-results\">\n    {% for facet_info in sorted_facet_results %}\n        <div class=\"facet-info facet-{{ database|to_css_class }}-{{ table|to_css_class }}-{{ facet_info.name|to_css_class }}\" id=\"facet-{{ facet_info.name|to_css_class }}\" data-column=\"{{ facet_info.name }}\">\n            <p class=\"facet-info-name\">\n                <strong>{{ facet_info.name }}{% if facet_info.type != \"column\" %} ({{ facet_info.type }}){% endif %}\n                    <span class=\"facet-info-total\">{% if facet_info.truncated %}&gt;{% endif %}{{ facet_info.results|length }}</span>\n                </strong>\n                {% if facet_info.hideable %}\n                    <a href=\"{{ facet_info.toggle_url }}\" class=\"cross\">&#x2716;</a>\n                {% endif %}\n            </p>\n            <ul class=\"tight-bullets\">\n                {% for facet_value in facet_info.results %}\n                    {% if not facet_value.selected %}\n                        <li><a href=\"{{ facet_value.toggle_url }}\" data-facet-value=\"{{ facet_value.value }}\">{{ (facet_value.label | string()) or \"-\" }}</a> {{ \"{:,}\".format(facet_value.count) }}</li>\n                    {% else %}\n                        <li>{{ facet_value.label or \"-\" }} &middot; {{ \"{:,}\".format(facet_value.count) }} <a href=\"{{ facet_value.toggle_url }}\" class=\"cross\">&#x2716;</a></li>\n                    {% endif %}\n                {% endfor %}\n                {% if facet_info.truncated %}\n                    <li class=\"facet-truncated\">{% if request.args._facet_size != \"max\" -%}\n                        <a href=\"{{ path_with_replaced_args(request, {\"_facet_size\": \"max\"}) }}\">…</a>{% else -%}…{% endif %}\n                    </li>\n                {% endif %}\n            </ul>\n        </div>\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "datasette/templates/_footer.html",
    "content": "Powered by <a href=\"https://datasette.io/\" title=\"Datasette v{{ datasette_version }}\">Datasette</a>\n{% if query_ms %}&middot; Queries took {{ query_ms|round(3) }}ms{% endif %}\n{% if metadata %}\n    {% if metadata.license or metadata.license_url %}&middot; Data license:\n        {% if metadata.license_url %}\n            <a href=\"{{ metadata.license_url }}\">{{ metadata.license or metadata.license_url }}</a>\n        {% else %}\n            {{ metadata.license }}\n        {% endif %}\n    {% endif %}\n    {% if metadata.source or metadata.source_url %}&middot;\n        Data source: {% if metadata.source_url %}\n            <a href=\"{{ metadata.source_url }}\">\n        {% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}\n    {% endif %}\n    {% if metadata.about or metadata.about_url %}&middot;\n        About: {% if metadata.about_url %}\n            <a href=\"{{ metadata.about_url }}\">\n        {% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "datasette/templates/_permission_ui_styles.html",
    "content": "<style>\n.permission-form {\n    background-color: #f5f5f5;\n    border: 1px solid #ddd;\n    border-radius: 5px;\n    padding: 1.5em;\n    margin-bottom: 2em;\n}\n.form-section {\n    margin-bottom: 1em;\n}\n.form-section label {\n    display: block;\n    margin-bottom: 0.3em;\n    font-weight: bold;\n}\n.form-section input[type=\"text\"],\n.form-section select {\n    width: 100%;\n    max-width: 500px;\n    padding: 0.5em;\n    box-sizing: border-box;\n    border: 1px solid #ccc;\n    border-radius: 3px;\n}\n.form-section input[type=\"text\"]:focus,\n.form-section select:focus {\n    outline: 2px solid #0066cc;\n    border-color: #0066cc;\n}\n.form-section small {\n    display: block;\n    margin-top: 0.3em;\n    color: #666;\n}\n.form-actions {\n    margin-top: 1em;\n}\n.submit-btn {\n    padding: 0.6em 1.5em;\n    font-size: 1em;\n    background-color: #0066cc;\n    color: white;\n    border: none;\n    border-radius: 3px;\n    cursor: pointer;\n}\n.submit-btn:hover {\n    background-color: #0052a3;\n}\n.submit-btn:disabled {\n    background-color: #ccc;\n    cursor: not-allowed;\n}\n.results-container {\n    margin-top: 2em;\n}\n.results-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 1em;\n}\n.results-count {\n    font-size: 0.9em;\n    color: #666;\n}\n.results-table {\n    width: 100%;\n    border-collapse: collapse;\n    background-color: white;\n    box-shadow: 0 1px 3px rgba(0,0,0,0.1);\n}\n.results-table th {\n    background-color: #f5f5f5;\n    padding: 0.75em;\n    text-align: left;\n    font-weight: bold;\n    border-bottom: 2px solid #ddd;\n}\n.results-table td {\n    padding: 0.75em;\n    border-bottom: 1px solid #eee;\n}\n.results-table tr:hover {\n    background-color: #f9f9f9;\n}\n.results-table tr.allow-row {\n    background-color: #f1f8f4;\n}\n.results-table tr.allow-row:hover {\n    background-color: #e8f5e9;\n}\n.results-table tr.deny-row {\n    background-color: #fef5f5;\n}\n.results-table tr.deny-row:hover {\n    background-color: #ffebee;\n}\n.resource-path {\n    font-family: monospace;\n    background-color: #f5f5f5;\n    padding: 0.2em 0.4em;\n    border-radius: 3px;\n}\n.pagination {\n    margin-top: 1.5em;\n    display: flex;\n    gap: 1em;\n    align-items: center;\n}\n.pagination a {\n    padding: 0.5em 1em;\n    background-color: #0066cc;\n    color: white;\n    text-decoration: none;\n    border-radius: 3px;\n}\n.pagination a:hover {\n    background-color: #0052a3;\n}\n.pagination span {\n    color: #666;\n}\n.no-results {\n    padding: 2em;\n    text-align: center;\n    color: #666;\n    background-color: #f9f9f9;\n    border: 1px solid #ddd;\n    border-radius: 5px;\n}\n.error-message {\n    padding: 1em;\n    background-color: #ffebee;\n    border: 2px solid #f44336;\n    border-radius: 5px;\n    color: #c62828;\n}\n.loading {\n    padding: 2em;\n    text-align: center;\n    color: #666;\n}\n</style>\n"
  },
  {
    "path": "datasette/templates/_permissions_debug_tabs.html",
    "content": "{% if has_debug_permission %}\n{% set query_string = '?' + request.query_string if request.query_string else '' %}\n\n<style>\n.permissions-debug-tabs {\n    border-bottom: 2px solid #e0e0e0;\n    margin-bottom: 2em;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5em;\n}\n.permissions-debug-tabs a {\n    padding: 0.75em 1.25em;\n    text-decoration: none;\n    color: #333;\n    border-bottom: 3px solid transparent;\n    margin-bottom: -2px;\n    transition: all 0.2s;\n    font-weight: 500;\n}\n.permissions-debug-tabs a:hover {\n    background-color: #f5f5f5;\n    border-bottom-color: #999;\n}\n.permissions-debug-tabs a.active {\n    color: #0066cc;\n    border-bottom-color: #0066cc;\n    background-color: #f0f7ff;\n}\n@media only screen and (max-width: 576px) {\n    .permissions-debug-tabs {\n        flex-direction: column;\n        gap: 0;\n    }\n    .permissions-debug-tabs a {\n        border-bottom: 1px solid #e0e0e0;\n        margin-bottom: 0;\n    }\n    .permissions-debug-tabs a.active {\n        border-left: 3px solid #0066cc;\n        border-bottom: 1px solid #e0e0e0;\n    }\n}\n</style>\n\n<nav class=\"permissions-debug-tabs\">\n    <a href=\"{{ urls.path('-/permissions') }}\" {% if current_tab == \"permissions\" %}class=\"active\"{% endif %}>Playground</a>\n    <a href=\"{{ urls.path('-/check') }}{{ query_string }}\" {% if current_tab == \"check\" %}class=\"active\"{% endif %}>Check</a>\n    <a href=\"{{ urls.path('-/allowed') }}{{ query_string }}\" {% if current_tab == \"allowed\" %}class=\"active\"{% endif %}>Allowed</a>\n    <a href=\"{{ urls.path('-/rules') }}{{ query_string }}\" {% if current_tab == \"rules\" %}class=\"active\"{% endif %}>Rules</a>\n    <a href=\"{{ urls.path('-/actions') }}\" {% if current_tab == \"actions\" %}class=\"active\"{% endif %}>Actions</a>\n    <a href=\"{{ urls.path('-/allow-debug') }}\" {% if current_tab == \"allow_debug\" %}class=\"active\"{% endif %}>Allow debug</a>\n</nav>\n{% endif %}\n"
  },
  {
    "path": "datasette/templates/_suggested_facets.html",
    "content": "<p class=\"suggested-facets\">\n    Suggested facets: {% for facet in suggested_facets %}<a href=\"{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}\">{{ facet.name }}</a>{% if facet.get(\"type\") %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}\n</p>\n"
  },
  {
    "path": "datasette/templates/_table.html",
    "content": "<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->\n<div class=\"above-table-panel\"> </div>\n{% if display_rows %}\n<div class=\"table-wrapper\">\n    <table class=\"rows-and-columns\">\n        <thead>\n            <tr>\n                {% for column in display_columns %}\n                    <th {% if column.description %}data-column-description=\"{{ column.description }}\" {% endif %}class=\"col-{{ column.name|to_css_class }}\" scope=\"col\" data-column=\"{{ column.name }}\" data-column-type=\"{{ column.type.lower() }}\" data-column-not-null=\"{{ column.notnull }}\" data-is-pk=\"{% if column.is_pk %}1{% else %}0{% endif %}\"{% if column.is_special_link_column %} data-is-link-column=\"1\"{% endif %}>\n                        {% if not column.sortable %}\n                            {{ column.name }}\n                        {% else %}\n                            {% if column.name == sort %}\n                                <a href=\"{{ fix_path(path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None})) }}\" rel=\"nofollow\">{{ column.name }}&nbsp;▼</a>\n                            {% else %}\n                                <a href=\"{{ fix_path(path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None})) }}\" rel=\"nofollow\">{{ column.name }}{% if column.name == sort_desc %}&nbsp;▲{% endif %}</a>\n                            {% endif %}\n                        {% endif %}\n                    </th>\n                {% endfor %}\n            </tr>\n        </thead>\n        <tbody>\n        {% for row in display_rows %}\n            <tr>\n                {% for cell in row %}\n                    <td class=\"col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}\">{{ cell.value }}</td>\n                {% endfor %}\n            </tr>\n        {% endfor %}\n        </tbody>\n    </table>\n</div>\n{% else %}\n    <p class=\"zero-results\">0 records</p>\n{% endif %}\n"
  },
  {
    "path": "datasette/templates/allow_debug.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Debug allow rules{% endblock %}\n\n{% block extra_head %}\n<style>\ntextarea {\n    height: 10em;\n    width: 95%;\n    box-sizing: border-box;\n    padding: 0.5em;\n    border: 2px dotted black;\n}\n.two-col {\n    display: inline-block;\n    width: 48%;\n}\n.two-col label {\n    width: 48%;\n}\np.message-warning {\n    white-space: pre-wrap;\n}\n@media only screen and (max-width: 576px) {\n    .two-col {\n        width: 100%;\n    }\n}\n</style>\n{% endblock %}\n\n{% block content %}\n\n<h1>Debug allow rules</h1>\n\n{% set current_tab = \"allow_debug\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p>Use this tool to try out different actor and allow combinations. See <a href=\"https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks\">Defining permissions with \"allow\" blocks</a> for documentation.</p>\n\n<form class=\"core\" action=\"{{ urls.path('-/allow-debug') }}\" method=\"get\" style=\"margin-bottom: 1em\">\n    <div class=\"two-col\">\n        <p><label>Allow block</label></p>\n        <textarea name=\"allow\">{{ allow_input }}</textarea>\n    </div>\n    <div class=\"two-col\">\n        <p><label>Actor</label></p>\n        <textarea name=\"actor\">{{ actor_input }}</textarea>\n    </div>\n    <div style=\"margin-top: 1em;\">\n        <input type=\"submit\" value=\"Apply allow block to actor\">\n    </div>\n</form>\n\n{% if error %}<p class=\"message-warning\">{{ error }}</p>{% endif %}\n\n{% if result == \"True\" %}<p class=\"message-info\">Result: allow</p>{% endif %}\n\n{% if result == \"False\" %}<p class=\"message-error\">Result: deny</p>{% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/api_explorer.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}API Explorer{% endblock %}\n\n{% block extra_head %}\n<script src=\"{{ base_url }}-/static/json-format-highlight-1.0.1.js\"></script>\n{% endblock %}\n\n{% block content %}\n\n<h1>API Explorer{% if private %} 🔒{% endif %}</h1>\n\n<p>Use this tool to try out the\n  {% if datasette_version %}\n    <a href=\"https://docs.datasette.io/en/{{ datasette_version }}/json_api.html\">Datasette API</a>.\n  {% else %}\n    Datasette API.\n  {% endif %}\n</p>\n<details open style=\"border: 2px solid #ccc; border-bottom: none; padding: 0.5em\">\n  <summary style=\"cursor: pointer;\">GET</summary>\n  <form class=\"core\" method=\"get\" id=\"api-explorer-get\" style=\"margin-top: 0.7em\">\n    <div>\n      <label for=\"path\">API path:</label>\n      <input type=\"text\" id=\"path\" name=\"path\" style=\"width: 60%\">\n      <input type=\"submit\" value=\"GET\">\n    </div>\n  </form>\n</details>\n<details style=\"border: 2px solid #ccc; padding: 0.5em\">\n  <summary style=\"cursor: pointer\">POST</summary>\n  <form class=\"core\" method=\"post\" id=\"api-explorer-post\" style=\"margin-top: 0.7em\">\n    <div>\n      <label for=\"path\">API path:</label>\n      <input type=\"text\" id=\"path\" name=\"path\" style=\"width: 60%\">\n    </div>\n    <div style=\"margin: 0.5em 0\">\n      <label for=\"apiJson\" style=\"vertical-align: top\">JSON:</label>\n      <textarea id=\"apiJson\" name=\"json\" style=\"width: 60%; height: 200px; font-family: monospace; font-size: 0.8em;\"></textarea>\n    </div>\n    <p><button id=\"json-format\" type=\"button\">Format JSON</button> <input type=\"submit\" value=\"POST\"></p>\n  </form>\n</details>\n\n<div id=\"output\" style=\"display: none\">\n  <h2>API response: HTTP <span id=\"response-status\"></span></h2>\n  </h2>\n  <ul class=\"errors message-error\"></ul>\n  <pre></pre>\n</div>\n\n<script>\ndocument.querySelector('#json-format').addEventListener('click', (ev) => {\n  ev.preventDefault();\n  let json = document.querySelector('textarea[name=\"json\"]').value.trim();\n  if (!json) {\n    return;\n  }\n  try {\n    const parsed = JSON.parse(json);\n    document.querySelector('textarea[name=\"json\"]').value = JSON.stringify(parsed, null, 2);\n  } catch (e) {\n    alert(\"Error parsing JSON: \" + e);\n  }\n});\nvar postForm = document.getElementById('api-explorer-post');\nvar getForm = document.getElementById('api-explorer-get');\nvar output = document.getElementById('output');\nvar errorList = output.querySelector('.errors');\n\n// On first load or fragment change populate forms from # in URL, if present\nif (window.location.hash) {\n  onFragmentChange();\n}\nfunction onFragmentChange() {\n  var hash = window.location.hash.slice(1);\n  // Treat hash as a foo=bar string and parse it:\n  var params = new URLSearchParams(hash);\n  var method = params.get('method');\n  if (method == 'GET') {\n    getForm.closest('details').open = true;\n    postForm.closest('details').open = false;\n    getForm.querySelector('input[name=\"path\"]').value = params.get('path');\n  } else if (method == 'POST') {\n    postForm.closest('details').open = true;\n    getForm.closest('details').open = false;\n    postForm.querySelector('input[name=\"path\"]').value = params.get('path');\n    postForm.querySelector('textarea[name=\"json\"]').value = params.get('json');\n  }\n}\nwindow.addEventListener('hashchange', () => {\n  onFragmentChange();\n  // Animate scroll to top of page\n  window.scrollTo({top: 0, behavior: 'smooth'});\n});\n\n// Cause GET and POST regions to toggle each other\nvar getDetails = getForm.closest('details');\nvar postDetails = postForm.closest('details');\ngetDetails.addEventListener('toggle', (ev) => {\n  if (getDetails.open) {\n    postDetails.open = false;\n  }\n});\npostDetails.addEventListener('toggle', (ev) => {\n  if (postDetails.open) {\n    getDetails.open = false;\n  }\n});\n\ngetForm.addEventListener(\"submit\", (ev) => {\n  ev.preventDefault();\n  var formData = new FormData(getForm);\n  // Update URL fragment hash\n  var serialized = new URLSearchParams(formData).toString() + '&method=GET';\n  window.history.pushState({}, \"\", location.pathname + '#' + serialized);\n  // Send the request\n  var path = formData.get('path');\n  fetch(path, {\n    method: 'GET',\n    headers: {\n      'Accept': 'application/json',\n    }\n  }).then((response) => {\n    output.style.display = 'block';\n    document.getElementById('response-status').textContent = response.status;\n    return response.json();\n  }).then((data) => {\n    output.querySelector('pre').innerHTML = jsonFormatHighlight(data);\n    errorList.style.display = 'none';\n  }).catch((error) => {\n    alert(error);\n  });\n});\n\npostForm.addEventListener(\"submit\", (ev) => {\n  ev.preventDefault();\n  var formData = new FormData(postForm);\n  // Update URL fragment hash\n  var serialized = new URLSearchParams(formData).toString() + '&method=POST';\n  window.history.pushState({}, \"\", location.pathname + '#' + serialized);\n  // Send the request\n  var json = formData.get('json');\n  var path = formData.get('path');\n  // Validate JSON\n  if (!json.length) {\n    json = '{}';\n  }\n  try {\n    var data = JSON.parse(json);\n  } catch (err) {\n    alert(\"Invalid JSON: \" + err);\n    return;\n  }\n  // POST JSON to path with content-type application/json\n  fetch(path, {\n    method: 'POST',\n    body: json,\n    headers: {\n      'Content-Type': 'application/json',\n    }\n  }).then(r => {\n    document.getElementById('response-status').textContent = r.status;\n    return r.json();\n  }).then(data => {\n    if (data.errors) {\n      errorList.style.display = 'block';\n      errorList.innerHTML = '';\n      data.errors.forEach(error => {\n        var li = document.createElement('li');\n        li.textContent = error;\n        errorList.appendChild(li);\n      });\n    } else {\n      errorList.style.display = 'none';\n    }\n    output.querySelector('pre').innerHTML = jsonFormatHighlight(data);\n    output.style.display = 'block';\n  }).catch(err => {\n    alert(\"Error: \" + err);\n  });\n});\n</script>\n\n{% if example_links %}\n<h2>API endpoints</h2>\n<ul class=\"bullets\">\n  {% for database in example_links %}\n    <li>Database: <strong>{{ database.name }}</strong></li>\n    <ul class=\"bullets\">\n      {% for link in database.links %}\n        <li><a href=\"{{ api_path(link) }}\">{{ link.path }}</a> - {{ link.label }} </li>\n      {% endfor %}\n      {% for table in database.tables %}\n        <li><strong>{{ table.name }}</strong>\n          <ul class=\"bullets\">\n            {% for link in table.links %}\n              <li><a href=\"{{ api_path(link) }}\">{{ link.path }}</a> - {{ link.label }} </li>\n            {% endfor %}\n          </ul>\n        </li>\n      {% endfor %}\n    </ul>\n  {% endfor %}\n</ul>\n{% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/base.html",
    "content": "{% import \"_crumbs.html\" as crumbs with context %}<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <title>{% block title %}{% endblock %}</title>\n    <link rel=\"stylesheet\" href=\"{{ urls.static('app.css') }}?{{ app_css_hash }}\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n{% for url in extra_css_urls %}\n    <link rel=\"stylesheet\" href=\"{{ url.url }}\"{% if url.get(\"sri\") %} integrity=\"{{ url.sri }}\" crossorigin=\"anonymous\"{% endif %}>\n{% endfor %}\n<script>window.datasetteVersion = '{{ datasette_version }}';</script>\n<script src=\"{{ urls.static('datasette-manager.js') }}\" defer></script>\n{% for url in extra_js_urls %}\n    <script {% if url.module %}type=\"module\" {% endif %}src=\"{{ url.url }}\"{% if url.get(\"sri\") %} integrity=\"{{ url.sri }}\" crossorigin=\"anonymous\"{% endif %}></script>\n{% endfor %}\n{%- if alternate_url_json -%}\n    <link rel=\"alternate\" type=\"application/json+datasette\" href=\"{{ alternate_url_json }}\">\n{%- endif -%}\n{%- block extra_head %}{% endblock -%}\n</head>\n<body class=\"{% block body_class %}{% endblock %}\">\n<div class=\"not-footer\">\n<header class=\"hd\"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}\n    {% set links = menu_links() %}{% if links or show_logout %}\n    <details class=\"nav-menu details-menu\">\n        <summary><svg aria-labelledby=\"nav-menu-svg-title\" role=\"img\"\n            fill=\"currentColor\" stroke=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">\n                <title id=\"nav-menu-svg-title\">Menu</title>\n                <path fill-rule=\"evenodd\" d=\"M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z\"></path>\n        </svg></summary>\n        <div class=\"nav-menu-inner\">\n            {% if links %}\n            <ul>\n                {% for link in links %}\n                <li><a href=\"{{ link.href }}\">{{ link.label }}</a></li>\n                {% endfor %}\n            </ul>\n            {% endif %}\n            {% if show_logout %}\n            <form class=\"nav-menu-logout\" action=\"{{ urls.logout() }}\" method=\"post\">\n                <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n                <button class=\"button-as-link\">Log out</button>\n            </form>{% endif %}\n        </div>\n    </details>{% endif %}\n    {% if actor %}\n    <div class=\"actor\">\n        <strong>{{ display_actor(actor) }}</strong>\n    </div>\n    {% endif %}\n{% endblock %}</nav></header>\n\n{% block messages %}\n{% if show_messages %}\n    {% for message, message_type in show_messages() %}\n        <p class=\"message-{% if message_type == 1 %}info{% elif message_type == 2 %}warning{% elif message_type == 3 %}error{% endif %}\">{{ message }}</p>\n    {% endfor %}\n{% endif %}\n{% endblock %}\n\n<section class=\"content\">\n{% block content %}\n{% endblock %}\n</section>\n</div>\n<footer class=\"ft\">{% block footer %}{% include \"_footer.html\" %}{% endblock %}</footer>\n\n{% include \"_close_open_menus.html\" %}\n\n{% for body_script in body_scripts %}\n    <script{% if body_script.module %} type=\"module\"{% endif %}>{{ body_script.script }}</script>\n{% endfor %}\n\n{% if select_templates %}<!-- Templates considered: {{ select_templates|join(\", \") }} -->{% endif %}\n<script src=\"{{ urls.static('navigation-search.js') }}\" defer></script>\n<navigation-search url=\"/-/tables\"></navigation-search>\n</body>\n</html>\n"
  },
  {
    "path": "datasette/templates/create_token.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Create an API token{% endblock %}\n\n{% block extra_head %}\n<style type=\"text/css\">\n#restrict-permissions label {\n  display: inline;\n  width: 90%;\n}\n</style>\n{% endblock %}\n\n{% block content %}\n\n<h1>Create an API token</h1>\n\n<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>\n\n{% if token %}\n  <div>\n    <h2>Your API token</h2>\n    <form>\n      <input type=\"text\" class=\"copyable\" style=\"width: 40%\" value=\"{{ token }}\">\n      <span class=\"copy-link-wrapper\"></span>\n    </form>\n    <!--- show token in a <details> -->\n    <details style=\"margin-top: 1em\">\n      <summary>Token details</summary>\n      <pre>{{ token_bits|tojson(4) }}</pre>\n    </details>\n  </div>\n  <h2>Create another token</h2>\n{% endif %}\n\n{% if errors %}\n  {% for error in errors %}\n    <p class=\"message-error\">{{ error }}</p>\n  {% endfor %}\n{% endif %}\n\n<form class=\"core\" action=\"{{ urls.path('-/create-token') }}\" method=\"post\">\n  <div>\n    <div class=\"select-wrapper\" style=\"width: unset\">\n      <select name=\"expire_type\">\n        <option value=\"\">Token never expires</option>\n        <option value=\"minutes\">Expires after X minutes</option>\n        <option value=\"hours\">Expires after X hours</option>\n        <option value=\"days\">Expires after X days</option>\n      </select>\n    </div>\n    <input type=\"text\" name=\"expire_duration\" style=\"width: 10%\">\n    <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n    <input type=\"submit\" value=\"Create token\">\n\n  <details style=\"margin-top: 1em\" id=\"restrict-permissions\">\n    <summary style=\"cursor: pointer;\">Restrict actions that can be performed using this token</summary>\n    <h2>All databases and tables</h2>\n    <ul>\n      {% for permission in all_actions %}\n        <li><label><input type=\"checkbox\" name=\"all:{{ permission }}\"> {{ permission }}</label></li>\n      {% endfor %}\n    </ul>\n\n    {% for database in database_with_tables %}\n      <h2>All tables in \"{{ database.name }}\"</h2>\n      <ul>\n        {% for permission in database_actions %}\n          <li><label><input type=\"checkbox\" name=\"database:{{ database.encoded }}:{{ permission }}\"> {{ permission }}</label></li>\n        {% endfor %}\n      </ul>\n    {% endfor %}\n    <h2>Specific tables</h2>\n    {% for database in database_with_tables %}\n      {% for table in database.tables %}\n        <h3>{{ database.name }}: {{ table.name }}</h3>\n        <ul>\n          {% for permission in child_actions %}\n            <li><label><input type=\"checkbox\" name=\"resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}\"> {{ permission }}</label></li>\n          {% endfor %}\n        </ul>\n      {% endfor %}\n    {% endfor %}\n  </details>\n\n</form>\n</div>\n\n<script>\nvar expireDuration = document.querySelector('input[name=\"expire_duration\"]');\nexpireDuration.style.display = 'none';\nvar expireType = document.querySelector('select[name=\"expire_type\"]');\nfunction showHideExpireDuration() {\n  if (expireType.value) {\n    expireDuration.style.display = 'inline';\n    expireDuration.setAttribute(\"placeholder\", expireType.value.replace(\"Expires after X \", \"\"));\n  } else {\n    expireDuration.style.display = 'none';\n  }\n}\nshowHideExpireDuration();\nexpireType.addEventListener('change', showHideExpireDuration);\nvar copyInput = document.querySelector(\".copyable\");\nif (copyInput) {\n  var wrapper = document.querySelector(\".copy-link-wrapper\");\n  var button = document.createElement(\"button\");\n  button.className = \"copyable-copy-button\";\n  button.setAttribute(\"type\", \"button\");\n  button.innerHTML = \"Copy to clipboard\";\n  button.onclick = (ev) => {\n    ev.preventDefault();\n    copyInput.select();\n    document.execCommand(\"copy\");\n    button.innerHTML = \"Copied!\";\n    setTimeout(() => {\n        button.innerHTML = \"Copy to clipboard\";\n    }, 1500);\n  };\n  wrapper.appendChild(button);\n  wrapper.insertAdjacentElement(\"afterbegin\", button);\n}\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/csrf_error.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}CSRF check failed){% endblock %}\n{% block content %}\n<h1>Form origin check failed</h1>\n\n<p>Your request's origin could not be validated. Please return to the form and submit it again.</p>\n\n<details><summary>Technical details</summary>\n  <p>Developers: consult Datasette's <a href=\"https://docs.datasette.io/en/latest/internals.html#csrf-protection\">CSRF protection documentation</a>.</p>\n  <p>Error code is {{ message_name }}.</p>\n</details>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/database.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ database }}{% endblock %}\n\n{% block extra_head %}\n{{- super() -}}\n{% include \"_codemirror.html\" %}\n{% endblock %}\n\n{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}\n\n{% block crumbs %}\n{{ crumbs.nav(request=request, database=database) }}\n{% endblock %}\n\n{% block content %}\n<div class=\"page-header\" style=\"border-color: #{{ database_color }}\">\n    <h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>\n</div>\n{% set action_links, action_title = database_actions(), \"Database actions\" %}\n{% include \"_action_menu.html\" %}\n\n{{ top_database() }}\n\n{% block description_source_license %}{% include \"_description_source_license.html\" %}{% endblock %}\n\n{% if allow_execute_sql %}\n    <form class=\"sql core\" action=\"{{ urls.database(database) }}/-/query\" method=\"get\">\n        <h3>Custom SQL query</h3>\n        <p><textarea id=\"sql-editor\" name=\"sql\">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>\n        <p>\n            <button id=\"sql-format\" type=\"button\" hidden>Format SQL</button>\n            <input type=\"submit\" value=\"Run SQL\">\n        </p>\n    </form>\n{% endif %}\n\n{% if attached_databases %}\n    <div class=\"message-info\">\n        <p>The following databases are attached to this connection, and can be used for cross-database joins:</p>\n        <ul class=\"bullets\">\n            {% for db_name in attached_databases %}\n                <li><strong>{{ db_name }}</strong> - <a href=\"{{ urls.database(db_name) }}/-/query?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'\">tables</a></li>\n            {% endfor %}\n        </ul>\n    </div>\n{% endif %}\n\n{% if queries %}\n    <h2 id=\"queries\">Queries</h2>\n    <ul class=\"bullets\">\n        {% for query in queries %}\n            <li><a href=\"{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}\" title=\"{{ query.description or query.sql }}\">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>\n        {% endfor %}\n    </ul>\n{% endif %}\n\n{% if tables %}\n<h2 id=\"tables\">Tables <a style=\"font-weight: normal; font-size: 0.75em; padding-left: 0.5em;\" href=\"{{ urls.database(database) }}/-/schema\">schema</a></h2>\n{% endif %}\n\n{% for table in tables %}\n{% if show_hidden or not table.hidden %}\n<div class=\"db-table\">\n    <h3><a href=\"{{ urls.table(database, table.name) }}\">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>\n    <p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>\n    <p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}&gt;{{ \"{:,}\".format(count_limit) }} rows{% else %}{{ \"{:,}\".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>\n</div>\n{% endif %}\n{% endfor %}\n\n{% if hidden_count and not show_hidden %}\n    <p>... and <a href=\"{{ urls.database(database) }}?_show_hidden=1\">{{ \"{:,}\".format(hidden_count) }} hidden table{% if hidden_count == 1 %}{% else %}s{% endif %}</a></p>\n{% endif %}\n\n{% if views %}\n    <h2 id=\"views\">Views</h2>\n    <ul class=\"bullets\">\n        {% for view in views %}\n            <li><a href=\"{{ urls.database(database) }}/{{ view.name|urlencode }}\">{{ view.name }}</a>{% if view.private %} 🔒{% endif %}</li>\n        {% endfor %}\n    </ul>\n{% endif %}\n\n{% if allow_download %}\n    <p class=\"download-sqlite\">Download SQLite DB: <a href=\"{{ urls.database(database) }}.db\" rel=\"nofollow\">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>\n{% endif %}\n\n{% include \"_codemirror_foot.html\" %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/debug_actions.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Registered Actions{% endblock %}\n\n{% block content %}\n<h1>Registered actions</h1>\n\n{% set current_tab = \"actions\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p style=\"margin-bottom: 2em;\">\n  This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and \"s\" or \"\" }}.\n  Actions are used by the permission system to control access to different features.\n</p>\n\n<table class=\"rows-and-columns\">\n  <thead>\n    <tr>\n      <th>Name</th>\n      <th>Abbr</th>\n      <th>Description</th>\n      <th>Resource</th>\n      <th>Takes Parent</th>\n      <th>Takes Child</th>\n      <th>Also Requires</th>\n    </tr>\n  </thead>\n  <tbody>\n    {% for action in data %}\n    <tr>\n      <td><strong>{{ action.name }}</strong></td>\n      <td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>\n      <td>{{ action.description or \"\" }}</td>\n      <td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td>\n      <td>{% if action.takes_parent %}✓{% endif %}</td>\n      <td>{% if action.takes_child %}✓{% endif %}</td>\n      <td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>\n    </tr>\n    {% endfor %}\n  </tbody>\n</table>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/debug_allowed.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Allowed Resources{% endblock %}\n\n{% block extra_head %}\n<script src=\"{{ base_url }}-/static/json-format-highlight-1.0.1.js\"></script>\n{% include \"_permission_ui_styles.html\" %}\n{% include \"_debug_common_functions.html\" %}\n{% endblock %}\n\n{% block content %}\n<h1>Allowed resources</h1>\n\n{% set current_tab = \"allowed\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>\n\n{% if request.actor %}\n<p>Current actor: <strong>{{ request.actor.get(\"id\", \"anonymous\") }}</strong></p>\n{% else %}\n<p>Current actor: <strong>anonymous (not logged in)</strong></p>\n{% endif %}\n\n<div class=\"permission-form\">\n    <form id=\"allowed-form\" method=\"get\" action=\"{{ urls.path(\"-/allowed\") }}\">\n        <div class=\"form-section\">\n            <label for=\"action\">Action (permission name):</label>\n            <select id=\"action\" name=\"action\" required>\n                <option value=\"\">Select an action...</option>\n                {% for action_name in supported_actions %}\n                <option value=\"{{ action_name }}\">{{ action_name }}</option>\n                {% endfor %}\n            </select>\n            <small>Only certain actions are supported by this endpoint</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"parent\">Filter by parent (optional):</label>\n            <input type=\"text\" id=\"parent\" name=\"parent\" placeholder=\"e.g., database name\">\n            <small>Filter results to a specific parent resource</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"child\">Filter by child (optional):</label>\n            <input type=\"text\" id=\"child\" name=\"child\" placeholder=\"e.g., table name\">\n            <small>Filter results to a specific child resource (requires parent to be set)</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"page_size\">Page size:</label>\n            <input type=\"number\" id=\"page_size\" name=\"page_size\" value=\"50\" min=\"1\" max=\"200\" style=\"max-width: 100px;\">\n            <small>Number of results per page (max 200)</small>\n        </div>\n\n        <div class=\"form-actions\">\n            <button type=\"submit\" class=\"submit-btn\" id=\"submit-btn\">Check Allowed Resources</button>\n        </div>\n    </form>\n</div>\n\n<div id=\"results-container\" style=\"display: none;\">\n    <div class=\"results-header\">\n        <h2>Results</h2>\n        <div class=\"results-count\" id=\"results-count\"></div>\n    </div>\n\n    <div id=\"results-content\"></div>\n\n    <div id=\"pagination\" class=\"pagination\"></div>\n\n    <details style=\"margin-top: 2em;\">\n        <summary style=\"cursor: pointer; font-weight: bold;\">Raw JSON response</summary>\n        <pre id=\"raw-json\" style=\"margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;\"></pre>\n    </details>\n</div>\n\n<script>\nconst form = document.getElementById('allowed-form');\nconst resultsContainer = document.getElementById('results-container');\nconst resultsContent = document.getElementById('results-content');\nconst resultsCount = document.getElementById('results-count');\nconst pagination = document.getElementById('pagination');\nconst submitBtn = document.getElementById('submit-btn');\nconst hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};\n\n// Populate form on initial load\n(function() {\n    const params = populateFormFromURL();\n    const action = params.get('action');\n    const page = params.get('page');\n    if (action) {\n        fetchResults(page ? parseInt(page) : 1);\n    }\n})();\n\nasync function fetchResults(page = 1) {\n    submitBtn.disabled = true;\n    submitBtn.textContent = 'Loading...';\n\n    const formData = new FormData(form);\n    const params = new URLSearchParams();\n\n    for (const [key, value] of formData.entries()) {\n        if (value && key !== 'page_size') {\n            params.append(key, value);\n        }\n    }\n\n    const pageSize = document.getElementById('page_size').value || '50';\n    params.append('page', page.toString());\n    params.append('page_size', pageSize);\n\n    try {\n        const response = await fetch('{{ urls.path(\"-/allowed.json\") }}?' + params.toString(), {\n            method: 'GET',\n            headers: {\n                'Accept': 'application/json',\n            }\n        });\n\n        const data = await response.json();\n\n        if (response.ok) {\n            displayResults(data);\n        } else {\n            displayError(data);\n        }\n    } catch (error) {\n        displayError({ error: error.message });\n    } finally {\n        submitBtn.disabled = false;\n        submitBtn.textContent = 'Check Allowed Resources';\n    }\n}\n\nfunction displayResults(data) {\n    resultsContainer.style.display = 'block';\n\n    // Update count\n    resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;\n\n    // Display results table\n    if (data.items.length === 0) {\n        resultsContent.innerHTML = '<div class=\"no-results\">No allowed resources found for this action.</div>';\n    } else {\n        let html = '<table class=\"results-table\">';\n        html += '<thead><tr>';\n        html += '<th>Resource Path</th>';\n        html += '<th>Parent</th>';\n        html += '<th>Child</th>';\n        if (hasDebugPermission) {\n            html += '<th>Reason</th>';\n        }\n        html += '</tr></thead>';\n        html += '<tbody>';\n\n        for (const item of data.items) {\n            html += '<tr>';\n            html += `<td><span class=\"resource-path\">${escapeHtml(item.resource || '/')}</span></td>`;\n            html += `<td>${escapeHtml(item.parent || '—')}</td>`;\n            html += `<td>${escapeHtml(item.child || '—')}</td>`;\n            if (hasDebugPermission) {\n                // Display reason as JSON array\n                let reasonHtml = '—';\n                if (item.reason && Array.isArray(item.reason)) {\n                    reasonHtml = `<code>${escapeHtml(JSON.stringify(item.reason))}</code>`;\n                }\n                html += `<td>${reasonHtml}</td>`;\n            }\n            html += '</tr>';\n        }\n\n        html += '</tbody></table>';\n        resultsContent.innerHTML = html;\n    }\n\n    // Update pagination\n    pagination.innerHTML = '';\n    if (data.previous_url || data.next_url) {\n        if (data.previous_url) {\n            const prevLink = document.createElement('a');\n            prevLink.href = data.previous_url;\n            prevLink.textContent = '← Previous';\n            pagination.appendChild(prevLink);\n        }\n\n        const pageInfo = document.createElement('span');\n        pageInfo.textContent = `Page ${data.page}`;\n        pagination.appendChild(pageInfo);\n\n        if (data.next_url) {\n            const nextLink = document.createElement('a');\n            nextLink.href = data.next_url;\n            nextLink.textContent = 'Next →';\n            pagination.appendChild(nextLink);\n        }\n    }\n\n    // Update raw JSON\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n}\n\nfunction displayError(data) {\n    resultsContainer.style.display = 'block';\n    resultsCount.textContent = '';\n    pagination.innerHTML = '';\n\n    resultsContent.innerHTML = `<div class=\"error-message\">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;\n\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n}\n\n// Disable child input if parent is empty\nconst parentInput = document.getElementById('parent');\nconst childInput = document.getElementById('child');\n\nparentInput.addEventListener('input', () => {\n    childInput.disabled = !parentInput.value;\n    if (!parentInput.value) {\n        childInput.value = '';\n    }\n});\n\n// Initialize disabled state\nchildInput.disabled = !parentInput.value;\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/debug_check.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Permission Check{% endblock %}\n\n{% block extra_head %}\n<script src=\"{{ base_url }}-/static/json-format-highlight-1.0.1.js\"></script>\n{% include \"_permission_ui_styles.html\" %}\n{% include \"_debug_common_functions.html\" %}\n<style>\n#output {\n    margin-top: 2em;\n    padding: 1em;\n    border-radius: 5px;\n}\n#output.allowed {\n    background-color: #e8f5e9;\n    border: 2px solid #4caf50;\n}\n#output.denied {\n    background-color: #ffebee;\n    border: 2px solid #f44336;\n}\n#output h2 {\n    margin-top: 0;\n}\n#output .result-badge {\n    display: inline-block;\n    padding: 0.3em 0.8em;\n    border-radius: 3px;\n    font-weight: bold;\n    font-size: 1.1em;\n}\n#output .allowed-badge {\n    background-color: #4caf50;\n    color: white;\n}\n#output .denied-badge {\n    background-color: #f44336;\n    color: white;\n}\n.details-section {\n    margin-top: 1em;\n}\n.details-section dt {\n    font-weight: bold;\n    margin-top: 0.5em;\n}\n.details-section dd {\n    margin-left: 1em;\n}\n</style>\n{% endblock %}\n\n{% block content %}\n<h1>Permission check</h1>\n\n{% set current_tab = \"check\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>\n\n{% if request.actor %}\n<p>Current actor: <strong>{{ request.actor.get(\"id\", \"anonymous\") }}</strong></p>\n{% else %}\n<p>Current actor: <strong>anonymous (not logged in)</strong></p>\n{% endif %}\n\n<div class=\"permission-form\">\n    <form id=\"check-form\" method=\"get\" action=\"{{ urls.path(\"-/check\") }}\">\n        <div class=\"form-section\">\n            <label for=\"action\">Action (permission name):</label>\n            <select id=\"action\" name=\"action\" required>\n                <option value=\"\">Select an action...</option>\n                {% for action_name in sorted_actions %}\n                <option value=\"{{ action_name }}\">{{ action_name }}</option>\n                {% endfor %}\n            </select>\n            <small>The permission action to check</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"parent\">Parent resource (optional):</label>\n            <input type=\"text\" id=\"parent\" name=\"parent\" placeholder=\"e.g., database name\">\n            <small>For database-level permissions, specify the database name</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"child\">Child resource (optional):</label>\n            <input type=\"text\" id=\"child\" name=\"child\" placeholder=\"e.g., table name\">\n            <small>For table-level permissions, specify the table name (requires parent)</small>\n        </div>\n\n        <div class=\"form-actions\">\n            <button type=\"submit\" class=\"submit-btn\" id=\"submit-btn\">Check Permission</button>\n        </div>\n    </form>\n</div>\n\n<div id=\"output\" style=\"display: none;\">\n    <h2>Result: <span class=\"result-badge\" id=\"result-badge\"></span></h2>\n\n    <dl class=\"details-section\">\n        <dt>Action:</dt>\n        <dd id=\"result-action\"></dd>\n\n        <dt>Resource Path:</dt>\n        <dd id=\"result-resource\"></dd>\n\n        <dt>Actor ID:</dt>\n        <dd id=\"result-actor\"></dd>\n\n        <div id=\"additional-details\"></div>\n    </dl>\n\n    <details style=\"margin-top: 1em;\">\n        <summary style=\"cursor: pointer; font-weight: bold;\">Raw JSON response</summary>\n        <pre id=\"raw-json\" style=\"margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;\"></pre>\n    </details>\n</div>\n\n<script>\nconst form = document.getElementById('check-form');\nconst output = document.getElementById('output');\nconst submitBtn = document.getElementById('submit-btn');\n\nasync function performCheck() {\n    submitBtn.disabled = true;\n    submitBtn.textContent = 'Checking...';\n\n    const formData = new FormData(form);\n    const params = new URLSearchParams();\n\n    for (const [key, value] of formData.entries()) {\n        if (value) {\n            params.append(key, value);\n        }\n    }\n\n    try {\n        const response = await fetch('{{ urls.path(\"-/check.json\") }}?' + params.toString(), {\n            method: 'GET',\n            headers: {\n                'Accept': 'application/json',\n            }\n        });\n\n        const data = await response.json();\n\n        if (response.ok) {\n            displayResult(data);\n        } else {\n            displayError(data);\n        }\n    } catch (error) {\n        alert('Error: ' + error.message);\n    } finally {\n        submitBtn.disabled = false;\n        submitBtn.textContent = 'Check Permission';\n    }\n}\n\n// Populate form on initial load\n(function() {\n    const params = populateFormFromURL();\n    const action = params.get('action');\n    if (action) {\n        performCheck();\n    }\n})();\n\nfunction displayResult(data) {\n    output.style.display = 'block';\n\n    // Set badge and styling\n    const resultBadge = document.getElementById('result-badge');\n    if (data.allowed) {\n        output.className = 'allowed';\n        resultBadge.className = 'result-badge allowed-badge';\n        resultBadge.textContent = 'ALLOWED ✓';\n    } else {\n        output.className = 'denied';\n        resultBadge.className = 'result-badge denied-badge';\n        resultBadge.textContent = 'DENIED ✗';\n    }\n\n    // Basic details\n    document.getElementById('result-action').textContent = data.action || 'N/A';\n    document.getElementById('result-resource').textContent = data.resource?.path || '/';\n    document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';\n\n    // Additional details\n    const additionalDetails = document.getElementById('additional-details');\n    additionalDetails.innerHTML = '';\n\n    if (data.reason !== undefined) {\n        const dt = document.createElement('dt');\n        dt.textContent = 'Reason:';\n        const dd = document.createElement('dd');\n        dd.textContent = data.reason || 'N/A';\n        additionalDetails.appendChild(dt);\n        additionalDetails.appendChild(dd);\n    }\n\n    if (data.source_plugin !== undefined) {\n        const dt = document.createElement('dt');\n        dt.textContent = 'Source Plugin:';\n        const dd = document.createElement('dd');\n        dd.textContent = data.source_plugin || 'N/A';\n        additionalDetails.appendChild(dt);\n        additionalDetails.appendChild(dd);\n    }\n\n    if (data.used_default !== undefined) {\n        const dt = document.createElement('dt');\n        dt.textContent = 'Used Default:';\n        const dd = document.createElement('dd');\n        dd.textContent = data.used_default ? 'Yes' : 'No';\n        additionalDetails.appendChild(dt);\n        additionalDetails.appendChild(dd);\n    }\n\n    if (data.depth !== undefined) {\n        const dt = document.createElement('dt');\n        dt.textContent = 'Depth:';\n        const dd = document.createElement('dd');\n        dd.textContent = data.depth;\n        additionalDetails.appendChild(dt);\n        additionalDetails.appendChild(dd);\n    }\n\n    // Raw JSON\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n\n    // Scroll to output\n    output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n}\n\nfunction displayError(data) {\n    output.style.display = 'block';\n    output.className = 'denied';\n\n    const resultBadge = document.getElementById('result-badge');\n    resultBadge.className = 'result-badge denied-badge';\n    resultBadge.textContent = 'ERROR';\n\n    document.getElementById('result-action').textContent = 'N/A';\n    document.getElementById('result-resource').textContent = 'N/A';\n    document.getElementById('result-actor').textContent = 'N/A';\n\n    const additionalDetails = document.getElementById('additional-details');\n    additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';\n\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n\n    output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n}\n\n// Disable child input if parent is empty\nconst parentInput = document.getElementById('parent');\nconst childInput = document.getElementById('child');\n\nchildInput.addEventListener('focus', () => {\n    if (!parentInput.value) {\n        alert('Please specify a parent resource first before adding a child resource.');\n        parentInput.focus();\n    }\n});\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/debug_permissions_playground.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Debug permissions{% endblock %}\n\n{% block extra_head %}\n{% include \"_permission_ui_styles.html\" %}\n<style type=\"text/css\">\n.check-result-true {\n    color: green;\n}\n.check-result-false {\n    color: red;\n}\n.check-result-no-opinion {\n    color: #aaa;\n}\n.check h2 {\n    font-size: 1em\n}\n.check-action, .check-when, .check-result {\n    font-size: 1.3em;\n}\ntextarea {\n    height: 10em;\n    width: 95%;\n    box-sizing: border-box;\n    padding: 0.5em;\n    border: 2px dotted black;\n}\n.two-col {\n    display: inline-block;\n    width: 48%;\n}\n.two-col label {\n    width: 48%;\n}\n@media only screen and (max-width: 576px) {\n    .two-col {\n        width: 100%;\n    }\n}\n</style>\n{% endblock %}\n\n{% block content %}\n<h1>Permission playground</h1>\n\n{% set current_tab = \"permissions\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p>This tool lets you simulate an actor and a permission check for that actor.</p>\n\n<div class=\"permission-form\">\n    <form action=\"{{ urls.path('-/permissions') }}\" id=\"debug-post\" method=\"post\">\n        <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n        <div class=\"two-col\">\n            <div class=\"form-section\">\n                <label>Actor</label>\n                <textarea name=\"actor\">{% if actor_input %}{{ actor_input }}{% else %}{\"id\": \"root\"}{% endif %}</textarea>\n            </div>\n        </div>\n        <div class=\"two-col\" style=\"vertical-align: top\">\n            <div class=\"form-section\">\n                <label for=\"permission\">Action</label>\n                <select name=\"permission\" id=\"permission\">\n                    {% for permission in permissions %}\n                        <option value=\"{{ permission.name }}\">{{ permission.name }}</option>\n                    {% endfor %}\n                </select>\n            </div>\n            <div class=\"form-section\">\n                <label for=\"resource_1\">Parent</label>\n                <input type=\"text\" id=\"resource_1\" name=\"resource_1\" placeholder=\"e.g., database name\">\n            </div>\n            <div class=\"form-section\">\n                <label for=\"resource_2\">Child</label>\n                <input type=\"text\" id=\"resource_2\" name=\"resource_2\" placeholder=\"e.g., table name\">\n            </div>\n        </div>\n        <div class=\"form-actions\">\n            <button type=\"submit\" class=\"submit-btn\">Simulate permission check</button>\n        </div>\n        <pre style=\"margin-top: 1em\" id=\"debugResult\"></pre>\n    </form>\n</div>\n\n<script>\nvar rawPerms = {{ permissions|tojson }};\nvar permissions = Object.fromEntries(rawPerms.map(p => [p.name, p]));\nvar permissionSelect = document.getElementById('permission');\nvar resource1 = document.getElementById('resource_1');\nvar resource2 = document.getElementById('resource_2');\nvar resource1Section = resource1.closest('.form-section');\nvar resource2Section = resource2.closest('.form-section');\nfunction updateResourceVisibility() {\n    var permission = permissionSelect.value;\n    var {takes_parent, takes_child} = permissions[permission];\n    resource1Section.style.display = takes_parent ? 'block' : 'none';\n    resource2Section.style.display = takes_child ? 'block' : 'none';\n}\npermissionSelect.addEventListener('change', updateResourceVisibility);\nupdateResourceVisibility();\n\n// When #debug-post form is submitted, use fetch() to POST data\nvar debugPost = document.getElementById('debug-post');\nvar debugResult = document.getElementById('debugResult');\ndebugPost.addEventListener('submit', function(ev) {\n    ev.preventDefault();\n    var formData = new FormData(debugPost);\n    fetch(debugPost.action, {\n        method: 'POST',\n        body: new URLSearchParams(formData),\n        headers: {\n            'Accept': 'application/json'\n        }\n    }).then(function(response) {\n        if (!response.ok) {\n            throw new Error('Request failed with status ' + response.status);\n        }\n        return response.json();\n    }).then(function(data) {\n        debugResult.innerText = JSON.stringify(data, null, 4);\n    }).catch(function(error) {\n        debugResult.innerText = JSON.stringify({ error: error.message }, null, 4);\n    });\n});\n</script>\n\n<h1>Recent permissions checks</h1>\n\n<p>\n    {% if filter != \"all\" %}<a href=\"?filter=all\">All</a>{% else %}<strong>All</strong>{% endif %},\n    {% if filter != \"exclude-yours\" %}<a href=\"?filter=exclude-yours\">Exclude yours</a>{% else %}<strong>Exclude yours</strong>{% endif %},\n    {% if filter != \"only-yours\" %}<a href=\"?filter=only-yours\">Only yours</a>{% else %}<strong>Only yours</strong>{% endif %}\n</p>\n\n{% if permission_checks %}\n<table class=\"rows-and-columns permission-checks-table\" id=\"permission-checks-table\">\n    <thead>\n        <tr>\n            <th>When</th>\n            <th>Action</th>\n            <th>Parent</th>\n            <th>Child</th>\n            <th>Actor</th>\n            <th>Result</th>\n        </tr>\n    </thead>\n    <tbody>\n        {% for check in permission_checks %}\n            <tr>\n                <td><span style=\"font-size: 0.8em\">{{ check.when.split('T', 1)[0] }}</span><br>{{ check.when.split('T', 1)[1].split('+', 1)[0].split('-', 1)[0].split('Z', 1)[0] }}</td>\n                <td><code>{{ check.action }}</code></td>\n                <td>{{ check.parent or '—' }}</td>\n                <td>{{ check.child or '—' }}</td>\n                <td>{% if check.actor %}<code>{{ check.actor|tojson }}</code>{% else %}<span class=\"check-actor-anon\">anonymous</span>{% endif %}</td>\n                <td>{% if check.result %}<span class=\"check-result check-result-true\">Allowed</span>{% elif check.result is none %}<span class=\"check-result check-result-no-opinion\">No opinion</span>{% else %}<span class=\"check-result check-result-false\">Denied</span>{% endif %}</td>\n            </tr>\n        {% endfor %}\n        </tbody>\n</table>\n{% else %}\n<p class=\"no-results\">No permission checks have been recorded yet.</p>\n{% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/debug_rules.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Permission Rules{% endblock %}\n\n{% block extra_head %}\n<script src=\"{{ base_url }}-/static/json-format-highlight-1.0.1.js\"></script>\n{% include \"_permission_ui_styles.html\" %}\n{% include \"_debug_common_functions.html\" %}\n{% endblock %}\n\n{% block content %}\n<h1>Permission rules</h1>\n\n{% set current_tab = \"rules\" %}\n{% include \"_permissions_debug_tabs.html\" %}\n\n<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>\n\n{% if request.actor %}\n<p>Current actor: <strong>{{ request.actor.get(\"id\", \"anonymous\") }}</strong></p>\n{% else %}\n<p>Current actor: <strong>anonymous (not logged in)</strong></p>\n{% endif %}\n\n<div class=\"permission-form\">\n    <form id=\"rules-form\" method=\"get\" action=\"{{ urls.path(\"-/rules\") }}\">\n        <div class=\"form-section\">\n            <label for=\"action\">Action (permission name):</label>\n            <select id=\"action\" name=\"action\" required>\n                <option value=\"\">Select an action...</option>\n                {% for action_name in sorted_actions %}\n                <option value=\"{{ action_name }}\">{{ action_name }}</option>\n                {% endfor %}\n            </select>\n            <small>The permission action to check</small>\n        </div>\n\n        <div class=\"form-section\">\n            <label for=\"page_size\">Page size:</label>\n            <input type=\"number\" id=\"page_size\" name=\"page_size\" value=\"50\" min=\"1\" max=\"200\" style=\"max-width: 100px;\">\n            <small>Number of results per page (max 200)</small>\n        </div>\n\n        <div class=\"form-actions\">\n            <button type=\"submit\" class=\"submit-btn\" id=\"submit-btn\">View Permission Rules</button>\n        </div>\n    </form>\n</div>\n\n<div id=\"results-container\" style=\"display: none;\">\n    <div class=\"results-header\">\n        <h2>Results</h2>\n        <div class=\"results-count\" id=\"results-count\"></div>\n    </div>\n\n    <div id=\"results-content\"></div>\n\n    <div id=\"pagination\" class=\"pagination\"></div>\n\n    <details style=\"margin-top: 2em;\">\n        <summary style=\"cursor: pointer; font-weight: bold;\">Raw JSON response</summary>\n        <pre id=\"raw-json\" style=\"margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;\"></pre>\n    </details>\n</div>\n\n<script>\nconst form = document.getElementById('rules-form');\nconst resultsContainer = document.getElementById('results-container');\nconst resultsContent = document.getElementById('results-content');\nconst resultsCount = document.getElementById('results-count');\nconst pagination = document.getElementById('pagination');\nconst submitBtn = document.getElementById('submit-btn');\n\n// Populate form on initial load\n(function() {\n    const params = populateFormFromURL();\n    const action = params.get('action');\n    const page = params.get('page');\n    if (action) {\n        fetchResults(page ? parseInt(page) : 1);\n    }\n})();\n\nasync function fetchResults(page = 1) {\n    submitBtn.disabled = true;\n    submitBtn.textContent = 'Loading...';\n\n    const formData = new FormData(form);\n    const params = new URLSearchParams();\n\n    for (const [key, value] of formData.entries()) {\n        if (value && key !== 'page_size') {\n            params.append(key, value);\n        }\n    }\n\n    const pageSize = document.getElementById('page_size').value || '50';\n    params.append('page', page.toString());\n    params.append('page_size', pageSize);\n\n    try {\n        const response = await fetch('{{ urls.path(\"-/rules.json\") }}?' + params.toString(), {\n            method: 'GET',\n            headers: {\n                'Accept': 'application/json',\n            }\n        });\n\n        const data = await response.json();\n\n        if (response.ok) {\n            displayResults(data);\n        } else {\n            displayError(data);\n        }\n    } catch (error) {\n        displayError({ error: error.message });\n    } finally {\n        submitBtn.disabled = false;\n        submitBtn.textContent = 'View Permission Rules';\n    }\n}\n\nfunction displayResults(data) {\n    resultsContainer.style.display = 'block';\n\n    // Update count\n    resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;\n\n    // Display results table\n    if (data.items.length === 0) {\n        resultsContent.innerHTML = '<div class=\"no-results\">No permission rules found for this action.</div>';\n    } else {\n        let html = '<table class=\"results-table\">';\n        html += '<thead><tr>';\n        html += '<th>Effect</th>';\n        html += '<th>Resource Path</th>';\n        html += '<th>Parent</th>';\n        html += '<th>Child</th>';\n        html += '<th>Source Plugin</th>';\n        html += '<th>Reason</th>';\n        html += '</tr></thead>';\n        html += '<tbody>';\n\n        for (const item of data.items) {\n            const rowClass = item.allow ? 'allow-row' : 'deny-row';\n            const effectBadge = item.allow\n                ? '<span style=\"background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;\">ALLOW</span>'\n                : '<span style=\"background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;\">DENY</span>';\n\n            html += `<tr class=\"${rowClass}\">`;\n            html += `<td>${effectBadge}</td>`;\n            html += `<td><span class=\"resource-path\">${escapeHtml(item.resource || '/')}</span></td>`;\n            html += `<td>${escapeHtml(item.parent || '—')}</td>`;\n            html += `<td>${escapeHtml(item.child || '—')}</td>`;\n            html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;\n            html += `<td>${escapeHtml(item.reason || '—')}</td>`;\n            html += '</tr>';\n        }\n\n        html += '</tbody></table>';\n        resultsContent.innerHTML = html;\n    }\n\n    // Update pagination\n    pagination.innerHTML = '';\n    if (data.previous_url || data.next_url) {\n        if (data.previous_url) {\n            const prevLink = document.createElement('a');\n            prevLink.href = data.previous_url;\n            prevLink.textContent = '← Previous';\n            pagination.appendChild(prevLink);\n        }\n\n        const pageInfo = document.createElement('span');\n        pageInfo.textContent = `Page ${data.page}`;\n        pagination.appendChild(pageInfo);\n\n        if (data.next_url) {\n            const nextLink = document.createElement('a');\n            nextLink.href = data.next_url;\n            nextLink.textContent = 'Next →';\n            pagination.appendChild(nextLink);\n        }\n    }\n\n    // Update raw JSON\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n}\n\nfunction displayError(data) {\n    resultsContainer.style.display = 'block';\n    resultsCount.textContent = '';\n    pagination.innerHTML = '';\n\n    resultsContent.innerHTML = `<div class=\"error-message\">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;\n\n    document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);\n}\n\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/error.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{% if title %}{{ title }}{% else %}Error {{ status }}{% endif %}{% endblock %}\n\n{% block content %}\n\n<h1>{% if title %}{{ title }}{% else %}Error {{ status }}{% endif %}</h1>\n\n<div style=\"padding: 1em; margin: 1em 0; border: 3px solid red;\">{{ error }}</div>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/index.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ metadata.title or \"Datasette\" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}\n\n{% block extra_head %}\n{% if noindex %}<meta name=\"robots\" content=\"noindex\">{% endif %}\n{% endblock %}\n\n{% block body_class %}index{% endblock %}\n\n{% block content %}\n<h1>{{ metadata.title or \"Datasette\" }}{% if private %} 🔒{% endif %}</h1>\n\n{% set action_links, action_title = homepage_actions, \"Homepage actions\" %}\n{% include \"_action_menu.html\" %}\n\n{{ top_homepage() }}\n\n{% block description_source_license %}{% include \"_description_source_license.html\" %}{% endblock %}\n\n{% for database in databases %}\n    <h2 style=\"padding-left: 10px; border-left: 10px solid #{{ database.color }}\"><a href=\"{{ urls.database(database.name) }}\">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>\n    <p>\n        {% if database.show_table_row_counts %}{{ \"{:,}\".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.hidden_tables_count %}, {% endif -%}\n        {% if database.hidden_tables_count -%}\n            {% 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 -%}\n        {% endif -%}\n        {% if database.views_count -%}\n            {% if database.tables_count or database.hidden_tables_count %}, {% endif -%}\n            {{ \"{:,}\".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}\n        {% endif %}\n    </p>\n    <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>\n{% endfor %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/logout.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Log out{% endblock %}\n\n{% block content %}\n\n<h1>Log out</h1>\n\n<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>\n\n<form class=\"core\" action=\"{{ urls.logout() }}\" method=\"post\">\n    <div>\n        <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n        <input type=\"submit\" value=\"Log out\">\n    </div>\n</form>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/messages_debug.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Debug messages{% endblock %}\n\n{% block content %}\n\n<h1>Debug messages</h1>\n\n<p>Set a message:</p>\n\n<form class=\"core\" action=\"{{ urls.path('-/messages') }}\" method=\"post\">\n    <div>\n        <input type=\"text\" name=\"message\" style=\"width: 40%\">\n        <div class=\"select-wrapper\">\n            <select name=\"message_type\">\n                <option>INFO</option>\n                <option>WARNING</option>\n                <option>ERROR</option>\n                <option>all</option>\n            </select>\n        </div>\n        <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n        <input type=\"submit\" value=\"Add message\">\n    </div>\n</form>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/patterns.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <title>Datasette: Pattern Portfolio</title>\n    <link rel=\"stylesheet\" href=\"{{ base_url }}-/static/app.css?{{ app_css_hash }}\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n    <meta name=\"robots\" content=\"noindex\">\n    <style></style>\n</head>\n<body>\n\n<header class=\"hd\"><nav>\n    <p class=\"crumbs\">\n        <a href=\"/\">home</a>\n    </p>\n    <details class=\"nav-menu details-menu\">\n        <summary><svg aria-labelledby=\"nav-menu-svg-title\" role=\"img\"\n            fill=\"currentColor\" stroke=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">\n                <title id=\"nav-menu-svg-title\">Menu</title>\n                <path fill-rule=\"evenodd\" d=\"M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z\"></path>\n        </svg></summary>\n        <div class=\"nav-menu-inner\">\n            <ul>\n                <li><a href=\"/-/databases\">Databases</a></li>\n                <li><a href=\"/-/plugins\">Installed plugins</a></li>\n                <li><a href=\"/-/versions\">Version info</a></li>\n            </ul>\n            <form class=\"nav-menu-logout\" action=\"/-/logout\" method=\"post\">\n                <button class=\"button-as-link\">Log out</button>\n            </form>\n        </div>\n    </details>\n    <div class=\"actor\">\n        <strong>root</strong>\n    </div>\n</nav></header>\n\n<section class=\"content\">\n    <h1>Pattern Portfolio</h1>\n</section>\n\n\n\n\n<h2 class=\"pattern-heading\">Header for /database/table/row and Messages</h2>\n\n<header class=\"hd\">\n  <nav>\n      <p class=\"crumbs\">\n          <a href=\"/\">home</a> /\n          <a href=\"/fixtures\">fixtures</a> /\n          <a href=\"/fixtures/attraction_characteristic\">attraction_characteristic</a>\n      </p>\n      <div class=\"actor\">\n          <strong>testuser</strong>\n      </div>\n  </nav>\n</header>\n\n<p class=\"message-info\">Example message</p>\n<p class=\"message-warning\">Example message</p>\n<p class=\"message-error\">Example message</p>\n\n<h2 class=\"pattern-heading\">.bd for /</h2>\n<section class=\"content\">\n    <h1>Datasette Fixtures</h1>\n    <div class=\"metadata-description\">\n            An example SQLite database demonstrating Datasette\n    </div>\n    <p>\n    Data license:\n            <a href=\"https://github.com/simonw/datasette/blob/main/LICENSE\">Apache License 2.0</a>\n    &middot;\n        Data source:\n            <a href=\"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\">\n        tests/fixtures.py</a>\n    &middot;\n        About:\n            <a href=\"https://github.com/simonw/datasette\">\n        About Datasette</a>\n    </p>\n    <h2 style=\"padding-left: 10px; border-left: 10px solid #9403e5\"><a href=\"/fixtures\">fixtures</a></h2>\n    <p>\n        1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views\n    </p>\n    <p><a href=\"/fixtures/compound_three_primary_keys\" title=\"1001 rows\">compound_three_primary_keys</a>, <a href=\"/fixtures/sortable\" title=\"201 rows\">sortable</a>, <a href=\"/fixtures/facetable\" title=\"15 rows\">facetable</a>, <a href=\"/fixtures/roadside_attraction_characteristics\" title=\"5 rows\">roadside_attraction_characteristics</a>, <a href=\"/fixtures/simple_primary_key\" title=\"4 rows\">simple_primary_key</a>, <a href=\"/fixtures\">...</a></p>\n    <h2 style=\"padding-left: 10px; border-left: 10px solid #8d777f\"><a href=\"/data\">data</a></h2>\n    <p>\n        6 rows in 2 tables\n    </p>\n    <p><a href=\"/data/names\" title=\"6 rows\">names</a>, <a href=\"/data/foo\">foo</a></p>\n</section>\n\n<h2 class=\"pattern-heading\">.bd for /database</h2>\n<section class=\"content\">\n    <div class=\"page-header\" style=\"border-color: #ff0000\">\n        <h1>fixtures</h1>\n    </div>\n    <div class=\"page-action-menu\">\n        <details class=\"actions-menu-links details-menu\">\n            <summary>\n                <div class=\"icon-text\">\n                    <svg class=\"icon\" aria-labelledby=\"actions-menu-links-title\" role=\"img\" style=\"color: #fff\" xmlns=\"http://www.w3.org/2000/svg\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                        <title id=\"actions-menu-links-title\">Database actions</title>\n                        <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n                        <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n                    </svg>\n                    <span>Database actions</span>\n                </div>\n            </summary>\n            <div class=\"dropdown-menu\">\n                <div class=\"hook\"></div>\n                <ul>\n                    <li><a href=\"#\">Action one</a></li>\n                    <li><a href=\"#\">Action two</a></li>\n                </ul>\n            </div>\n        </details>\n    </div>\n\n    <div class=\"metadata-description\">\n            Test tables description\n    </div>\n    <p>\n    Data license:\n            <a href=\"https://github.com/simonw/datasette/blob/main/LICENSE\">Apache License 2.0</a>\n    &middot;\n        Data source:\n            <a href=\"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\">\n        tests/fixtures.py</a>\n    &middot;\n        About:\n            <a href=\"https://github.com/simonw/datasette\">\n        About Datasette</a>\n    </p>\n    <form class=\"sql\" action=\"/fixtures\" method=\"get\">\n        <h3>Custom SQL query</h3>\n        <p><textarea id=\"sql-editor\" name=\"sql\">select * from [123_starts_with_digits]</textarea></p>\n        <p>\n            <button id=\"sql-format\" type=\"button\" hidden>Format SQL</button>\n            <input type=\"submit\" value=\"Run SQL\">\n        </p>\n    </form>\n    <div class=\"db-table\">\n        <h2><a href=\"/fixtures/123_starts_with_digits\">123_starts_with_digits</a></h2>\n        <p><em>content</em></p>\n        <p>0 rows</p>\n    </div>\n    <div class=\"db-table\">\n        <h2><a href=\"/fixtures/Table+With+Space+In+Name\">Table With Space In Name</a></h2>\n        <p><em>pk, content</em></p>\n        <p>0 rows</p>\n    </div>\n    <div class=\"db-table\">\n        <h2><a href=\"/fixtures/attraction_characteristic\">attraction_characteristic</a></h2>\n        <p><em>pk, name</em></p>\n        <p>2 rows</p>\n    </div>\n</section>\n\n<h2 class=\"pattern-heading\">.bd for /database/table</h2>\n\n<section class=\"content\">\n    <div class=\"page-header\" style=\"border-color: #ff0000\">\n        <h1>roadside_attraction_characteristics</h1>\n    </div>\n    <div class=\"page-action-menu\">\n        <details class=\"actions-menu-links details-menu\">\n            <summary>\n                <div class=\"icon-text\">\n                    <svg class=\"icon\" aria-labelledby=\"actions-menu-links-title\" role=\"img\" style=\"color: #fff\" xmlns=\"http://www.w3.org/2000/svg\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                        <title id=\"actions-menu-links-title\">Database actions</title>\n                        <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n                        <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n                    </svg>\n                    <span>Table actions</span>\n                </div>\n            </summary>\n            <div class=\"dropdown-menu\">\n                <div class=\"hook\"></div>\n                <ul>\n                    <li><a href=\"#\">Action one</a></li>\n                    <li><a href=\"#\">Action two</a></li>\n                </ul>\n            </div>\n        </details>\n    </div>\n\n    <p>\n    Data license:\n            <a href=\"https://github.com/simonw/datasette/blob/main/LICENSE\">Apache License 2.0</a>\n    &middot;\n        Data source:\n            <a href=\"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\">\n        tests/fixtures.py</a>\n    &middot;\n        About:\n            <a href=\"https://github.com/simonw/datasette\">\n        About Datasette</a>\n    </p>\n    <h3>3 rows\n        where characteristic_id = 2\n    </h3>\n    <form class=\"filters\" action=\"/fixtures/roadside_attraction_characteristics\" method=\"get\">\n    <div class=\"search-row\"><label for=\"_search\">Search:</label><input id=\"_search\" type=\"search\" name=\"_search\" value=\"\"></div>\n        <div class=\"filter-row\">\n            <div class=\"select-wrapper\">\n                <select name=\"_filter_column_1\">\n                    <option value=\"\">- remove filter -</option>\n                    <option>rowid</option>\n                    <option>attraction_id</option>\n                    <option selected>characteristic_id</option>\n                </select>\n            </div>\n            <div class=\"select-wrapper filter-op\">\n                <select name=\"_filter_op_1\">\n                    <option value=\"exact\" selected>=</option>\n                    <option value=\"not\">!=</option>\n                    <option value=\"contains\">contains</option>\n                    <option value=\"endswith\">ends with</option>\n                    <option value=\"startswith\">starts with</option>\n                    <option value=\"gt\">&gt;</option>\n                    <option value=\"gte\">≥</option>\n                    <option value=\"lt\">&lt;</option>\n                    <option value=\"lte\">≤</option>\n                    <option value=\"like\">like</option>\n                    <option value=\"notlike\">not like</option>\n                    <option value=\"glob\">glob</option>\n                    <option value=\"in\">in</option>\n                    <option value=\"notin\">not in</option>\n                    <option value=\"arraycontains\">array contains</option>\n                    <option value=\"date\">date</option>\n                    <option value=\"isnull__1\">is null</option>\n                    <option value=\"notnull__1\">is not null</option>\n                    <option value=\"isblank__1\">is blank</option>\n                    <option value=\"notblank__1\">is not blank</option>\n                </select>\n            </div><input type=\"text\" name=\"_filter_value_1\" class=\"filter-value\" value=\"2\">\n        </div>\n    <div class=\"filter-row\">\n        <div class=\"select-wrapper\">\n            <select name=\"_filter_column\">\n                <option value=\"\">- column -</option>\n                <option>rowid</option>\n                <option>attraction_id</option>\n                <option>characteristic_id</option>\n            </select>\n        </div>\n        <div class=\"select-wrapper filter-op\">\n            <select name=\"_filter_op\">\n                <option value=\"exact\">=</option>\n                <option value=\"not\">!=</option>\n                <option value=\"contains\">contains</option>\n                <option value=\"endswith\">ends with</option>\n                <option value=\"startswith\">starts with</option>\n                <option value=\"gt\">&gt;</option>\n                <option value=\"gte\">≥</option>\n                <option value=\"lt\">&lt;</option>\n                <option value=\"lte\">≤</option>\n                <option value=\"like\">like</option>\n                <option value=\"notlike\">not like</option>\n                <option value=\"glob\">glob</option>\n                <option value=\"in\">in</option>\n                <option value=\"notin\">not in</option>\n                <option value=\"arraycontains\">array contains</option>\n                <option value=\"date\">date</option>\n                <option value=\"isnull__1\">is null</option>\n                <option value=\"notnull__1\">is not null</option>\n                <option value=\"isblank__1\">is blank</option>\n                <option value=\"notblank__1\">is not blank</option>\n            </select>\n        </div><input type=\"text\" name=\"_filter_value\" class=\"filter-value\">\n    </div>\n    <div class=\"filter-row\">\n            <div class=\"select-wrapper small-screen-only\">\n                <select name=\"_sort\" id=\"sort_by\">\n                    <option value=\"\">Sort...</option>\n                    <option value=\"rowid\" selected>Sort by rowid</option>\n                    <option value=\"attraction_id\">Sort by attraction_id</option>\n                    <option value=\"characteristic_id\">Sort by characteristic_id</option>\n                </select>\n            </div>\n            <label class=\"sort_by_desc small-screen-only\"><input type=\"checkbox\" name=\"_sort_by_desc\"> descending</label>\n        <input type=\"submit\" value=\"Apply\">\n    </div>\n  </form>\n\n  <div class=\"extra-wheres\">\n    <h3>2 extra where clauses</h3>\n    <ul>\n\n        <li><code>planet_int=1</code> [<a href=\"/fixtures/facetable?_where=state%3D%27CA%27\">remove</a>]</li>\n\n        <li><code>state='CA'</code> [<a href=\"/fixtures/facetable?_where=planet_int%3D1\">remove</a>]</li>\n\n    </ul>\n  </div>\n\n  <p><a class=\"not-underlined\" title=\"select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where &#34;characteristic_id&#34; = :p0 order by rowid limit 101\" href=\"/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&amp;p0=2\">&#x270e; <span class=\"underlined\">View and edit SQL</span></a></p>\n\n  <p class=\"export-links\">This data as <a href=\"/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on\">json</a>, <a href=\"/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max\">CSV</a> (<a href=\"#export\">advanced</a>)</p>\n\n  <p class=\"suggested-facets\">\n        Suggested facets: <a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags\">tags</a>, <a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created\">created</a> (date), <a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags\">tags</a> (array)\n    </p>\n\n    <div class=\"facet-results\">\n\n            <div class=\"facet-info facet-fixtures-facetable-tags\" id=\"facet-tags\">\n                <p class=\"facet-info-name\">\n                    <strong>tags (array)</strong>\n\n                        <a href=\"/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created\" class=\"cross\">✖</a>\n\n                </p>\n                <ul>\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag1\">tag1</a> 2</li>\n\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag2\">tag2</a> 1</li>\n\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag3\">tag3</a> 1</li>\n\n\n\n                </ul>\n            </div>\n\n            <div class=\"facet-info facet-fixtures-facetable-created\" id=\"facet-created\">\n                <p class=\"facet-info-name\">\n                    <strong>created</strong>\n\n                        <a href=\"/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags\" class=\"cross\">✖</a>\n\n                </p>\n                <ul>\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-14+08%3A00%3A00\">2019-01-14 08:00:00</a> 4</li>\n\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-15+08%3A00%3A00\">2019-01-15 08:00:00</a> 4</li>\n\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-16+08%3A00%3A00\">2019-01-16 08:00:00</a> 2</li>\n\n\n\n                </ul>\n            </div>\n\n            <div class=\"facet-info facet-fixtures-facetable-city_id\" id=\"facet-city_id\">\n                <p class=\"facet-info-name\">\n                    <strong>city_id</strong>\n\n                        <a href=\"/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags\" class=\"cross\">✖</a>\n\n                </p>\n                <ul>\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;city_id=1\">San Francisco</a> 6</li>\n\n\n\n                            <li><a href=\"http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;city_id=2\">Los Angeles</a> 4</li>\n\n\n\n                </ul>\n            </div>\n\n    </div>\n\n    <table class=\"rows-and-columns\">\n        <thead>\n            <tr>\n                    <th class=\"col-Link\" scope=\"col\">\n                                Link\n                        </th>\n                    <th class=\"col-rowid\" scope=\"col\">\n                                            <a href=\"/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid\" rel=\"nofollow\">rowid&nbsp;▼</a>\n                                </th>\n                    <th class=\"col-attraction_id\" scope=\"col\">\n                                            <a href=\"/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id\" rel=\"nofollow\">attraction_id</a>\n                                </th>\n                    <th class=\"col-characteristic_id\" scope=\"col\">\n                                            <a href=\"/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id\" rel=\"nofollow\">characteristic_id</a>\n                                </th>\n            </tr>\n        </thead>\n        <tbody>\n            <tr>\n                    <td class=\"col-Link\"><a href=\"/fixtures/roadside_attraction_characteristics/1\">1</a></td>\n                    <td class=\"col-rowid\">1</td>\n                    <td class=\"col-attraction_id\"><a href=\"/fixtures/roadside_attractions/1\">The Mystery Spot</a>&nbsp;<em>1</em></td>\n                    <td class=\"col-characteristic_id\"><a href=\"/fixtures/attraction_characteristic/2\">Paranormal</a>&nbsp;<em>2</em></td>\n            </tr>\n            <tr>\n                    <td class=\"col-Link\"><a href=\"/fixtures/roadside_attraction_characteristics/2\">2</a></td>\n                    <td class=\"col-rowid\">2</td>\n                    <td class=\"col-attraction_id\"><a href=\"/fixtures/roadside_attractions/2\">Winchester Mystery House</a>&nbsp;<em>2</em></td>\n                    <td class=\"col-characteristic_id\"><a href=\"/fixtures/attraction_characteristic/2\">Paranormal</a>&nbsp;<em>2</em></td>\n            </tr>\n            <tr>\n                    <td class=\"col-Link\"><a href=\"/fixtures/roadside_attraction_characteristics/3\">3</a></td>\n                    <td class=\"col-rowid\">3</td>\n                    <td class=\"col-attraction_id\"><a href=\"/fixtures/roadside_attractions/4\">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td>\n                    <td class=\"col-characteristic_id\"><a href=\"/fixtures/attraction_characteristic/2\">Paranormal</a>&nbsp;<em>2</em></td>\n            </tr>\n        </tbody>\n    </table>\n    <div id=\"export\" class=\"advanced-export\">\n        <h3>Advanced export</h3>\n        <p>JSON shape:\n            <a href=\"/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on\">default</a>,\n            <a href=\"/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array\">array</a>,\n            <a href=\"/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on\">newline-delimited</a>\n        </p>\n        <form action=\"/fixtures/roadside_attraction_characteristics.csv\" method=\"get\">\n            <p>\n                CSV options:\n                <label><input type=\"checkbox\" name=\"_dl\"> download file</label>\n                <label><input type=\"checkbox\" name=\"_labels\" checked> expand labels</label>\n                <input type=\"submit\" value=\"Export CSV\">\n                <input type=\"hidden\" name=\"characteristic_id\" value=\"2\">\n                <input type=\"hidden\" name=\"_size\" value=\"max\">\n            </p>\n        </form>\n    </div>\n    <pre class=\"wrapped-sql\">CREATE TABLE roadside_attraction_characteristics (\n    attraction_id INTEGER REFERENCES roadside_attractions(pk),\n    characteristic_id INTEGER REFERENCES attraction_characteristic(pk)\n);</pre>\n</section>\n\n<h2 class=\"pattern-heading\">.bd for /database/table/row</h2>\n<section class=\"content\">\n    <h1 style=\"padding-left: 10px; border-left: 10px solid #ff0000\">roadside_attractions: 2</h1>\n    <p>This data as <a href=\"/fixtures/roadside_attractions/2.json\">json</a></p>\n    <table class=\"rows-and-columns\">\n        <thead>\n            <tr>\n                    <th class=\"col-pk\" scope=\"col\">\n                                pk\n                        </th>\n                    <th class=\"col-name\" scope=\"col\">\n                                name\n                        </th>\n                    <th class=\"col-address\" scope=\"col\">\n                                address\n                        </th>\n                    <th class=\"col-latitude\" scope=\"col\">\n                                latitude\n                        </th>\n                    <th class=\"col-longitude\" scope=\"col\">\n                                longitude\n                        </th>\n            </tr>\n        </thead>\n        <tbody>\n            <tr>\n                    <td class=\"col-pk\">2</td>\n                    <td class=\"col-name\">Winchester Mystery House</td>\n                    <td class=\"col-address\">525 South Winchester Boulevard, San Jose, CA 95128</td>\n                    <td class=\"col-latitude\">37.3184</td>\n                    <td class=\"col-longitude\">-121.9511</td>\n            </tr>\n        </tbody>\n    </table>\n    <h2>Links from other tables</h2>\n    <ul>\n            <li>\n                <a href=\"/fixtures/roadside_attraction_characteristics?attraction_id=2\">\n                    1 row</a>\n                from attraction_id in roadside_attraction_characteristics\n            </li>\n    </ul>\n</section>\n\n<h2 class=\"pattern-heading\">.ft</h2>\n\n<footer class=\"ft\">Powered by <a href=\"https://datasette.io/\" title=\"Datasette v0+unknown\">Datasette</a>\n    &middot; Data license:\n            <a href=\"https://github.com/simonw/datasette/blob/main/LICENSE\">Apache License 2.0</a>\n    &middot;\n        Data source:\n            <a href=\"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\">\n        tests/fixtures.py</a>\n    &middot;\n        About:\n            <a href=\"https://github.com/simonw/datasette\">\n        About Datasette</a>\n</footer>\n\n{% include \"_close_open_menus.html\" %}\n\n</body>\n</html>\n"
  },
  {
    "path": "datasette/templates/query.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ database }}{% if query and query.sql %}: {{ query.sql }}{% endif %}{% endblock %}\n\n{% block extra_head %}\n{{- super() -}}\n{% if columns %}\n<style>\n@media only screen and (max-width: 576px) {\n{% for column in columns %}\n    .rows-and-columns td:nth-of-type({{ loop.index }}):before { content: \"{{ column|escape_css_string }}\"; }\n{% endfor %}\n}\n</style>\n{% endif %}\n{% include \"_codemirror.html\" %}\n{% endblock %}\n\n{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}\n\n{% block crumbs %}\n{{ crumbs.nav(request=request, database=database) }}\n{% endblock %}\n\n{% block content %}\n\n{% if canned_query_write and db_is_immutable %}\n    <p class=\"message-error\">This query cannot be executed because the database is immutable.</p>\n{% endif %}\n\n<h1 style=\"padding-left: 10px; border-left: 10px solid #{{ database_color }}\">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>\n{% set action_links, action_title = query_actions(), \"Query actions\" %}\n{% include \"_action_menu.html\" %}\n\n{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}\n\n{% block description_source_license %}{% include \"_description_source_license.html\" %}{% endblock %}\n\n<form class=\"sql core\" action=\"{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}\" method=\"{% if canned_query_write %}post{% else %}get{% endif %}\">\n    <h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ \"{:,}\".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}\n        <span class=\"show-hide-sql\">(<a href=\"{{ show_hide_link }}\">{{ show_hide_text }}</a>)</span>\n    {% endif %}</h3>\n    {% if error %}\n        <p class=\"message-error\">{{ error }}</p>\n    {% endif %}\n    {% if not hide_sql %}\n        {% if editable and allow_execute_sql %}\n            <p><textarea id=\"sql-editor\" name=\"sql\"{% if query and query.sql %} style=\"height: {{ query.sql.split(\"\\n\")|length + 2 }}em\"{% endif %}\n            >{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>\n        {% else %}\n            <pre id=\"sql-query\">{% if query %}{{ query.sql }}{% endif %}</pre>\n        {% endif %}\n    {% else %}\n        {% if not canned_query %}\n            <input type=\"hidden\" name=\"sql\"\n                value=\"{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}\"\n            >\n        {% endif %}\n    {% endif %}\n    {% if named_parameter_values %}\n        <h3>Query parameters</h3>\n        {% for name, value in named_parameter_values.items() %}\n            <p><label for=\"qp{{ loop.index }}\">{{ name }}</label> <input type=\"text\" id=\"qp{{ loop.index }}\" name=\"{{ name }}\" value=\"{{ value }}\"></p>\n        {% endfor %}\n    {% endif %}\n    <p>\n        {% if not hide_sql %}<button id=\"sql-format\" type=\"button\" hidden>Format SQL</button>{% endif %}\n        {% if canned_query_write %}<input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">{% endif %}\n        <input type=\"submit\" value=\"Run SQL\"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>\n        {{ show_hide_hidden }}\n        {% if canned_query and edit_sql_url %}<a href=\"{{ edit_sql_url }}\" class=\"canned-query-edit-sql\">Edit SQL</a>{% endif %}\n    </p>\n</form>\n\n{% if display_rows %}\n<p class=\"export-links\">This data as {% for name, url in renderers.items() %}<a href=\"{{ url }}\">{{ name }}</a>{{ \", \" if not loop.last }}{% endfor %}, <a href=\"{{ url_csv }}\">CSV</a></p>\n<div class=\"table-wrapper\"><table class=\"rows-and-columns\">\n    <thead>\n        <tr>\n            {% for column in columns %}<th class=\"col-{{ column|to_css_class }}\" scope=\"col\">{{ column }}</th>{% endfor %}\n        </tr>\n    </thead>\n    <tbody>\n    {% for row in display_rows %}\n        <tr>\n            {% for column, td in zip(columns, row) %}\n                <td class=\"col-{{ column|to_css_class }}\">{{ td }}</td>\n            {% endfor %}\n        </tr>\n    {% endfor %}\n    </tbody>\n</table></div>\n{% else %}\n    {% if not canned_query_write and not error %}\n        <p class=\"zero-results\">0 results</p>\n    {% endif %}\n{% endif %}\n\n{% include \"_codemirror_foot.html\" %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/row.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ database }}: {{ table }}{% endblock %}\n\n{% block extra_head %}\n{{- super() -}}\n<style>\n@media only screen and (max-width: 576px) {\n{% for column in columns %}\n    .rows-and-columns td:nth-of-type({{ loop.index }}):before { content: \"{{ column|escape_css_string }}\"; }\n{% endfor %}\n}\n</style>\n{% endblock %}\n\n{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}\n\n{% block crumbs %}\n{{ crumbs.nav(request=request, database=database, table=table) }}\n{% endblock %}\n\n{% block content %}\n<h1 style=\"padding-left: 10px; border-left: 10px solid #{{ database_color }}\">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>\n\n{% set action_links, action_title = row_actions, \"Row actions\" %}\n{% include \"_action_menu.html\" %}\n\n{{ top_row() }}\n\n{% block description_source_license %}{% include \"_description_source_license.html\" %}{% endblock %}\n\n<p>This data as {% for name, url in renderers.items() %}<a href=\"{{ url }}\">{{ name }}</a>{{ \", \" if not loop.last }}{% endfor %}</p>\n\n{% include custom_table_templates %}\n\n{% if foreign_key_tables %}\n    <h2>Links from other tables</h2>\n    <ul>\n        {% for other in foreign_key_tables %}\n            <li>\n                <a href=\"{{ other.link }}\">\n                    {{ \"{:,}\".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>\n                from {{ other.other_column }} in {{ other.other_table }}\n            </li>\n        {% endfor %}\n    </ul>\n{% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/schema.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ schemas[0].database }}.{{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}{% endblock %}\n\n{% block body_class %}schema{% endblock %}\n\n{% block crumbs %}\n{% if is_instance %}\n{{ crumbs.nav(request=request) }}\n{% elif table_name %}\n{{ crumbs.nav(request=request, database=schemas[0].database, table=table_name) }}\n{% else %}\n{{ crumbs.nav(request=request, database=schemas[0].database) }}\n{% endif %}\n{% endblock %}\n\n{% block content %}\n<div class=\"page-header\">\n    <h1>{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}</h1>\n</div>\n\n{% for item in schemas %}\n    {% if is_instance %}\n        <h2>{{ item.database }}</h2>\n    {% endif %}\n\n    {% if item.schema %}\n        <pre style=\"background-color: #f5f5f5; padding: 1em; overflow-x: auto; border: 1px solid #ddd; border-radius: 4px;\"><code>{{ item.schema }}</code></pre>\n    {% else %}\n        <p><em>No schema available for this database.</em></p>\n    {% endif %}\n\n    {% if not loop.last %}\n        <hr style=\"margin: 2em 0;\">\n    {% endif %}\n{% endfor %}\n\n{% if not schemas %}\n    <p><em>No databases with viewable schemas found.</em></p>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/show_json.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ filename }}{% endblock %}\n\n{% block body_class %}show-json{% endblock %}\n\n{% block content %}\n<h1>{{ filename }}</h1>\n\n<pre>{{ data_json }}</pre>\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/templates/table.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ \"{:,}\".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}\n\n{% block extra_head %}\n{{- super() -}}\n<script src=\"{{ urls.static('column-chooser.js') }}\" defer></script>\n<script src=\"{{ urls.static('table.js') }}\" defer></script>\n<script src=\"{{ urls.static('mobile-column-actions.js') }}\" defer></script>\n<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>\n<style>\n@media only screen and (max-width: 576px) {\n{% for column in display_columns -%}\n    .rows-and-columns td:nth-of-type({{ loop.index }}):before { content: \"{{ column.name|escape_css_string }}\"; }\n{% endfor %}}\n</style>\n{% endblock %}\n\n{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}\n\n{% block crumbs %}\n{{ crumbs.nav(request=request, database=database, table=table) }}\n{% endblock %}\n\n{% block content %}\n<div class=\"page-header\" style=\"border-color: #{{ database_color }}\">\n    <h1>{{ metadata.get(\"title\") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>\n</div>\n{% set action_links, action_title = actions(), \"View actions\" if is_view else \"Table actions\" %}\n{% include \"_action_menu.html\" %}\n\n{{ top_table() }}\n\n{% block description_source_license %}{% include \"_description_source_license.html\" %}{% endblock %}\n\n{% if metadata.get(\"columns\") %}\n<dl class=\"column-descriptions\">\n    {% for column_name, column_description in metadata.columns.items() %}\n        <dt>{{ column_name }}</dt><dd>{{ column_description }}</dd>\n    {% endfor %}\n</dl>\n{% endif %}\n\n{% if count or human_description_en %}\n    <h3>\n        {% if count == count_limit + 1 %}&gt;{{ \"{:,}\".format(count_limit) }} rows\n        {% 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 %}\n        {% elif count or count == 0 %}{{ \"{:,}\".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}\n        {% if human_description_en %}{{ human_description_en }}{% endif %}\n    </h3>\n{% endif %}\n\n<form class=\"core\" class=\"filters\" action=\"{{ urls.table(database, table) }}\" method=\"get\">\n    {% if supports_search %}\n        <div class=\"search-row\"><label for=\"_search\">Search:</label><input id=\"_search\" type=\"search\" name=\"_search\" value=\"{{ search }}\"></div>\n    {% endif %}\n    {% for column, lookup, value in filters.selections() %}\n        <div class=\"filter-row\">\n            <div class=\"select-wrapper\">\n                <select name=\"_filter_column_{{ loop.index }}\">\n                    <option value=\"\">- remove filter -</option>\n                    {% for c in filter_columns %}\n                          <option{% if c == column %} selected{% endif %}>{{ c }}</option>\n                    {% endfor %}\n                </select>\n            </div><div class=\"select-wrapper filter-op\">\n                <select name=\"_filter_op_{{ loop.index }}\">\n                    {% for key, display, no_argument in filters.lookups() %}\n                        <option value=\"{{ key }}{% if no_argument %}__1{% endif %}\"{% if key == lookup %} selected{% endif %}>{{ display }}</option>\n                    {% endfor %}\n                </select>\n            </div><input type=\"text\" name=\"_filter_value_{{ loop.index }}\" class=\"filter-value\" value=\"{{ value }}\">\n        </div>\n    {% endfor %}\n    <div class=\"filter-row\">\n        <div class=\"select-wrapper\">\n            <select name=\"_filter_column\">\n                <option value=\"\">- column -</option>\n                {% for column in filter_columns %}\n                      <option>{{ column }}</option>\n                {% endfor %}\n            </select>\n        </div><div class=\"select-wrapper filter-op\">\n            <select name=\"_filter_op\">\n                {% for key, display, no_argument in filters.lookups() %}\n                    <option value=\"{{ key }}{% if no_argument %}__1{% endif %}\">{{ display }}</option>\n                {% endfor %}\n            </select>\n        </div><input type=\"text\" name=\"_filter_value\" class=\"filter-value\">\n    </div>\n    <div class=\"filter-row\">\n        {% if is_sortable %}\n            <div class=\"select-wrapper small-screen-only\">\n                <select name=\"_sort\" id=\"sort_by\">\n                    <option value=\"\">Sort...</option>\n                    {% for column in display_columns %}\n                        {% if column.sortable %}\n                            <option value=\"{{ column.name }}\"{% if column.name == sort or column.name == sort_desc %} selected{% endif %}>Sort by {{ column.name }}</option>\n                        {% endif %}\n                    {% endfor %}\n                </select>\n            </div>\n            <label class=\"sort_by_desc small-screen-only\"><input type=\"checkbox\" name=\"_sort_by_desc\"{% if sort_desc %} checked{% endif %}> descending</label>\n        {% endif %}\n        {% for key, value in form_hidden_args %}\n            <input type=\"hidden\" name=\"{{ key }}\" value=\"{{ value }}\">\n        {% endfor %}\n        <input type=\"submit\" value=\"Apply\">\n    </div>\n</form>\n\n{% if extra_wheres_for_ui %}\n<div class=\"extra-wheres\">\n    <h3>{{ extra_wheres_for_ui|length }} extra where clause{% if extra_wheres_for_ui|length != 1 %}s{% endif %}</h3>\n    <ul>\n    {% for extra_where in extra_wheres_for_ui %}\n        <li><code>{{ extra_where.text }}</code> [<a href=\"{{ extra_where.remove_url }}\">remove</a>]</li>\n    {% endfor %}\n    </ul>\n</div>\n{% endif %}\n\n{% if query.sql and allow_execute_sql %}\n    <p><a class=\"not-underlined\" title=\"{{ query.sql }}\" href=\"{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}\">&#x270e; <span class=\"underlined\">View and edit SQL</span></a></p>\n{% endif %}\n\n<p class=\"export-links\">This data as {% for name, url in renderers.items() %}<a href=\"{{ url }}\">{{ name }}</a>{{ \", \" if not loop.last }}{% endfor %}{% if display_rows %}, <a href=\"{{ url_csv }}\">CSV</a> (<a href=\"#export\">advanced</a>){% endif %}</p>\n\n{% if suggested_facets %}\n    {% include \"_suggested_facets.html\" %}\n{% endif %}\n\n{% if facets_timed_out %}\n    <p class=\"facets-timed-out\">These facets timed out: {{ \", \".join(facets_timed_out) }}</p>\n{% endif %}\n\n{% if facet_results %}\n    {% include \"_facet_results.html\" %}\n{% endif %}\n\n{% if all_columns %}\n<column-chooser></column-chooser>\n<button class=\"choose-columns-mobile small-screen-only\" onclick=\"openColumnChooser()\">Choose columns</button>\n{% if display_rows %}\n<button type=\"button\" class=\"column-actions-mobile small-screen-only\">\n    <svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n        <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n        <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n    </svg>\n    <span>Column actions</span>\n</button>\n{% endif %}\n<script>\nwindow._columnChooserData = {{ {\"allColumns\": all_columns, \"selectedColumns\": display_columns|map(attribute='name')|list, \"primaryKeys\": primary_keys}|tojson }};\n</script>\n{% endif %}\n{% if set_column_type_ui %}\n<script>\nwindow._setColumnTypeData = {{ set_column_type_ui|tojson }};\n</script>\n{% endif %}\n\n{% include custom_table_templates %}\n\n{% if next_url %}\n     <p><a href=\"{{ next_url }}\">Next page</a></p>\n{% endif %}\n\n{% if display_rows %}\n    <div id=\"export\" class=\"advanced-export\">\n        <h3>Advanced export</h3>\n        <p>JSON shape:\n            <a href=\"{{ renderers['json'] }}\">default</a>,\n            <a href=\"{{ append_querystring(renderers['json'], '_shape=array') }}\">array</a>,\n            <a href=\"{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}\">newline-delimited</a>{% if primary_keys %},\n                <a href=\"{{ append_querystring(renderers['json'], '_shape=object') }}\">object</a>\n            {% endif %}\n        </p>\n        <form class=\"core\" action=\"{{ url_csv_path }}\" method=\"get\">\n            <p>\n                CSV options:\n                <label><input type=\"checkbox\" name=\"_dl\"> download file</label>\n                {% if expandable_columns %}<label><input type=\"checkbox\" name=\"_labels\" checked> expand labels</label>{% endif %}\n                {% if next_url and settings.allow_csv_stream %}<label><input type=\"checkbox\" name=\"_stream\"> stream all rows</label>{% endif %}\n                <input type=\"submit\" value=\"Export CSV\">\n                {% for key, value in url_csv_hidden_args %}\n                    <input type=\"hidden\" name=\"{{ key }}\" value=\"{{ value }}\">\n                {% endfor %}\n            </p>\n        </form>\n    </div>\n{% endif %}\n\n{% if table_definition %}\n    <pre class=\"wrapped-sql\">{{ table_definition }}</pre>\n{% endif %}\n\n{% if view_definition %}\n    <pre class=\"wrapped-sql\">{{ view_definition }}</pre>\n{% endif %}\n\n{% if allow_execute_sql and query.sql %}\n<script>\ndocument.addEventListener('DOMContentLoaded', function() {\n    const countLink = document.querySelector('a.count-sql');\n    if (countLink) {\n        countLink.addEventListener('click', async function(ev) {\n            ev.preventDefault();\n            // Replace countLink with span with same style attribute\n            const span = document.createElement('span');\n            span.textContent = 'counting...';\n            span.setAttribute('style', countLink.getAttribute('style'));\n            countLink.replaceWith(span);\n            countLink.setAttribute('disabled', 'disabled');\n            let url = countLink.href.replace(/(\\?|$)/, '.json$1');\n            try {\n                const response = await fetch(url);\n                console.log({response});\n                const data = await response.json();\n                console.log({data});\n                if (!response.ok) {\n                    console.log('throw error');\n                    throw new Error(data.title || data.error);\n                }\n                const count = data['rows'][0]['count(*)'];\n                const formattedCount = count.toLocaleString();\n                span.closest('h3').textContent = formattedCount + ' rows';\n            } catch (error) {\n                console.log('Update', span, 'with error message', error);\n                span.textContent = error.message;\n                span.style.color = 'red';\n            }\n        });\n    }\n});\n</script>\n{% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "datasette/tokens.py",
    "content": "\"\"\"\nToken handler system for Datasette.\n\nProvides a base class for token handlers and the default signed token handler.\nPlugins can implement register_token_handler to provide custom token backends\n(e.g. database-backed tokens that can be revoked and audited).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport dataclasses\nimport time\nfrom typing import TYPE_CHECKING, Optional\n\nimport itsdangerous\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\n\n@dataclasses.dataclass\nclass TokenRestrictions:\n    \"\"\"\n    Restrictions to apply to a token, limiting which actions it can perform.\n\n    Use the builder methods to construct restrictions::\n\n        restrictions = (TokenRestrictions()\n            .allow_all(\"view-instance\")\n            .allow_database(\"mydb\", \"create-table\")\n            .allow_resource(\"mydb\", \"mytable\", \"insert-row\"))\n    \"\"\"\n\n    all: list[str] = dataclasses.field(default_factory=list)\n    database: dict[str, list[str]] = dataclasses.field(default_factory=dict)\n    resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)\n\n    def allow_all(self, action: str) -> \"TokenRestrictions\":\n        \"\"\"Allow an action across all databases and resources.\"\"\"\n        self.all.append(action)\n        return self\n\n    def allow_database(self, database: str, action: str) -> \"TokenRestrictions\":\n        \"\"\"Allow an action on a specific database.\"\"\"\n        self.database.setdefault(database, []).append(action)\n        return self\n\n    def allow_resource(\n        self, database: str, resource: str, action: str\n    ) -> \"TokenRestrictions\":\n        \"\"\"Allow an action on a specific resource within a database.\"\"\"\n        self.resource.setdefault(database, {}).setdefault(resource, []).append(action)\n        return self\n\n\nclass TokenHandler:\n    \"\"\"\n    Base class for token handlers.\n\n    Subclass this and implement create_token() and verify_token() to provide\n    a custom token backend. Return an instance from the register_token_handler hook.\n    \"\"\"\n\n    name: str = \"\"\n\n    async def create_token(\n        self,\n        datasette: \"Datasette\",\n        actor_id: str,\n        *,\n        expires_after: Optional[int] = None,\n        restrictions: Optional[TokenRestrictions] = None,\n    ) -> str:\n        \"\"\"Create and return a token string for the given actor.\"\"\"\n        raise NotImplementedError\n\n    async def verify_token(self, datasette: \"Datasette\", token: str) -> Optional[dict]:\n        \"\"\"\n        Verify a token and return an actor dict, or None if this handler\n        does not recognize the token.\n        \"\"\"\n        raise NotImplementedError\n\n\nclass SignedTokenHandler(TokenHandler):\n    \"\"\"\n    Default token handler using itsdangerous signed tokens (dstok_ prefix).\n    \"\"\"\n\n    name = \"signed\"\n\n    async def create_token(\n        self,\n        datasette: \"Datasette\",\n        actor_id: str,\n        *,\n        expires_after: Optional[int] = None,\n        restrictions: Optional[TokenRestrictions] = None,\n    ) -> str:\n        if not datasette.setting(\"allow_signed_tokens\"):\n            raise ValueError(\n                \"Signed tokens are not enabled for this Datasette instance\"\n            )\n\n        token = {\"a\": actor_id, \"t\": int(time.time())}\n\n        def abbreviate_action(action):\n            action_obj = datasette.actions.get(action)\n            if not action_obj:\n                return action\n            return action_obj.abbr or action\n\n        if expires_after:\n            token[\"d\"] = expires_after\n        if restrictions and (\n            restrictions.all or restrictions.database or restrictions.resource\n        ):\n            token[\"_r\"] = {}\n            if restrictions.all:\n                token[\"_r\"][\"a\"] = [abbreviate_action(a) for a in restrictions.all]\n            if restrictions.database:\n                token[\"_r\"][\"d\"] = {}\n                for database, actions in restrictions.database.items():\n                    token[\"_r\"][\"d\"][database] = [abbreviate_action(a) for a in actions]\n            if restrictions.resource:\n                token[\"_r\"][\"r\"] = {}\n                for database, resources in restrictions.resource.items():\n                    for resource, actions in resources.items():\n                        token[\"_r\"][\"r\"].setdefault(database, {})[resource] = [\n                            abbreviate_action(a) for a in actions\n                        ]\n        return \"dstok_{}\".format(datasette.sign(token, namespace=\"token\"))\n\n    async def verify_token(self, datasette: \"Datasette\", token: str) -> Optional[dict]:\n        prefix = \"dstok_\"\n\n        if not datasette.setting(\"allow_signed_tokens\"):\n            return None\n\n        max_signed_tokens_ttl = datasette.setting(\"max_signed_tokens_ttl\")\n\n        if not token.startswith(prefix):\n            return None\n\n        raw = token[len(prefix) :]\n        try:\n            decoded = datasette.unsign(raw, namespace=\"token\")\n        except itsdangerous.BadSignature:\n            return None\n\n        if \"t\" not in decoded:\n            return None\n        created = decoded[\"t\"]\n        if not isinstance(created, int):\n            return None\n\n        duration = decoded.get(\"d\")\n        if duration is not None and not isinstance(duration, int):\n            return None\n\n        if (duration is None and max_signed_tokens_ttl) or (\n            duration is not None\n            and max_signed_tokens_ttl\n            and duration > max_signed_tokens_ttl\n        ):\n            duration = max_signed_tokens_ttl\n\n        if duration:\n            if time.time() - created > duration:\n                return None\n\n        actor = {\"id\": decoded[\"a\"], \"token\": \"dstok\"}\n\n        if \"_r\" in decoded:\n            actor[\"_r\"] = decoded[\"_r\"]\n\n        if duration:\n            actor[\"token_expires\"] = created + duration\n\n        return actor\n"
  },
  {
    "path": "datasette/tracer.py",
    "content": "import asyncio\nfrom contextlib import contextmanager\nfrom contextvars import ContextVar\nfrom markupsafe import escape\nimport time\nimport json\nimport traceback\n\ntracers = {}\n\nTRACE_RESERVED_KEYS = {\"type\", \"start\", \"end\", \"duration_ms\", \"traceback\"}\n\ntrace_task_id = ContextVar(\"trace_task_id\", default=None)\n\n\ndef get_task_id():\n    current = trace_task_id.get(None)\n    if current is not None:\n        return current\n    try:\n        loop = asyncio.get_event_loop()\n    except RuntimeError:\n        return None\n    return id(asyncio.current_task(loop=loop))\n\n\n@contextmanager\ndef trace_child_tasks():\n    token = trace_task_id.set(get_task_id())\n    yield\n    trace_task_id.reset(token)\n\n\n@contextmanager\ndef trace(trace_type, **kwargs):\n    assert not TRACE_RESERVED_KEYS.intersection(\n        kwargs.keys()\n    ), f\".trace() keyword parameters cannot include {TRACE_RESERVED_KEYS}\"\n    task_id = get_task_id()\n    if task_id is None:\n        yield kwargs\n        return\n    tracer = tracers.get(task_id)\n    if tracer is None:\n        yield kwargs\n        return\n    start = time.perf_counter()\n    captured_error = None\n    try:\n        yield kwargs\n    except Exception as ex:\n        captured_error = ex\n        raise\n    finally:\n        end = time.perf_counter()\n        trace_info = {\n            \"type\": trace_type,\n            \"start\": start,\n            \"end\": end,\n            \"duration_ms\": (end - start) * 1000,\n            \"traceback\": traceback.format_list(traceback.extract_stack(limit=6)[:-3]),\n            \"error\": str(captured_error) if captured_error else None,\n        }\n        trace_info.update(kwargs)\n        tracer.append(trace_info)\n\n\n@contextmanager\ndef capture_traces(tracer):\n    # tracer is a list\n    task_id = get_task_id()\n    if task_id is None:\n        yield\n        return\n    tracers[task_id] = tracer\n    yield\n    del tracers[task_id]\n\n\nclass AsgiTracer:\n    # If the body is larger than this we don't attempt to append the trace\n    max_body_bytes = 1024 * 256  # 256 KB\n\n    def __init__(self, app):\n        self.app = app\n\n    async def __call__(self, scope, receive, send):\n        if b\"_trace=1\" not in scope.get(\"query_string\", b\"\").split(b\"&\"):\n            await self.app(scope, receive, send)\n            return\n        trace_start = time.perf_counter()\n        traces = []\n\n        accumulated_body = b\"\"\n        size_limit_exceeded = False\n        response_headers = []\n\n        async def wrapped_send(message):\n            nonlocal accumulated_body, size_limit_exceeded, response_headers\n\n            if message[\"type\"] == \"http.response.start\":\n                response_headers = message[\"headers\"]\n                await send(message)\n                return\n\n            if message[\"type\"] != \"http.response.body\" or size_limit_exceeded:\n                await send(message)\n                return\n\n            # Accumulate body until the end or until size is exceeded\n            accumulated_body += message[\"body\"]\n            if len(accumulated_body) > self.max_body_bytes:\n                # Send what we have accumulated so far\n                await send(\n                    {\n                        \"type\": \"http.response.body\",\n                        \"body\": accumulated_body,\n                        \"more_body\": bool(message.get(\"more_body\")),\n                    }\n                )\n                size_limit_exceeded = True\n                return\n\n            if not message.get(\"more_body\"):\n                # We have all the body - modify it and send the result\n                # TODO: What to do about Content-Type or other cases?\n                trace_info = {\n                    \"request_duration_ms\": 1000 * (time.perf_counter() - trace_start),\n                    \"sum_trace_duration_ms\": sum(t[\"duration_ms\"] for t in traces),\n                    \"num_traces\": len(traces),\n                    \"traces\": traces,\n                }\n                try:\n                    content_type = [\n                        v.decode(\"utf8\")\n                        for k, v in response_headers\n                        if k.lower() == b\"content-type\"\n                    ][0]\n                except IndexError:\n                    content_type = \"\"\n                if \"text/html\" in content_type and b\"</body>\" in accumulated_body:\n                    extra = escape(json.dumps(trace_info, indent=2))\n                    extra_html = f\"<pre>{extra}</pre></body>\".encode(\"utf8\")\n                    accumulated_body = accumulated_body.replace(b\"</body>\", extra_html)\n                elif \"json\" in content_type and accumulated_body.startswith(b\"{\"):\n                    data = json.loads(accumulated_body.decode(\"utf8\"))\n                    if \"_trace\" not in data:\n                        data[\"_trace\"] = trace_info\n                    accumulated_body = json.dumps(data).encode(\"utf8\")\n                await send({\"type\": \"http.response.body\", \"body\": accumulated_body})\n\n        with capture_traces(traces):\n            await self.app(scope, receive, wrapped_send)\n"
  },
  {
    "path": "datasette/url_builder.py",
    "content": "from .utils import tilde_encode, path_with_format, PrefixedUrlString\nimport urllib\n\n\nclass Urls:\n    def __init__(self, ds):\n        self.ds = ds\n\n    def path(self, path, format=None):\n        if not isinstance(path, PrefixedUrlString):\n            if path.startswith(\"/\"):\n                path = path[1:]\n            path = self.ds.setting(\"base_url\") + path\n        if format is not None:\n            path = path_with_format(path=path, format=format)\n        return PrefixedUrlString(path)\n\n    def instance(self, format=None):\n        return self.path(\"\", format=format)\n\n    def static(self, path):\n        return self.path(f\"-/static/{path}\")\n\n    def static_plugins(self, plugin, path):\n        return self.path(f\"-/static-plugins/{plugin}/{path}\")\n\n    def logout(self):\n        return self.path(\"-/logout\")\n\n    def database(self, database, format=None):\n        db = self.ds.get_database(database)\n        return self.path(tilde_encode(db.route), format=format)\n\n    def database_query(self, database, sql, format=None):\n        path = f\"{self.database(database)}/-/query?\" + urllib.parse.urlencode(\n            {\"sql\": sql}\n        )\n        return self.path(path, format=format)\n\n    def table(self, database, table, format=None):\n        path = f\"{self.database(database)}/{tilde_encode(table)}\"\n        if format is not None:\n            path = path_with_format(path=path, format=format)\n        return PrefixedUrlString(path)\n\n    def query(self, database, query, format=None):\n        path = f\"{self.database(database)}/{tilde_encode(query)}\"\n        if format is not None:\n            path = path_with_format(path=path, format=format)\n        return PrefixedUrlString(path)\n\n    def row(self, database, table, row_path, format=None):\n        path = f\"{self.table(database, table)}/{row_path}\"\n        if format is not None:\n            path = path_with_format(path=path, format=format)\n        return PrefixedUrlString(path)\n\n    def row_blob(self, database, table, row_path, column):\n        return self.table(database, table) + \"/{}.blob?_blob_column={}\".format(\n            row_path, urllib.parse.quote_plus(column)\n        )\n"
  },
  {
    "path": "datasette/utils/__init__.py",
    "content": "import asyncio\nfrom contextlib import contextmanager\nimport aiofiles\nimport click\nfrom collections import OrderedDict, namedtuple, Counter\nimport copy\nimport dataclasses\nimport base64\nimport hashlib\nimport inspect\nimport json\nimport markupsafe\nimport mergedeep\nimport os\nimport re\nimport shlex\nimport tempfile\nimport typing\nimport time\nimport types\nimport secrets\nimport shutil\nfrom typing import Iterable, List, Tuple\nimport urllib\nimport yaml\nfrom .shutil_backport import copytree\nfrom .sqlite import sqlite3, supports_table_xinfo\n\nif typing.TYPE_CHECKING:\n    from datasette.database import Database\n    from datasette.permissions import Resource\n\n\n@dataclasses.dataclass\nclass PaginatedResources:\n    \"\"\"Paginated results from allowed_resources query.\"\"\"\n\n    resources: List[\"Resource\"]\n    next: str | None  # Keyset token for next page (None if no more results)\n    _datasette: typing.Any = dataclasses.field(default=None, repr=False)\n    _action: str = dataclasses.field(default=None, repr=False)\n    _actor: typing.Any = dataclasses.field(default=None, repr=False)\n    _parent: str | None = dataclasses.field(default=None, repr=False)\n    _include_is_private: bool = dataclasses.field(default=False, repr=False)\n    _include_reasons: bool = dataclasses.field(default=False, repr=False)\n    _limit: int = dataclasses.field(default=100, repr=False)\n\n    async def all(self):\n        \"\"\"\n        Async generator that yields all resources across all pages.\n\n        Automatically handles pagination under the hood. This is useful when you need\n        to iterate through all results without manually managing pagination tokens.\n\n        Yields:\n            Resource objects one at a time\n\n        Example:\n            page = await datasette.allowed_resources(\"view-table\", actor)\n            async for table in page.all():\n                print(f\"{table.parent}/{table.child}\")\n        \"\"\"\n        # Yield all resources from current page\n        for resource in self.resources:\n            yield resource\n\n        # Continue fetching subsequent pages if there are more\n        next_token = self.next\n        while next_token:\n            page = await self._datasette.allowed_resources(\n                self._action,\n                self._actor,\n                parent=self._parent,\n                include_is_private=self._include_is_private,\n                include_reasons=self._include_reasons,\n                limit=self._limit,\n                next=next_token,\n            )\n            for resource in page.resources:\n                yield resource\n            next_token = page.next\n\n\n# From https://www.sqlite.org/lang_keywords.html\nreserved_words = set(\n    (\n        \"abort action add after all alter analyze and as asc attach autoincrement \"\n        \"before begin between by cascade case cast check collate column commit \"\n        \"conflict constraint create cross current_date current_time \"\n        \"current_timestamp database default deferrable deferred delete desc detach \"\n        \"distinct drop each else end escape except exclusive exists explain fail \"\n        \"for foreign from full glob group having if ignore immediate in index \"\n        \"indexed initially inner insert instead intersect into is isnull join key \"\n        \"left like limit match natural no not notnull null of offset on or order \"\n        \"outer plan pragma primary query raise recursive references regexp reindex \"\n        \"release rename replace restrict right rollback row savepoint select set \"\n        \"table temp temporary then to transaction trigger union unique update using \"\n        \"vacuum values view virtual when where with without\"\n    ).split()\n)\n\nAPT_GET_DOCKERFILE_EXTRAS = r\"\"\"\nRUN apt-get update && \\\n    apt-get install -y {} && \\\n    rm -rf /var/lib/apt/lists/*\n\"\"\"\n\n# Can replace with sqlite-utils when I add that dependency\nSPATIALITE_PATHS = (\n    \"/usr/lib/x86_64-linux-gnu/mod_spatialite.so\",\n    \"/usr/local/lib/mod_spatialite.dylib\",\n    \"/usr/local/lib/mod_spatialite.so\",\n    \"/opt/homebrew/lib/mod_spatialite.dylib\",\n)\n# Used to display /-/versions.json SpatiaLite information\nSPATIALITE_FUNCTIONS = (\n    \"spatialite_version\",\n    \"spatialite_target_cpu\",\n    \"check_strict_sql_quoting\",\n    \"freexl_version\",\n    \"proj_version\",\n    \"geos_version\",\n    \"rttopo_version\",\n    \"libxml2_version\",\n    \"HasIconv\",\n    \"HasMathSQL\",\n    \"HasGeoCallbacks\",\n    \"HasProj\",\n    \"HasProj6\",\n    \"HasGeos\",\n    \"HasGeosAdvanced\",\n    \"HasGeosTrunk\",\n    \"HasGeosReentrant\",\n    \"HasGeosOnlyReentrant\",\n    \"HasMiniZip\",\n    \"HasRtTopo\",\n    \"HasLibXML2\",\n    \"HasEpsg\",\n    \"HasFreeXL\",\n    \"HasGeoPackage\",\n    \"HasGCP\",\n    \"HasTopology\",\n    \"HasKNN\",\n    \"HasRouting\",\n)\n# Length of hash subset used in hashed URLs:\nHASH_LENGTH = 7\n\n\n# Can replace this with Column from sqlite_utils when I add that dependency\nColumn = namedtuple(\n    \"Column\", (\"cid\", \"name\", \"type\", \"notnull\", \"default_value\", \"is_pk\", \"hidden\")\n)\n\nfunctions_marked_as_documented = []\n\n\ndef documented(fn):\n    functions_marked_as_documented.append(fn)\n    return fn\n\n\n@documented\nasync def await_me_maybe(value: typing.Any) -> typing.Any:\n    \"If value is callable, call it. If awaitable, await it. Otherwise return it.\"\n    if callable(value):\n        value = value()\n    if asyncio.iscoroutine(value):\n        value = await value\n    return value\n\n\ndef urlsafe_components(token):\n    \"\"\"Splits token on commas and tilde-decodes each component\"\"\"\n    return [tilde_decode(b) for b in token.split(\",\")]\n\n\ndef path_from_row_pks(row, pks, use_rowid, quote=True):\n    \"\"\"Generate an optionally tilde-encoded unique identifier\n    for a row from its primary keys.\"\"\"\n    if use_rowid:\n        bits = [row[\"rowid\"]]\n    else:\n        bits = [\n            row[pk][\"value\"] if isinstance(row[pk], dict) else row[pk] for pk in pks\n        ]\n    if quote:\n        bits = [tilde_encode(str(bit)) for bit in bits]\n    else:\n        bits = [str(bit) for bit in bits]\n\n    return \",\".join(bits)\n\n\ndef compound_keys_after_sql(pks, start_index=0):\n    # Implementation of keyset pagination\n    # See https://github.com/simonw/datasette/issues/190\n    # For pk1/pk2/pk3 returns:\n    #\n    # ([pk1] > :p0)\n    #   or\n    # ([pk1] = :p0 and [pk2] > :p1)\n    #   or\n    # ([pk1] = :p0 and [pk2] = :p1 and [pk3] > :p2)\n    or_clauses = []\n    pks_left = pks[:]\n    while pks_left:\n        and_clauses = []\n        last = pks_left[-1]\n        rest = pks_left[:-1]\n        and_clauses = [\n            f\"{escape_sqlite(pk)} = :p{i + start_index}\" for i, pk in enumerate(rest)\n        ]\n        and_clauses.append(f\"{escape_sqlite(last)} > :p{len(rest) + start_index}\")\n        or_clauses.append(f\"({' and '.join(and_clauses)})\")\n        pks_left.pop()\n    or_clauses.reverse()\n    return \"({})\".format(\"\\n  or\\n\".join(or_clauses))\n\n\nclass CustomJSONEncoder(json.JSONEncoder):\n    def default(self, obj):\n        if isinstance(obj, sqlite3.Row):\n            return tuple(obj)\n        if isinstance(obj, sqlite3.Cursor):\n            return list(obj)\n        if isinstance(obj, bytes):\n            # Does it encode to utf8?\n            try:\n                return obj.decode(\"utf8\")\n            except UnicodeDecodeError:\n                return {\n                    \"$base64\": True,\n                    \"encoded\": base64.b64encode(obj).decode(\"latin1\"),\n                }\n        return json.JSONEncoder.default(self, obj)\n\n\n@contextmanager\ndef sqlite_timelimit(conn, ms):\n    deadline = time.perf_counter() + (ms / 1000)\n    # n is the number of SQLite virtual machine instructions that will be\n    # executed between each check. It takes about 0.08ms to execute 1000.\n    # https://github.com/simonw/datasette/issues/1679\n    n = 1000\n    if ms <= 20:\n        # This mainly happens while executing our test suite\n        n = 1\n\n    def handler():\n        if time.perf_counter() >= deadline:\n            # Returning 1 terminates the query with an error\n            return 1\n\n    conn.set_progress_handler(handler, n)\n    try:\n        yield\n    finally:\n        conn.set_progress_handler(None, n)\n\n\nclass InvalidSql(Exception):\n    pass\n\n\n# Allow SQL to start with a /* */ or -- comment\ncomment_re = (\n    # Start of string, then any amount of whitespace\n    r\"^\\s*(\"\n    +\n    # Comment that starts with -- and ends at a newline\n    r\"(?:\\-\\-.*?\\n\\s*)\"\n    +\n    # Comment that starts with /* and ends with */ - but does not have */ in it\n    r\"|(?:\\/\\*((?!\\*\\/)[\\s\\S])*\\*\\/)\"\n    +\n    # Whitespace\n    r\"\\s*)*\\s*\"\n)\n\nallowed_sql_res = [\n    re.compile(comment_re + r\"select\\b\"),\n    re.compile(comment_re + r\"explain\\s+select\\b\"),\n    re.compile(comment_re + r\"explain\\s+query\\s+plan\\s+select\\b\"),\n    re.compile(comment_re + r\"with\\b\"),\n    re.compile(comment_re + r\"explain\\s+with\\b\"),\n    re.compile(comment_re + r\"explain\\s+query\\s+plan\\s+with\\b\"),\n]\n\nallowed_pragmas = (\n    \"database_list\",\n    \"foreign_key_list\",\n    \"function_list\",\n    \"index_info\",\n    \"index_list\",\n    \"index_xinfo\",\n    \"page_count\",\n    \"max_page_count\",\n    \"page_size\",\n    \"schema_version\",\n    \"table_info\",\n    \"table_xinfo\",\n    \"table_list\",\n)\ndisallawed_sql_res = [\n    (\n        re.compile(f\"pragma(?!_({'|'.join(allowed_pragmas)}))\"),\n        \"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}\".format(\n            \", \".join(\"pragma_{}()\".format(pragma) for pragma in allowed_pragmas)\n        ),\n    )\n]\n\n\ndef validate_sql_select(sql):\n    sql = \"\\n\".join(\n        line for line in sql.split(\"\\n\") if not line.strip().startswith(\"--\")\n    )\n    sql = sql.strip().lower()\n    if not any(r.match(sql) for r in allowed_sql_res):\n        raise InvalidSql(\"Statement must be a SELECT\")\n    for r, msg in disallawed_sql_res:\n        if r.search(sql):\n            raise InvalidSql(msg)\n\n\ndef append_querystring(url, querystring):\n    op = \"&\" if (\"?\" in url) else \"?\"\n    return f\"{url}{op}{querystring}\"\n\n\ndef path_with_added_args(request, args, path=None):\n    path = path or request.path\n    if isinstance(args, dict):\n        args = args.items()\n    args_to_remove = {k for k, v in args if v is None}\n    current = []\n    for key, value in urllib.parse.parse_qsl(request.query_string):\n        if key not in args_to_remove:\n            current.append((key, value))\n    current.extend([(key, value) for key, value in args if value is not None])\n    query_string = urllib.parse.urlencode(current)\n    if query_string:\n        query_string = f\"?{query_string}\"\n    return path + query_string\n\n\ndef path_with_removed_args(request, args, path=None):\n    query_string = request.query_string\n    if path is None:\n        path = request.path\n    else:\n        if \"?\" in path:\n            bits = path.split(\"?\", 1)\n            path, query_string = bits\n    # args can be a dict or a set\n    current = []\n    if isinstance(args, set):\n\n        def should_remove(key, value):\n            return key in args\n\n    elif isinstance(args, dict):\n        # Must match key AND value\n        def should_remove(key, value):\n            return args.get(key) == value\n\n    for key, value in urllib.parse.parse_qsl(query_string):\n        if not should_remove(key, value):\n            current.append((key, value))\n    query_string = urllib.parse.urlencode(current)\n    if query_string:\n        query_string = f\"?{query_string}\"\n    return path + query_string\n\n\ndef path_with_replaced_args(request, args, path=None):\n    path = path or request.path\n    if isinstance(args, dict):\n        args = args.items()\n    keys_to_replace = {p[0] for p in args}\n    current = []\n    for key, value in urllib.parse.parse_qsl(request.query_string):\n        if key not in keys_to_replace:\n            current.append((key, value))\n    current.extend([p for p in args if p[1] is not None])\n    query_string = urllib.parse.urlencode(current)\n    if query_string:\n        query_string = f\"?{query_string}\"\n    return path + query_string\n\n\n_css_re = re.compile(r\"\"\"['\"\\n\\\\]\"\"\")\n_boring_keyword_re = re.compile(r\"^[a-zA-Z_][a-zA-Z0-9_]*$\")\n\n\ndef escape_css_string(s):\n    return _css_re.sub(\n        lambda m: \"\\\\\" + (f\"{ord(m.group()):X}\".zfill(6)),\n        s.replace(\"\\r\\n\", \"\\n\"),\n    )\n\n\ndef escape_sqlite(s):\n    if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):\n        return s\n    else:\n        return f\"[{s}]\"\n\n\ndef make_dockerfile(\n    files,\n    metadata_file,\n    extra_options,\n    branch,\n    template_dir,\n    plugins_dir,\n    static,\n    install,\n    spatialite,\n    version_note,\n    secret,\n    environment_variables=None,\n    port=8001,\n    apt_get_extras=None,\n):\n    cmd = [\"datasette\", \"serve\", \"--host\", \"0.0.0.0\"]\n    environment_variables = environment_variables or {}\n    environment_variables[\"DATASETTE_SECRET\"] = secret\n    apt_get_extras = apt_get_extras or []\n    for filename in files:\n        cmd.extend([\"-i\", filename])\n    cmd.extend([\"--cors\", \"--inspect-file\", \"inspect-data.json\"])\n    if metadata_file:\n        cmd.extend([\"--metadata\", f\"{metadata_file}\"])\n    if template_dir:\n        cmd.extend([\"--template-dir\", \"templates/\"])\n    if plugins_dir:\n        cmd.extend([\"--plugins-dir\", \"plugins/\"])\n    if version_note:\n        cmd.extend([\"--version-note\", f\"{version_note}\"])\n    if static:\n        for mount_point, _ in static:\n            cmd.extend([\"--static\", f\"{mount_point}:{mount_point}\"])\n    if extra_options:\n        for opt in extra_options.split():\n            cmd.append(f\"{opt}\")\n    cmd = [shlex.quote(part) for part in cmd]\n    # port attribute is a (fixed) env variable and should not be quoted\n    cmd.extend([\"--port\", \"$PORT\"])\n    cmd = \" \".join(cmd)\n    if branch:\n        install = [f\"https://github.com/simonw/datasette/archive/{branch}.zip\"] + list(\n            install\n        )\n    else:\n        install = [\"datasette\"] + list(install)\n\n    apt_get_extras_ = []\n    apt_get_extras_.extend(apt_get_extras)\n    apt_get_extras = apt_get_extras_\n    if spatialite:\n        apt_get_extras.extend([\"python3-dev\", \"gcc\", \"libsqlite3-mod-spatialite\"])\n        environment_variables[\"SQLITE_EXTENSIONS\"] = (\n            \"/usr/lib/x86_64-linux-gnu/mod_spatialite.so\"\n        )\n    return \"\"\"\nFROM python:3.11.0-slim-bullseye\nCOPY . /app\nWORKDIR /app\n{apt_get_extras}\n{environment_variables}\nRUN pip install -U {install_from}\nRUN datasette inspect {files} --inspect-file inspect-data.json\nENV PORT {port}\nEXPOSE {port}\nCMD {cmd}\"\"\".format(\n        apt_get_extras=(\n            APT_GET_DOCKERFILE_EXTRAS.format(\" \".join(apt_get_extras))\n            if apt_get_extras\n            else \"\"\n        ),\n        environment_variables=\"\\n\".join(\n            [\n                \"ENV {} '{}'\".format(key, value)\n                for key, value in environment_variables.items()\n            ]\n        ),\n        install_from=\" \".join(install),\n        files=\" \".join(files),\n        port=port,\n        cmd=cmd,\n    ).strip()\n\n\n@contextmanager\ndef temporary_docker_directory(\n    files,\n    name,\n    metadata,\n    extra_options,\n    branch,\n    template_dir,\n    plugins_dir,\n    static,\n    install,\n    spatialite,\n    version_note,\n    secret,\n    extra_metadata=None,\n    environment_variables=None,\n    port=8001,\n    apt_get_extras=None,\n):\n    extra_metadata = extra_metadata or {}\n    tmp = tempfile.TemporaryDirectory()\n    # We create a datasette folder in there to get a nicer now deploy name\n    datasette_dir = os.path.join(tmp.name, name)\n    os.mkdir(datasette_dir)\n    saved_cwd = os.getcwd()\n    file_paths = [os.path.join(saved_cwd, file_path) for file_path in files]\n    file_names = [os.path.split(f)[-1] for f in files]\n    if metadata:\n        metadata_content = parse_metadata(metadata.read())\n    else:\n        metadata_content = {}\n    # Merge in the non-null values in extra_metadata\n    mergedeep.merge(\n        metadata_content,\n        {key: value for key, value in extra_metadata.items() if value is not None},\n    )\n    try:\n        dockerfile = make_dockerfile(\n            file_names,\n            metadata_content and \"metadata.json\",\n            extra_options,\n            branch,\n            template_dir,\n            plugins_dir,\n            static,\n            install,\n            spatialite,\n            version_note,\n            secret,\n            environment_variables,\n            port=port,\n            apt_get_extras=apt_get_extras,\n        )\n        os.chdir(datasette_dir)\n        if metadata_content:\n            with open(\"metadata.json\", \"w\") as fp:\n                fp.write(json.dumps(metadata_content, indent=2))\n        with open(\"Dockerfile\", \"w\") as fp:\n            fp.write(dockerfile)\n        for path, filename in zip(file_paths, file_names):\n            link_or_copy(path, os.path.join(datasette_dir, filename))\n        if template_dir:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, template_dir),\n                os.path.join(datasette_dir, \"templates\"),\n            )\n        if plugins_dir:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, plugins_dir),\n                os.path.join(datasette_dir, \"plugins\"),\n            )\n        for mount_point, path in static:\n            link_or_copy_directory(\n                os.path.join(saved_cwd, path), os.path.join(datasette_dir, mount_point)\n            )\n        yield datasette_dir\n    finally:\n        tmp.cleanup()\n        os.chdir(saved_cwd)\n\n\ndef detect_primary_keys(conn, table):\n    \"\"\"Figure out primary keys for a table.\"\"\"\n    columns = table_column_details(conn, table)\n    pks = [column for column in columns if column.is_pk]\n    pks.sort(key=lambda column: column.is_pk)\n    return [column.name for column in pks]\n\n\ndef get_outbound_foreign_keys(conn, table):\n    infos = conn.execute(f\"PRAGMA foreign_key_list([{table}])\").fetchall()\n    fks = []\n    for info in infos:\n        if info is not None:\n            id, seq, table_name, from_, to_, on_update, on_delete, match = info\n            fks.append(\n                {\n                    \"column\": from_,\n                    \"other_table\": table_name,\n                    \"other_column\": to_,\n                    \"id\": id,\n                    \"seq\": seq,\n                }\n            )\n    # Filter out compound foreign keys by removing any where \"id\" is not unique\n    id_counts = Counter(fk[\"id\"] for fk in fks)\n    return [\n        {\n            \"column\": fk[\"column\"],\n            \"other_table\": fk[\"other_table\"],\n            \"other_column\": fk[\"other_column\"],\n        }\n        for fk in fks\n        if id_counts[fk[\"id\"]] == 1\n    ]\n\n\ndef get_all_foreign_keys(conn):\n    tables = [\n        r[0]\n        for r in conn.execute(\n            'select name from sqlite_master where type=\"table\" order by name'\n        )\n    ]\n    table_to_foreign_keys = {}\n    for table in tables:\n        table_to_foreign_keys[table] = {\"incoming\": [], \"outgoing\": []}\n    for table in tables:\n        fks = get_outbound_foreign_keys(conn, table)\n        for fk in fks:\n            table_name = fk[\"other_table\"]\n            from_ = fk[\"column\"]\n            to_ = fk[\"other_column\"]\n            if table_name not in table_to_foreign_keys:\n                # Weird edge case where something refers to a table that does\n                # not actually exist\n                continue\n            table_to_foreign_keys[table_name][\"incoming\"].append(\n                {\"other_table\": table, \"column\": to_, \"other_column\": from_}\n            )\n            table_to_foreign_keys[table][\"outgoing\"].append(\n                {\"other_table\": table_name, \"column\": from_, \"other_column\": to_}\n            )\n\n    # Sort foreign keys for deterministic ordering\n    for table in table_to_foreign_keys:\n        table_to_foreign_keys[table][\"incoming\"].sort(\n            key=lambda fk: (fk[\"other_table\"], fk[\"column\"], fk[\"other_column\"])\n        )\n        table_to_foreign_keys[table][\"outgoing\"].sort(\n            key=lambda fk: (fk[\"other_table\"], fk[\"column\"], fk[\"other_column\"])\n        )\n\n    return table_to_foreign_keys\n\n\ndef detect_spatialite(conn):\n    rows = conn.execute(\n        'select 1 from sqlite_master where tbl_name = \"geometry_columns\"'\n    ).fetchall()\n    return len(rows) > 0\n\n\ndef detect_fts(conn, table):\n    \"\"\"Detect if table has a corresponding FTS virtual table and return it\"\"\"\n    rows = conn.execute(detect_fts_sql(table)).fetchall()\n    if len(rows) == 0:\n        return None\n    else:\n        return rows[0][0]\n\n\ndef detect_fts_sql(table):\n    return r\"\"\"\n        select name from sqlite_master\n            where rootpage = 0\n            and (\n                sql like '%VIRTUAL TABLE%USING FTS%content=\"{table}\"%'\n                or sql like '%VIRTUAL TABLE%USING FTS%content=[{table}]%'\n                or (\n                    tbl_name = \"{table}\"\n                    and sql like '%VIRTUAL TABLE%USING FTS%'\n                )\n            )\n    \"\"\".format(table=table.replace(\"'\", \"''\"))\n\n\ndef detect_json1(conn=None):\n    if conn is None:\n        conn = sqlite3.connect(\":memory:\")\n    try:\n        conn.execute(\"SELECT json('{}')\")\n        return True\n    except Exception:\n        return False\n\n\ndef table_columns(conn, table):\n    return [column.name for column in table_column_details(conn, table)]\n\n\ndef table_column_details(conn, table):\n    if supports_table_xinfo():\n        # table_xinfo was added in 3.26.0\n        return [\n            Column(*r)\n            for r in conn.execute(\n                f\"PRAGMA table_xinfo({escape_sqlite(table)});\"\n            ).fetchall()\n        ]\n    else:\n        # First trigger a query against sqlite_master to fix an intermittent\n        # test failure, see https://github.com/simonw/datasette/issues/2632\n        conn.execute(\"select 1 from sqlite_master limit 1\").fetchall()\n        return [\n            # Treat hidden as 0 for all columns.\n            Column(*(list(r) + [0]))\n            for r in conn.execute(\n                f\"PRAGMA table_info({escape_sqlite(table)});\"\n            ).fetchall()\n        ]\n\n\nfilter_column_re = re.compile(r\"^_filter_column_\\d+$\")\n\n\ndef filters_should_redirect(special_args):\n    redirect_params = []\n    # Handle _filter_column=foo&_filter_op=exact&_filter_value=...\n    filter_column = special_args.get(\"_filter_column\")\n    filter_op = special_args.get(\"_filter_op\") or \"\"\n    filter_value = special_args.get(\"_filter_value\") or \"\"\n    if \"__\" in filter_op:\n        filter_op, filter_value = filter_op.split(\"__\", 1)\n    if filter_column:\n        redirect_params.append((f\"{filter_column}__{filter_op}\", filter_value))\n    for key in (\"_filter_column\", \"_filter_op\", \"_filter_value\"):\n        if key in special_args:\n            redirect_params.append((key, None))\n    # Now handle _filter_column_1=name&_filter_op_1=contains&_filter_value_1=hello\n    column_keys = [k for k in special_args if filter_column_re.match(k)]\n    for column_key in column_keys:\n        number = column_key.split(\"_\")[-1]\n        column = special_args[column_key]\n        op = special_args.get(f\"_filter_op_{number}\") or \"exact\"\n        value = special_args.get(f\"_filter_value_{number}\") or \"\"\n        if \"__\" in op:\n            op, value = op.split(\"__\", 1)\n        if column:\n            redirect_params.append((f\"{column}__{op}\", value))\n        redirect_params.extend(\n            [\n                (f\"_filter_column_{number}\", None),\n                (f\"_filter_op_{number}\", None),\n                (f\"_filter_value_{number}\", None),\n            ]\n        )\n    return redirect_params\n\n\nwhitespace_re = re.compile(r\"\\s\")\n\n\ndef is_url(value):\n    \"\"\"Must start with http:// or https:// and contain JUST a URL\"\"\"\n    if not isinstance(value, str):\n        return False\n    if not value.startswith(\"http://\") and not value.startswith(\"https://\"):\n        return False\n    # Any whitespace at all is invalid\n    if whitespace_re.search(value):\n        return False\n    return True\n\n\ncss_class_re = re.compile(r\"^[a-zA-Z]+[_a-zA-Z0-9-]*$\")\ncss_invalid_chars_re = re.compile(r\"[^a-zA-Z0-9_\\-]\")\n\n\ndef to_css_class(s):\n    \"\"\"\n    Given a string (e.g. a table name) returns a valid unique CSS class.\n    For simple cases, just returns the string again. If the string is not a\n    valid CSS class (we disallow - and _ prefixes even though they are valid\n    as they may be confused with browser prefixes) we strip invalid characters\n    and add a 6 char md5 sum suffix, to make sure two tables with identical\n    names after stripping characters don't end up with the same CSS class.\n    \"\"\"\n    if css_class_re.match(s):\n        return s\n    md5_suffix = md5_not_usedforsecurity(s)[:6]\n    # Strip leading _, -\n    s = s.lstrip(\"_\").lstrip(\"-\")\n    # Replace any whitespace with hyphens\n    s = \"-\".join(s.split())\n    # Remove any remaining invalid characters\n    s = css_invalid_chars_re.sub(\"\", s)\n    # Attach the md5 suffix\n    bits = [b for b in (s, md5_suffix) if b]\n    return \"-\".join(bits)\n\n\ndef link_or_copy(src, dst):\n    # Intended for use in populating a temp directory. We link if possible,\n    # but fall back to copying if the temp directory is on a different device\n    # https://github.com/simonw/datasette/issues/141\n    try:\n        os.link(src, dst)\n    except OSError:\n        shutil.copyfile(src, dst)\n\n\ndef link_or_copy_directory(src, dst):\n    try:\n        copytree(src, dst, copy_function=os.link, dirs_exist_ok=True)\n    except OSError:\n        copytree(src, dst, dirs_exist_ok=True)\n\n\ndef module_from_path(path, name):\n    # Adapted from http://sayspy.blogspot.com/2011/07/how-to-import-module-from-just-file.html\n    mod = types.ModuleType(name)\n    mod.__file__ = path\n    with open(path, \"r\") as file:\n        code = compile(file.read(), path, \"exec\", dont_inherit=True)\n    exec(code, mod.__dict__)\n    return mod\n\n\ndef path_with_format(\n    *, request=None, path=None, format=None, extra_qs=None, replace_format=None\n):\n    qs = extra_qs or {}\n    path = request.path if request else path\n    if replace_format and path.endswith(f\".{replace_format}\"):\n        path = path[: -(1 + len(replace_format))]\n    if \".\" in path:\n        qs[\"_format\"] = format\n    else:\n        path = f\"{path}.{format}\"\n    if qs:\n        extra = urllib.parse.urlencode(sorted(qs.items()))\n        if request and request.query_string:\n            path = f\"{path}?{request.query_string}&{extra}\"\n        else:\n            path = f\"{path}?{extra}\"\n    elif request and request.query_string:\n        path = f\"{path}?{request.query_string}\"\n    return path\n\n\nclass CustomRow(OrderedDict):\n    # Loose imitation of sqlite3.Row which offers\n    # both index-based AND key-based lookups\n    def __init__(self, columns, values=None):\n        self.columns = columns\n        if values:\n            self.update(values)\n\n    def __getitem__(self, key):\n        if isinstance(key, int):\n            return super().__getitem__(self.columns[key])\n        else:\n            return super().__getitem__(key)\n\n    def __iter__(self):\n        for column in self.columns:\n            yield self[column]\n\n\ndef value_as_boolean(value):\n    if value.lower() not in (\"on\", \"off\", \"true\", \"false\", \"1\", \"0\"):\n        raise ValueAsBooleanError\n    return value.lower() in (\"on\", \"true\", \"1\")\n\n\nclass ValueAsBooleanError(ValueError):\n    pass\n\n\nclass WriteLimitExceeded(Exception):\n    pass\n\n\nclass LimitedWriter:\n    def __init__(self, writer, limit_mb):\n        self.writer = writer\n        self.limit_bytes = limit_mb * 1024 * 1024\n        self.bytes_count = 0\n\n    async def write(self, bytes):\n        self.bytes_count += len(bytes)\n        if self.limit_bytes and (self.bytes_count > self.limit_bytes):\n            raise WriteLimitExceeded(f\"CSV contains more than {self.limit_bytes} bytes\")\n        await self.writer.write(bytes)\n\n\nclass EscapeHtmlWriter:\n    def __init__(self, writer):\n        self.writer = writer\n\n    async def write(self, content):\n        await self.writer.write(markupsafe.escape(content))\n\n\n_infinities = {float(\"inf\"), float(\"-inf\")}\n\n\ndef remove_infinites(row):\n    \"\"\"\n    Replace float('inf') and float('-inf') with None in a row.\n\n    Returns the original row object unchanged if no infinities are found.\n    \"\"\"\n    if isinstance(row, dict):\n        for v in row.values():\n            if isinstance(v, float) and v in _infinities:\n                return {\n                    k: (None if isinstance(v2, float) and v2 in _infinities else v2)\n                    for k, v2 in row.items()\n                }\n    else:\n        for v in row:\n            if isinstance(v, float) and v in _infinities:\n                return [\n                    None if isinstance(v2, float) and v2 in _infinities else v2\n                    for v2 in row\n                ]\n    return row\n\n\nclass StaticMount(click.ParamType):\n    name = \"mount:directory\"\n\n    def convert(self, value, param, ctx):\n        if \":\" not in value:\n            self.fail(\n                f'\"{value}\" should be of format mountpoint:directory',\n                param,\n                ctx,\n            )\n        path, dirpath = value.split(\":\", 1)\n        dirpath = os.path.abspath(dirpath)\n        if not os.path.exists(dirpath) or not os.path.isdir(dirpath):\n            self.fail(f\"{value} is not a valid directory path\", param, ctx)\n        return path, dirpath\n\n\n# The --load-extension parameter can optionally include a specific entrypoint.\n# This is done by appending \":entrypoint_name\" after supplying the path to the extension\nclass LoadExtension(click.ParamType):\n    name = \"path:entrypoint?\"\n\n    def convert(self, value, param, ctx):\n        if \":\" not in value:\n            return value\n        path, entrypoint = value.split(\":\", 1)\n        return path, entrypoint\n\n\ndef format_bytes(bytes):\n    current = float(bytes)\n    for unit in (\"bytes\", \"KB\", \"MB\", \"GB\", \"TB\"):\n        if current < 1024:\n            break\n        current = current / 1024\n    if unit == \"bytes\":\n        return f\"{int(current)} {unit}\"\n    else:\n        return f\"{current:.1f} {unit}\"\n\n\n_escape_fts_re = re.compile(r'\\s+|(\".*?\")')\n\n\ndef escape_fts(query):\n    # If query has unbalanced \", add one at end\n    if query.count('\"') % 2:\n        query += '\"'\n    bits = _escape_fts_re.split(query)\n    bits = [b for b in bits if b and b != '\"\"']\n    return \" \".join(\n        '\"{}\"'.format(bit) if not bit.startswith('\"') else bit for bit in bits\n    )\n\n\nclass MultiParams:\n    def __init__(self, data):\n        # data is a dictionary of key => [list, of, values] or a list of [[\"key\", \"value\"]] pairs\n        if isinstance(data, dict):\n            for key in data:\n                assert isinstance(\n                    data[key], (list, tuple)\n                ), \"dictionary data should be a dictionary of key => [list]\"\n            self._data = data\n        elif isinstance(data, list) or isinstance(data, tuple):\n            new_data = {}\n            for item in data:\n                assert (\n                    isinstance(item, (list, tuple)) and len(item) == 2\n                ), \"list data should be a list of [key, value] pairs\"\n                key, value = item\n                new_data.setdefault(key, []).append(value)\n            self._data = new_data\n\n    def __repr__(self):\n        return f\"<MultiParams: {self._data}>\"\n\n    def __contains__(self, key):\n        return key in self._data\n\n    def __getitem__(self, key):\n        return self._data[key][0]\n\n    def keys(self):\n        return self._data.keys()\n\n    def __iter__(self):\n        yield from self._data.keys()\n\n    def __len__(self):\n        return len(self._data)\n\n    def get(self, name, default=None):\n        \"\"\"Return first value in the list, if available\"\"\"\n        try:\n            return self._data.get(name)[0]\n        except (KeyError, TypeError):\n            return default\n\n    def getlist(self, name):\n        \"\"\"Return full list\"\"\"\n        return self._data.get(name) or []\n\n\nclass ConnectionProblem(Exception):\n    pass\n\n\nclass SpatialiteConnectionProblem(ConnectionProblem):\n    pass\n\n\ndef check_connection(conn):\n    tables = [\n        r[0]\n        for r in conn.execute(\n            \"select name from sqlite_master where type='table'\"\n        ).fetchall()\n    ]\n    for table in tables:\n        try:\n            conn.execute(\n                f\"PRAGMA table_info({escape_sqlite(table)});\",\n            )\n        except sqlite3.OperationalError as e:\n            if e.args[0] == \"no such module: VirtualSpatialIndex\":\n                raise SpatialiteConnectionProblem(e)\n            else:\n                raise ConnectionProblem(e)\n\n\nclass BadMetadataError(Exception):\n    pass\n\n\n@documented\ndef parse_metadata(content: str) -> dict:\n    \"Detects if content is JSON or YAML and parses it appropriately.\"\n    # content can be JSON or YAML\n    try:\n        return json.loads(content)\n    except json.JSONDecodeError:\n        try:\n            return yaml.safe_load(content)\n        except yaml.YAMLError:\n            raise BadMetadataError(\"Metadata is not valid JSON or YAML\")\n\n\ndef _gather_arguments(fn, kwargs):\n    parameters = inspect.signature(fn).parameters.keys()\n    call_with = []\n    for parameter in parameters:\n        if parameter not in kwargs:\n            raise TypeError(\n                \"{} requires parameters {}, missing: {}\".format(\n                    fn, tuple(parameters), set(parameters) - set(kwargs.keys())\n                )\n            )\n        call_with.append(kwargs[parameter])\n    return call_with\n\n\ndef call_with_supported_arguments(fn, **kwargs):\n    call_with = _gather_arguments(fn, kwargs)\n    return fn(*call_with)\n\n\nasync def async_call_with_supported_arguments(fn, **kwargs):\n    call_with = _gather_arguments(fn, kwargs)\n    return await fn(*call_with)\n\n\ndef actor_matches_allow(actor, allow):\n    if allow is True:\n        return True\n    if allow is False:\n        return False\n    if actor is None and allow and allow.get(\"unauthenticated\") is True:\n        return True\n    if allow is None:\n        return True\n    actor = actor or {}\n    for key, values in allow.items():\n        if values == \"*\" and key in actor:\n            return True\n        if not isinstance(values, list):\n            values = [values]\n        actor_values = actor.get(key)\n        if actor_values is None:\n            continue\n        if not isinstance(actor_values, list):\n            actor_values = [actor_values]\n        actor_values = set(actor_values)\n        if actor_values.intersection(values):\n            return True\n    return False\n\n\ndef resolve_env_secrets(config, environ):\n    \"\"\"Create copy that recursively replaces {\"$env\": \"NAME\"} with values from environ\"\"\"\n    if isinstance(config, dict):\n        if list(config.keys()) == [\"$env\"]:\n            return environ.get(list(config.values())[0])\n        elif list(config.keys()) == [\"$file\"]:\n            with open(list(config.values())[0]) as fp:\n                return fp.read()\n        else:\n            return {\n                key: resolve_env_secrets(value, environ)\n                for key, value in config.items()\n            }\n    elif isinstance(config, list):\n        return [resolve_env_secrets(value, environ) for value in config]\n    else:\n        return config\n\n\ndef display_actor(actor):\n    for key in (\"display\", \"name\", \"username\", \"login\", \"id\"):\n        if actor.get(key):\n            return actor[key]\n    return str(actor)\n\n\nclass SpatialiteNotFound(Exception):\n    pass\n\n\n# Can replace with sqlite-utils when I add that dependency\ndef find_spatialite():\n    for path in SPATIALITE_PATHS:\n        if os.path.exists(path):\n            return path\n    raise SpatialiteNotFound\n\n\nasync def initial_path_for_datasette(datasette):\n    \"\"\"Return suggested path for opening this Datasette, based on number of DBs and tables\"\"\"\n    databases = dict([p for p in datasette.databases.items() if p[0] != \"_internal\"])\n    if len(databases) == 1:\n        db_name = next(iter(databases.keys()))\n        path = datasette.urls.database(db_name)\n        # Does this DB only have one table?\n        db = next(iter(databases.values()))\n        tables = await db.table_names()\n        if len(tables) == 1:\n            path = datasette.urls.table(db_name, tables[0])\n    else:\n        path = datasette.urls.instance()\n    return path\n\n\nclass PrefixedUrlString(str):\n    def __add__(self, other):\n        return type(self)(super().__add__(other))\n\n    def __str__(self):\n        return super().__str__()\n\n    def __getattribute__(self, name):\n        if not name.startswith(\"__\") and name in dir(str):\n\n            def method(self, *args, **kwargs):\n                value = getattr(super(), name)(*args, **kwargs)\n                if isinstance(value, str):\n                    return type(self)(value)\n                elif isinstance(value, list):\n                    return [type(self)(i) for i in value]\n                elif isinstance(value, tuple):\n                    return tuple(type(self)(i) for i in value)\n                else:\n                    return value\n\n            return method.__get__(self)\n        else:\n            return super().__getattribute__(name)\n\n\nclass StartupError(Exception):\n    pass\n\n\n_single_line_comment_re = re.compile(r\"--.*\")\n_multi_line_comment_re = re.compile(r\"/\\*.*?\\*/\", re.DOTALL)\n_single_quote_re = re.compile(r\"'(?:''|[^'])*'\")\n_double_quote_re = re.compile(r'\"(?:\\\"\\\"|[^\"])*\"')\n_named_param_re = re.compile(r\":(\\w+)\")\n\n\n@documented\ndef named_parameters(sql: str) -> List[str]:\n    \"\"\"\n    Given a SQL statement, return a list of named parameters that are used in the statement\n\n    e.g. for ``select * from foo where id=:id`` this would return ``[\"id\"]``\n    \"\"\"\n    sql = _single_line_comment_re.sub(\"\", sql)\n    sql = _multi_line_comment_re.sub(\"\", sql)\n    sql = _single_quote_re.sub(\"\", sql)\n    sql = _double_quote_re.sub(\"\", sql)\n    # Extract parameters from what is left\n    return _named_param_re.findall(sql)\n\n\nasync def derive_named_parameters(db: \"Database\", sql: str) -> List[str]:\n    \"\"\"\n    This undocumented but stable method exists for backwards compatibility\n    with plugins that were using it before it switched to named_parameters()\n    \"\"\"\n    return named_parameters(sql)\n\n\ndef add_cors_headers(headers):\n    headers[\"Access-Control-Allow-Origin\"] = \"*\"\n    headers[\"Access-Control-Allow-Headers\"] = \"Authorization, Content-Type\"\n    headers[\"Access-Control-Expose-Headers\"] = \"Link\"\n    headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, HEAD, OPTIONS\"\n    headers[\"Access-Control-Max-Age\"] = \"3600\"\n\n\n_TILDE_ENCODING_SAFE = frozenset(\n    b\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n    b\"abcdefghijklmnopqrstuvwxyz\"\n    b\"0123456789_-\"\n    # This is the same as Python percent-encoding but I removed\n    # '.' and '~'\n)\n\n_space = ord(\" \")\n\n\nclass TildeEncoder(dict):\n    # Keeps a cache internally, via __missing__\n    def __missing__(self, b):\n        # Handle a cache miss, store encoded string in cache and return.\n        if b in _TILDE_ENCODING_SAFE:\n            res = chr(b)\n        elif b == _space:\n            res = \"+\"\n        else:\n            res = \"~{:02X}\".format(b)\n        self[b] = res\n        return res\n\n\n_tilde_encoder = TildeEncoder().__getitem__\n\n\n@documented\ndef tilde_encode(s: str) -> str:\n    \"Returns tilde-encoded string - for example ``/foo/bar`` -> ``~2Ffoo~2Fbar``\"\n    return \"\".join(_tilde_encoder(char) for char in s.encode(\"utf-8\"))\n\n\n@documented\ndef tilde_decode(s: str) -> str:\n    \"Decodes a tilde-encoded string, so ``~2Ffoo~2Fbar`` -> ``/foo/bar``\"\n    # Avoid accidentally decoding a %2f style sequence\n    temp = secrets.token_hex(16)\n    s = s.replace(\"%\", temp)\n    decoded = urllib.parse.unquote_plus(s.replace(\"~\", \"%\"))\n    return decoded.replace(temp, \"%\")\n\n\ndef resolve_routes(routes, path):\n    for regex, view in routes:\n        match = regex.match(path)\n        if match is not None:\n            return match, view\n    return None, None\n\n\ndef truncate_url(url, length):\n    if (not length) or (len(url) <= length):\n        return url\n    bits = url.rsplit(\".\", 1)\n    if len(bits) == 2 and 1 <= len(bits[1]) <= 4 and \"/\" not in bits[1]:\n        rest, ext = bits\n        return rest[: length - 1 - len(ext)] + \"….\" + ext\n    return url[: length - 1] + \"…\"\n\n\nasync def row_sql_params_pks(db, table, pk_values):\n    pks = await db.primary_keys(table)\n    use_rowid = not pks\n    select = \"*\"\n    if use_rowid:\n        select = \"rowid, *\"\n        pks = [\"rowid\"]\n    wheres = [f'\"{pk}\"=:p{i}' for i, pk in enumerate(pks)]\n    sql = f\"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}\"\n    params = {}\n    for i, pk_value in enumerate(pk_values):\n        params[f\"p{i}\"] = pk_value\n    return sql, params, pks\n\n\ndef _handle_pair(key: str, value: str) -> dict:\n    \"\"\"\n    Turn a key-value pair into a nested dictionary.\n    foo, bar => {'foo': 'bar'}\n    foo.bar, baz => {'foo': {'bar': 'baz'}}\n    foo.bar, [1, 2, 3] => {'foo': {'bar': [1, 2, 3]}}\n    foo.bar, \"baz\" => {'foo': {'bar': 'baz'}}\n    foo.bar, '{\"baz\": \"qux\"}' => {'foo': {'bar': \"{'baz': 'qux'}\"}}\n    \"\"\"\n    try:\n        value = json.loads(value)\n    except json.JSONDecodeError:\n        # If it doesn't parse as JSON, treat it as a string\n        pass\n\n    keys = key.split(\".\")\n    result = current_dict = {}\n\n    for k in keys[:-1]:\n        current_dict[k] = {}\n        current_dict = current_dict[k]\n\n    current_dict[keys[-1]] = value\n    return result\n\n\ndef _combine(base: dict, update: dict) -> dict:\n    \"\"\"\n    Recursively merge two dictionaries.\n    \"\"\"\n    for key, value in update.items():\n        if isinstance(value, dict) and key in base and isinstance(base[key], dict):\n            base[key] = _combine(base[key], value)\n        else:\n            base[key] = value\n    return base\n\n\ndef pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:\n    \"\"\"\n    Parse a list of key-value pairs into a nested dictionary.\n    \"\"\"\n    result = {}\n    for key, value in pairs:\n        parsed_pair = _handle_pair(key, value)\n        result = _combine(result, parsed_pair)\n    return result\n\n\ndef make_slot_function(name, datasette, request, **kwargs):\n    from datasette.plugins import pm\n\n    method = getattr(pm.hook, name, None)\n    assert method is not None, \"No hook found for {}\".format(name)\n\n    async def inner():\n        html_bits = []\n        for hook in method(datasette=datasette, request=request, **kwargs):\n            html = await await_me_maybe(hook)\n            if html is not None:\n                html_bits.append(html)\n        return markupsafe.Markup(\"\".join(html_bits))\n\n    return inner\n\n\ndef prune_empty_dicts(d: dict):\n    \"\"\"\n    Recursively prune all empty dictionaries from a given dictionary.\n    \"\"\"\n    for key, value in list(d.items()):\n        if isinstance(value, dict):\n            prune_empty_dicts(value)\n            if value == {}:\n                d.pop(key, None)\n\n\ndef move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:\n    \"\"\"\n    Move 'plugins' and 'allow' keys from source to destination dictionary. Creates\n    hierarchy in destination if needed. After moving, recursively remove any keys\n    in the source that are left empty.\n    \"\"\"\n    source = copy.deepcopy(source)\n    destination = copy.deepcopy(destination)\n\n    def recursive_move(src, dest, path=None):\n        if path is None:\n            path = []\n        for key, value in list(src.items()):\n            new_path = path + [key]\n            if key in (\"plugins\", \"allow\"):\n                # Navigate and create the hierarchy in destination if needed\n                d = dest\n                for step in path:\n                    d = d.setdefault(step, {})\n                # Move the plugins\n                d[key] = value\n                # Remove the plugins from source\n                src.pop(key, None)\n            elif isinstance(value, dict):\n                recursive_move(value, dest, new_path)\n                # After moving, check if the current dictionary is empty and remove it if so\n                if not value:\n                    src.pop(key, None)\n\n    recursive_move(source, destination)\n    prune_empty_dicts(source)\n    return source, destination\n\n\n_table_config_keys = (\n    \"hidden\",\n    \"sort\",\n    \"sort_desc\",\n    \"size\",\n    \"sortable_columns\",\n    \"label_column\",\n    \"facets\",\n    \"fts_table\",\n    \"fts_pk\",\n    \"searchmode\",\n)\n\n\ndef move_table_config(metadata: dict, config: dict):\n    \"\"\"\n    Move all known table configuration keys from metadata to config.\n    \"\"\"\n    if \"databases\" not in metadata:\n        return metadata, config\n    metadata = copy.deepcopy(metadata)\n    config = copy.deepcopy(config)\n    for database_name, database in metadata[\"databases\"].items():\n        if \"tables\" not in database:\n            continue\n        for table_name, table in database[\"tables\"].items():\n            for key in _table_config_keys:\n                if key in table:\n                    config.setdefault(\"databases\", {}).setdefault(\n                        database_name, {}\n                    ).setdefault(\"tables\", {}).setdefault(table_name, {})[\n                        key\n                    ] = table.pop(\n                        key\n                    )\n    prune_empty_dicts(metadata)\n    return metadata, config\n\n\ndef redact_keys(original: dict, key_patterns: Iterable) -> dict:\n    \"\"\"\n    Recursively redact sensitive keys in a dictionary based on given patterns\n\n    :param original: The original dictionary\n    :param key_patterns: A list of substring patterns to redact\n    :return: A copy of the original dictionary with sensitive values redacted\n    \"\"\"\n\n    def redact(data):\n        if isinstance(data, dict):\n            return {\n                k: (\n                    redact(v)\n                    if not any(pattern in k for pattern in key_patterns)\n                    else \"***\"\n                )\n                for k, v in data.items()\n            }\n        elif isinstance(data, list):\n            return [redact(item) for item in data]\n        else:\n            return data\n\n    return redact(original)\n\n\ndef md5_not_usedforsecurity(s):\n    try:\n        return hashlib.md5(s.encode(\"utf8\"), usedforsecurity=False).hexdigest()\n    except TypeError:\n        # For Python 3.8 which does not support usedforsecurity=False\n        return hashlib.md5(s.encode(\"utf8\")).hexdigest()\n\n\n_etag_cache = {}\n\n\nasync def calculate_etag(filepath, chunk_size=4096):\n    if filepath in _etag_cache:\n        return _etag_cache[filepath]\n\n    hasher = hashlib.md5()\n    async with aiofiles.open(filepath, \"rb\") as f:\n        while True:\n            chunk = await f.read(chunk_size)\n            if not chunk:\n                break\n            hasher.update(chunk)\n\n    etag = f'\"{hasher.hexdigest()}\"'\n    _etag_cache[filepath] = etag\n\n    return etag\n\n\ndef deep_dict_update(dict1, dict2):\n    for key, value in dict2.items():\n        if isinstance(value, dict):\n            dict1[key] = deep_dict_update(dict1.get(key, type(value)()), value)\n        else:\n            dict1[key] = value\n    return dict1\n"
  },
  {
    "path": "datasette/utils/actions_sql.py",
    "content": "\"\"\"\nSQL query builder for hierarchical permission checking.\n\nThis module implements a cascading permission system based on the pattern\nfrom https://github.com/simonw/research/tree/main/sqlite-permissions-poc\n\nIt builds SQL queries that:\n\n1. Start with all resources of a given type (from resource_type.resources_sql())\n2. Gather permission rules from plugins (via permission_resources_sql hook)\n3. Apply cascading logic: child → parent → global\n4. Apply DENY-beats-ALLOW at each level\n\nThe core pattern is:\n- Resources are identified by (parent, child) tuples\n- Rules are evaluated at three levels:\n  - child: exact match on (parent, child)\n  - parent: match on (parent, NULL)\n  - global: match on (NULL, NULL)\n- At the same level, DENY (allow=0) beats ALLOW (allow=1)\n- Across levels, child beats parent beats global\n\"\"\"\n\nfrom typing import TYPE_CHECKING\n\nfrom datasette.utils.permissions import gather_permission_sql_from_hooks\n\nif TYPE_CHECKING:\n    from datasette.app import Datasette\n\n\nasync def build_allowed_resources_sql(\n    datasette: \"Datasette\",\n    actor: dict | None,\n    action: str,\n    *,\n    parent: str | None = None,\n    include_is_private: bool = False,\n) -> tuple[str, dict]:\n    \"\"\"\n    Build a SQL query that returns all resources the actor can access for this action.\n\n    Args:\n        datasette: The Datasette instance\n        actor: The actor dict (or None for unauthenticated)\n        action: The action name (e.g., \"view-table\", \"view-database\")\n        parent: Optional parent filter to limit results (e.g., database name)\n        include_is_private: If True, add is_private column showing if anonymous cannot access\n\n    Returns:\n        A tuple of (sql_query, params_dict)\n\n    The returned SQL query will have three columns (or four with include_is_private):\n        - parent: The parent resource identifier (or NULL)\n        - child: The child resource identifier (or NULL)\n        - reason: The reason from the rule that granted access\n        - is_private: (if include_is_private) 1 if anonymous cannot access, 0 otherwise\n\n    Example:\n        For action=\"view-table\", this might return:\n        SELECT parent, child, reason FROM ... WHERE is_allowed = 1\n\n        Results would be like:\n        ('analytics', 'users', 'role-based: analysts can access analytics DB')\n        ('analytics', 'events', 'role-based: analysts can access analytics DB')\n        ('production', 'orders', 'business-exception: allow production.orders for carol')\n    \"\"\"\n    # Get the Action object\n    action_obj = datasette.actions.get(action)\n    if not action_obj:\n        raise ValueError(f\"Unknown action: {action}\")\n\n    # If this action also_requires another action, we need to combine the queries\n    if action_obj.also_requires:\n        # Build both queries\n        main_sql, main_params = await _build_single_action_sql(\n            datasette,\n            actor,\n            action,\n            parent=parent,\n            include_is_private=include_is_private,\n        )\n        required_sql, required_params = await _build_single_action_sql(\n            datasette,\n            actor,\n            action_obj.also_requires,\n            parent=parent,\n            include_is_private=False,\n        )\n\n        # Merge parameters - they should have identical values for :actor, :actor_id, etc.\n        all_params = {**main_params, **required_params}\n        if parent is not None:\n            all_params[\"filter_parent\"] = parent\n\n        # Combine with INNER JOIN - only resources allowed by both actions\n        combined_sql = f\"\"\"\nWITH\nmain_allowed AS (\n{main_sql}\n),\nrequired_allowed AS (\n{required_sql}\n)\nSELECT m.parent, m.child, m.reason\"\"\"\n\n        if include_is_private:\n            combined_sql += \", m.is_private\"\n\n        combined_sql += \"\"\"\nFROM main_allowed m\nINNER JOIN required_allowed r\n  ON ((m.parent = r.parent) OR (m.parent IS NULL AND r.parent IS NULL))\n AND ((m.child = r.child) OR (m.child IS NULL AND r.child IS NULL))\n\"\"\"\n\n        if parent is not None:\n            combined_sql += \"WHERE m.parent = :filter_parent\\n\"\n\n        combined_sql += \"ORDER BY m.parent, m.child\"\n\n        return combined_sql, all_params\n\n    # No also_requires, build single action query\n    return await _build_single_action_sql(\n        datasette, actor, action, parent=parent, include_is_private=include_is_private\n    )\n\n\nasync def _build_single_action_sql(\n    datasette: \"Datasette\",\n    actor: dict | None,\n    action: str,\n    *,\n    parent: str | None = None,\n    include_is_private: bool = False,\n) -> tuple[str, dict]:\n    \"\"\"\n    Build SQL for a single action (internal helper for build_allowed_resources_sql).\n\n    This contains the original logic from build_allowed_resources_sql, extracted\n    to allow combining multiple actions when also_requires is used.\n    \"\"\"\n    # Get the Action object\n    action_obj = datasette.actions.get(action)\n    if not action_obj:\n        raise ValueError(f\"Unknown action: {action}\")\n\n    # Get base resources SQL from the resource class\n    base_resources_sql = await action_obj.resource_class.resources_sql(\n        datasette, actor=actor\n    )\n\n    permission_sqls = await gather_permission_sql_from_hooks(\n        datasette=datasette,\n        actor=actor,\n        action=action,\n    )\n\n    # If permission_sqls is the sentinel, skip all permission checks\n    # Return SQL that allows all resources\n    from datasette.utils.permissions import SKIP_PERMISSION_CHECKS\n\n    if permission_sqls is SKIP_PERMISSION_CHECKS:\n        cols = \"parent, child, 'skip_permission_checks' AS reason\"\n        if include_is_private:\n            cols += \", 0 AS is_private\"\n        return f\"SELECT {cols} FROM ({base_resources_sql})\", {}\n\n    all_params = {}\n    rule_sqls = []\n    restriction_sqls = []\n\n    for permission_sql in permission_sqls:\n        # Always collect params (even from restriction-only plugins)\n        all_params.update(permission_sql.params or {})\n\n        # Collect restriction SQL filters\n        if permission_sql.restriction_sql:\n            restriction_sqls.append(permission_sql.restriction_sql)\n\n        # Skip plugins that only provide restriction_sql (no permission rules)\n        if permission_sql.sql is None:\n            continue\n        rule_sqls.append(f\"\"\"\n            SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n                {permission_sql.sql}\n            )\n            \"\"\".strip())\n\n    # If no rules, return empty result (deny all)\n    if not rule_sqls:\n        empty_cols = \"NULL AS parent, NULL AS child, NULL AS reason\"\n        if include_is_private:\n            empty_cols += \", NULL AS is_private\"\n        return f\"SELECT {empty_cols} WHERE 0\", {}\n\n    # Build the cascading permission query\n    rules_union = \" UNION ALL \".join(rule_sqls)\n\n    # Build the main query\n    query_parts = [\n        \"WITH\",\n        \"base AS (\",\n        f\"  {base_resources_sql}\",\n        \"),\",\n        \"all_rules AS (\",\n        f\"  {rules_union}\",\n        \"),\",\n    ]\n\n    # If include_is_private, we need to build anonymous permissions too\n    if include_is_private:\n        anon_permission_sqls = await gather_permission_sql_from_hooks(\n            datasette=datasette,\n            actor=None,\n            action=action,\n        )\n        anon_sqls_rewritten = []\n        anon_params = {}\n\n        for permission_sql in anon_permission_sqls:\n            # Skip plugins that only provide restriction_sql (no permission rules)\n            if permission_sql.sql is None:\n                continue\n            rewritten_sql = permission_sql.sql\n            for key, value in (permission_sql.params or {}).items():\n                anon_key = f\"anon_{key}\"\n                anon_params[anon_key] = value\n                rewritten_sql = rewritten_sql.replace(f\":{key}\", f\":{anon_key}\")\n            anon_sqls_rewritten.append(rewritten_sql)\n\n        all_params.update(anon_params)\n\n        if anon_sqls_rewritten:\n            anon_rules_union = \" UNION ALL \".join(anon_sqls_rewritten)\n            query_parts.extend(\n                [\n                    \"anon_rules AS (\",\n                    f\"  {anon_rules_union}\",\n                    \"),\",\n                ]\n            )\n\n    # Continue with the cascading logic\n    query_parts.extend(\n        [\n            \"child_lvl AS (\",\n            \"  SELECT b.parent, b.child,\",\n            \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n            \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,\",\n            \"         json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,\",\n            \"         json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons\",\n            \"  FROM base b\",\n            \"  LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child\",\n            \"  GROUP BY b.parent, b.child\",\n            \"),\",\n            \"parent_lvl AS (\",\n            \"  SELECT b.parent, b.child,\",\n            \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n            \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,\",\n            \"         json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,\",\n            \"         json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons\",\n            \"  FROM base b\",\n            \"  LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL\",\n            \"  GROUP BY b.parent, b.child\",\n            \"),\",\n            \"global_lvl AS (\",\n            \"  SELECT b.parent, b.child,\",\n            \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n            \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,\",\n            \"         json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,\",\n            \"         json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons\",\n            \"  FROM base b\",\n            \"  LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL\",\n            \"  GROUP BY b.parent, b.child\",\n            \"),\",\n        ]\n    )\n\n    # Add anonymous decision logic if needed\n    if include_is_private:\n        query_parts.extend(\n            [\n                \"anon_child_lvl AS (\",\n                \"  SELECT b.parent, b.child,\",\n                \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n                \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow\",\n                \"  FROM base b\",\n                \"  LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child\",\n                \"  GROUP BY b.parent, b.child\",\n                \"),\",\n                \"anon_parent_lvl AS (\",\n                \"  SELECT b.parent, b.child,\",\n                \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n                \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow\",\n                \"  FROM base b\",\n                \"  LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL\",\n                \"  GROUP BY b.parent, b.child\",\n                \"),\",\n                \"anon_global_lvl AS (\",\n                \"  SELECT b.parent, b.child,\",\n                \"         MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,\",\n                \"         MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow\",\n                \"  FROM base b\",\n                \"  LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL\",\n                \"  GROUP BY b.parent, b.child\",\n                \"),\",\n                \"anon_decisions AS (\",\n                \"  SELECT\",\n                \"    b.parent, b.child,\",\n                \"    CASE\",\n                \"      WHEN acl.any_deny = 1 THEN 0\",\n                \"      WHEN acl.any_allow = 1 THEN 1\",\n                \"      WHEN apl.any_deny = 1 THEN 0\",\n                \"      WHEN apl.any_allow = 1 THEN 1\",\n                \"      WHEN agl.any_deny = 1 THEN 0\",\n                \"      WHEN agl.any_allow = 1 THEN 1\",\n                \"      ELSE 0\",\n                \"    END AS anon_is_allowed\",\n                \"  FROM base b\",\n                \"  JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))\",\n                \"  JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))\",\n                \"  JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))\",\n                \"),\",\n            ]\n        )\n\n    # Final decisions\n    query_parts.extend(\n        [\n            \"decisions AS (\",\n            \"  SELECT\",\n            \"    b.parent, b.child,\",\n            \"    -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level\",\n            \"    -- Priority order:\",\n            \"    --   1. Child-level deny (most specific, blocks access)\",\n            \"    --   2. Child-level allow (most specific, grants access)\",\n            \"    --   3. Parent-level deny (intermediate, blocks access)\",\n            \"    --   4. Parent-level allow (intermediate, grants access)\",\n            \"    --   5. Global-level deny (least specific, blocks access)\",\n            \"    --   6. Global-level allow (least specific, grants access)\",\n            \"    --   7. Default deny (no rules match)\",\n            \"    CASE\",\n            \"      WHEN cl.any_deny = 1 THEN 0\",\n            \"      WHEN cl.any_allow = 1 THEN 1\",\n            \"      WHEN pl.any_deny = 1 THEN 0\",\n            \"      WHEN pl.any_allow = 1 THEN 1\",\n            \"      WHEN gl.any_deny = 1 THEN 0\",\n            \"      WHEN gl.any_allow = 1 THEN 1\",\n            \"      ELSE 0\",\n            \"    END AS is_allowed,\",\n            \"    CASE\",\n            \"      WHEN cl.any_deny = 1 THEN cl.deny_reasons\",\n            \"      WHEN cl.any_allow = 1 THEN cl.allow_reasons\",\n            \"      WHEN pl.any_deny = 1 THEN pl.deny_reasons\",\n            \"      WHEN pl.any_allow = 1 THEN pl.allow_reasons\",\n            \"      WHEN gl.any_deny = 1 THEN gl.deny_reasons\",\n            \"      WHEN gl.any_allow = 1 THEN gl.allow_reasons\",\n            \"      ELSE '[]'\",\n            \"    END AS reason\",\n        ]\n    )\n\n    if include_is_private:\n        query_parts.append(\n            \"    , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private\"\n        )\n\n    query_parts.extend(\n        [\n            \"  FROM base b\",\n            \"  JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))\",\n            \"  JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))\",\n            \"  JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))\",\n        ]\n    )\n\n    if include_is_private:\n        query_parts.append(\n            \"  JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))\"\n        )\n\n    query_parts.append(\")\")\n\n    # Add restriction list CTE if there are restrictions\n    if restriction_sqls:\n        # Wrap each restriction_sql in a subquery to avoid operator precedence issues\n        # with UNION ALL inside the restriction SQL statements\n        restriction_intersect = \"\\nINTERSECT\\n\".join(\n            f\"SELECT * FROM ({sql})\" for sql in restriction_sqls\n        )\n        query_parts.extend(\n            [\",\", \"restriction_list AS (\", f\"  {restriction_intersect}\", \")\"]\n        )\n\n    # Final SELECT\n    select_cols = \"parent, child, reason\"\n    if include_is_private:\n        select_cols += \", is_private\"\n\n    query_parts.append(f\"SELECT {select_cols}\")\n    query_parts.append(\"FROM decisions\")\n    query_parts.append(\"WHERE is_allowed = 1\")\n\n    # Add restriction filter if there are restrictions\n    if restriction_sqls:\n        query_parts.append(\"\"\"\n  AND EXISTS (\n    SELECT 1 FROM restriction_list r\n    WHERE (r.parent = decisions.parent OR r.parent IS NULL)\n      AND (r.child = decisions.child OR r.child IS NULL)\n  )\"\"\")\n\n    # Add parent filter if specified\n    if parent is not None:\n        query_parts.append(\"  AND parent = :filter_parent\")\n        all_params[\"filter_parent\"] = parent\n\n    query_parts.append(\"ORDER BY parent, child\")\n\n    query = \"\\n\".join(query_parts)\n    return query, all_params\n\n\nasync def build_permission_rules_sql(\n    datasette: \"Datasette\", actor: dict | None, action: str\n) -> tuple[str, dict]:\n    \"\"\"\n    Build the UNION SQL and params for all permission rules for a given actor and action.\n\n    Returns:\n        A tuple of (sql, params) where sql is a UNION ALL query that returns\n        (parent, child, allow, reason, source_plugin) rows.\n    \"\"\"\n    # Get the Action object\n    action_obj = datasette.actions.get(action)\n    if not action_obj:\n        raise ValueError(f\"Unknown action: {action}\")\n\n    permission_sqls = await gather_permission_sql_from_hooks(\n        datasette=datasette,\n        actor=actor,\n        action=action,\n    )\n\n    # If permission_sqls is the sentinel, skip all permission checks\n    # Return SQL that allows everything\n    from datasette.utils.permissions import SKIP_PERMISSION_CHECKS\n\n    if permission_sqls is SKIP_PERMISSION_CHECKS:\n        return (\n            \"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'skip_permission_checks' AS reason, 'skip' AS source_plugin\",\n            {},\n            [],\n        )\n\n    if not permission_sqls:\n        return (\n            \"SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason, NULL AS source_plugin WHERE 0\",\n            {},\n            [],\n        )\n\n    union_parts = []\n    all_params = {}\n    restriction_sqls = []\n\n    for permission_sql in permission_sqls:\n        all_params.update(permission_sql.params or {})\n\n        # Collect restriction SQL filters\n        if permission_sql.restriction_sql:\n            restriction_sqls.append(permission_sql.restriction_sql)\n\n        # Skip plugins that only provide restriction_sql (no permission rules)\n        if permission_sql.sql is None:\n            continue\n\n        union_parts.append(f\"\"\"\n            SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n                {permission_sql.sql}\n            )\n            \"\"\".strip())\n\n    rules_union = \" UNION ALL \".join(union_parts)\n    return rules_union, all_params, restriction_sqls\n\n\nasync def check_permission_for_resource(\n    *,\n    datasette: \"Datasette\",\n    actor: dict | None,\n    action: str,\n    parent: str | None,\n    child: str | None,\n) -> bool:\n    \"\"\"\n    Check if an actor has permission for a specific action on a specific resource.\n\n    Args:\n        datasette: The Datasette instance\n        actor: The actor dict (or None)\n        action: The action name\n        parent: The parent resource identifier (e.g., database name, or None)\n        child: The child resource identifier (e.g., table name, or None)\n\n    Returns:\n        True if the actor is allowed, False otherwise\n\n    This builds the cascading permission query and checks if the specific\n    resource is in the allowed set.\n    \"\"\"\n    rules_union, all_params, restriction_sqls = await build_permission_rules_sql(\n        datasette, actor, action\n    )\n\n    # If no rules (empty SQL), default deny\n    if not rules_union:\n        return False\n\n    # Add parameters for the resource we're checking\n    all_params[\"_check_parent\"] = parent\n    all_params[\"_check_child\"] = child\n\n    # If there are restriction filters, check if the resource passes them first\n    if restriction_sqls:\n        # Check if resource is in restriction allowlist\n        # Database-level restrictions (parent, NULL) should match all children (parent, *)\n        # Wrap each restriction_sql in a subquery to avoid operator precedence issues\n        restriction_check = \"\\nINTERSECT\\n\".join(\n            f\"SELECT * FROM ({sql})\" for sql in restriction_sqls\n        )\n        restriction_query = f\"\"\"\nWITH restriction_list AS (\n    {restriction_check}\n)\nSELECT EXISTS (\n    SELECT 1 FROM restriction_list\n    WHERE (parent = :_check_parent OR parent IS NULL)\n      AND (child = :_check_child OR child IS NULL)\n) AS in_allowlist\n\"\"\"\n        result = await datasette.get_internal_database().execute(\n            restriction_query, all_params\n        )\n        if result.rows and not result.rows[0][0]:\n            # Resource not in restriction allowlist - deny\n            return False\n\n    query = f\"\"\"\nWITH\nall_rules AS (\n  {rules_union}\n),\nmatched_rules AS (\n  SELECT ar.*,\n    CASE\n      WHEN ar.child IS NOT NULL THEN 2  -- child-level (most specific)\n      WHEN ar.parent IS NOT NULL THEN 1  -- parent-level\n      ELSE 0                             -- root/global\n    END AS depth\n  FROM all_rules ar\n  WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)\n    AND (ar.child IS NULL OR ar.child = :_check_child)\n),\nwinner AS (\n  SELECT *\n  FROM matched_rules\n  ORDER BY\n    depth DESC,                          -- specificity first (higher depth wins)\n    CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow\n    source_plugin                        -- stable tie-break\n  LIMIT 1\n)\nSELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed\n\"\"\"\n\n    # Execute the query against the internal database\n    result = await datasette.get_internal_database().execute(query, all_params)\n    if result.rows:\n        return bool(result.rows[0][0])\n    return False\n"
  },
  {
    "path": "datasette/utils/asgi.py",
    "content": "import json\nfrom typing import Optional\nfrom datasette.utils import MultiParams, calculate_etag\nfrom datasette.utils.multipart import (\n    parse_form_data,\n    MultipartParseError,\n    FormData,\n    DEFAULT_MAX_FILE_SIZE,\n    DEFAULT_MAX_REQUEST_SIZE,\n    DEFAULT_MAX_FIELDS,\n    DEFAULT_MAX_FILES,\n    DEFAULT_MAX_PARTS,\n    DEFAULT_MAX_FIELD_SIZE,\n    DEFAULT_MAX_MEMORY_FILE_SIZE,\n    DEFAULT_MAX_PART_HEADER_BYTES,\n    DEFAULT_MAX_PART_HEADER_LINES,\n    DEFAULT_MIN_FREE_DISK_BYTES,\n)\nfrom mimetypes import guess_type\nfrom urllib.parse import parse_qs, urlunparse, parse_qsl\nfrom pathlib import Path\nfrom http.cookies import SimpleCookie, Morsel\nimport aiofiles\nimport aiofiles.os\nimport re\n\n# Workaround for adding samesite support to pre 3.8 python\nMorsel._reserved[\"samesite\"] = \"SameSite\"\n# Thanks, Starlette:\n# https://github.com/encode/starlette/blob/519f575/starlette/responses.py#L17\n\n\nclass Base400(Exception):\n    status = 400\n\n\nclass NotFound(Base400):\n    status = 404\n\n\nclass DatabaseNotFound(NotFound):\n    def __init__(self, database_name):\n        self.database_name = database_name\n        super().__init__(\"Database not found\")\n\n\nclass TableNotFound(NotFound):\n    def __init__(self, database_name, table):\n        super().__init__(\"Table not found\")\n        self.database_name = database_name\n        self.table = table\n\n\nclass RowNotFound(NotFound):\n    def __init__(self, database_name, table, pk_values):\n        super().__init__(\"Row not found\")\n        self.database_name = database_name\n        self.table_name = table\n        self.pk_values = pk_values\n\n\nclass Forbidden(Base400):\n    status = 403\n\n\nclass BadRequest(Base400):\n    status = 400\n\n\nSAMESITE_VALUES = (\"strict\", \"lax\", \"none\")\n\n\nclass Request:\n    def __init__(self, scope, receive):\n        self.scope = scope\n        self.receive = receive\n\n    def __repr__(self):\n        return '<asgi.Request method=\"{}\" url=\"{}\">'.format(self.method, self.url)\n\n    @property\n    def method(self):\n        return self.scope[\"method\"]\n\n    @property\n    def url(self):\n        return urlunparse(\n            (self.scheme, self.host, self.path, None, self.query_string, None)\n        )\n\n    @property\n    def url_vars(self):\n        return (self.scope.get(\"url_route\") or {}).get(\"kwargs\") or {}\n\n    @property\n    def scheme(self):\n        return self.scope.get(\"scheme\") or \"http\"\n\n    @property\n    def headers(self):\n        return {\n            k.decode(\"latin-1\").lower(): v.decode(\"latin-1\")\n            for k, v in self.scope.get(\"headers\") or []\n        }\n\n    @property\n    def host(self):\n        return self.headers.get(\"host\") or \"localhost\"\n\n    @property\n    def cookies(self):\n        cookies = SimpleCookie()\n        cookies.load(self.headers.get(\"cookie\", \"\"))\n        return {key: value.value for key, value in cookies.items()}\n\n    @property\n    def path(self):\n        if self.scope.get(\"raw_path\") is not None:\n            return self.scope[\"raw_path\"].decode(\"latin-1\").partition(\"?\")[0]\n        else:\n            path = self.scope[\"path\"]\n            if isinstance(path, str):\n                return path\n            else:\n                return path.decode(\"utf-8\")\n\n    @property\n    def query_string(self):\n        return (self.scope.get(\"query_string\") or b\"\").decode(\"latin-1\")\n\n    @property\n    def full_path(self):\n        qs = self.query_string\n        return \"{}{}\".format(self.path, (\"?\" + qs) if qs else \"\")\n\n    @property\n    def args(self):\n        return MultiParams(parse_qs(qs=self.query_string, keep_blank_values=True))\n\n    @property\n    def actor(self):\n        return self.scope.get(\"actor\", None)\n\n    async def post_body(self):\n        body = b\"\"\n        more_body = True\n        while more_body:\n            message = await self.receive()\n            assert message[\"type\"] == \"http.request\", message\n            body += message.get(\"body\", b\"\")\n            more_body = message.get(\"more_body\", False)\n        return body\n\n    async def post_vars(self):\n        body = await self.post_body()\n        return dict(parse_qsl(body.decode(\"utf-8\"), keep_blank_values=True))\n\n    async def form(\n        self,\n        files: bool = False,\n        max_file_size: int = DEFAULT_MAX_FILE_SIZE,\n        max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,\n        max_fields: int = DEFAULT_MAX_FIELDS,\n        max_files: int = DEFAULT_MAX_FILES,\n        max_parts: Optional[int] = DEFAULT_MAX_PARTS,\n        max_field_size: int = DEFAULT_MAX_FIELD_SIZE,\n        max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,\n        max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,\n        max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,\n        min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,\n    ) -> FormData:\n        \"\"\"\n        Parse form data from the request body.\n\n        Supports both application/x-www-form-urlencoded and multipart/form-data.\n\n        Args:\n            files: If True, store file uploads; if False (default), discard them\n            max_file_size: Maximum size per file in bytes (default 50MB)\n            max_request_size: Maximum total request size in bytes (default 100MB)\n            max_fields: Maximum number of form fields (default 1000)\n            max_files: Maximum number of file uploads (default 100)\n            max_parts: Maximum number of multipart parts (default max_fields + max_files)\n            max_field_size: Maximum size of a text field value in bytes (default 100KB)\n            max_memory_file_size: Threshold before files spill to disk (default 1MB)\n            max_part_header_bytes: Maximum bytes allowed in part headers (default 16KB)\n            max_part_header_lines: Maximum header lines per part (default 100)\n            min_free_disk_bytes: Minimum free bytes required in temp dir (default 50MB)\n\n        Returns:\n            FormData object with dict-like access to fields and files.\n            Use form[\"key\"] for first value, form.getlist(\"key\") for all values.\n\n        Raises:\n            BadRequest: If content-type is missing, unsupported, or parsing fails\n        \"\"\"\n        content_type = self.headers.get(\"content-type\", \"\")\n        if not content_type:\n            raise BadRequest(\n                \"Missing Content-Type header; expected application/x-www-form-urlencoded \"\n                \"or multipart/form-data\"\n            )\n\n        try:\n            return await parse_form_data(\n                receive=self.receive,\n                content_type=content_type,\n                files=files,\n                max_file_size=max_file_size,\n                max_request_size=max_request_size,\n                max_fields=max_fields,\n                max_files=max_files,\n                max_parts=max_parts,\n                max_field_size=max_field_size,\n                max_memory_file_size=max_memory_file_size,\n                max_part_header_bytes=max_part_header_bytes,\n                max_part_header_lines=max_part_header_lines,\n                min_free_disk_bytes=min_free_disk_bytes,\n            )\n        except MultipartParseError as e:\n            raise BadRequest(str(e))\n\n    @classmethod\n    def fake(cls, path_with_query_string, method=\"GET\", scheme=\"http\", url_vars=None):\n        \"\"\"Useful for constructing Request objects for tests\"\"\"\n        path, _, query_string = path_with_query_string.partition(\"?\")\n        scope = {\n            \"http_version\": \"1.1\",\n            \"method\": method,\n            \"path\": path,\n            \"raw_path\": path_with_query_string.encode(\"latin-1\"),\n            \"query_string\": query_string.encode(\"latin-1\"),\n            \"scheme\": scheme,\n            \"type\": \"http\",\n        }\n        if url_vars:\n            scope[\"url_route\"] = {\"kwargs\": url_vars}\n        return cls(scope, None)\n\n\nclass AsgiLifespan:\n    def __init__(self, app, on_startup=None, on_shutdown=None):\n        self.app = app\n        on_startup = on_startup or []\n        on_shutdown = on_shutdown or []\n        if not isinstance(on_startup or [], list):\n            on_startup = [on_startup]\n        if not isinstance(on_shutdown or [], list):\n            on_shutdown = [on_shutdown]\n        self.on_startup = on_startup\n        self.on_shutdown = on_shutdown\n\n    async def __call__(self, scope, receive, send):\n        if scope[\"type\"] == \"lifespan\":\n            while True:\n                message = await receive()\n                if message[\"type\"] == \"lifespan.startup\":\n                    for fn in self.on_startup:\n                        await fn()\n                    await send({\"type\": \"lifespan.startup.complete\"})\n                elif message[\"type\"] == \"lifespan.shutdown\":\n                    for fn in self.on_shutdown:\n                        await fn()\n                    await send({\"type\": \"lifespan.shutdown.complete\"})\n                    return\n        else:\n            await self.app(scope, receive, send)\n\n\nclass AsgiStream:\n    def __init__(self, stream_fn, status=200, headers=None, content_type=\"text/plain\"):\n        self.stream_fn = stream_fn\n        self.status = status\n        self.headers = headers or {}\n        self.content_type = content_type\n\n    async def asgi_send(self, send):\n        # Remove any existing content-type header\n        headers = {k: v for k, v in self.headers.items() if k.lower() != \"content-type\"}\n        headers[\"content-type\"] = self.content_type\n        await send(\n            {\n                \"type\": \"http.response.start\",\n                \"status\": self.status,\n                \"headers\": [\n                    [key.encode(\"utf-8\"), value.encode(\"utf-8\")]\n                    for key, value in headers.items()\n                ],\n            }\n        )\n        w = AsgiWriter(send)\n        await self.stream_fn(w)\n        await send({\"type\": \"http.response.body\", \"body\": b\"\"})\n\n\nclass AsgiWriter:\n    def __init__(self, send):\n        self.send = send\n\n    async def write(self, chunk):\n        await self.send(\n            {\n                \"type\": \"http.response.body\",\n                \"body\": chunk.encode(\"utf-8\"),\n                \"more_body\": True,\n            }\n        )\n\n\nasync def asgi_send_json(send, info, status=200, headers=None):\n    headers = headers or {}\n    await asgi_send(\n        send,\n        json.dumps(info),\n        status=status,\n        headers=headers,\n        content_type=\"application/json; charset=utf-8\",\n    )\n\n\nasync def asgi_send_html(send, html, status=200, headers=None):\n    headers = headers or {}\n    await asgi_send(\n        send,\n        html,\n        status=status,\n        headers=headers,\n        content_type=\"text/html; charset=utf-8\",\n    )\n\n\nasync def asgi_send_redirect(send, location, status=302):\n    # Prevent open redirect vulnerability: strip multiple leading slashes\n    # //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)\n    location = re.sub(r\"^/+\", \"/\", location)\n    await asgi_send(\n        send,\n        \"\",\n        status=status,\n        headers={\"Location\": location},\n        content_type=\"text/html; charset=utf-8\",\n    )\n\n\nasync def asgi_send(send, content, status, headers=None, content_type=\"text/plain\"):\n    await asgi_start(send, status, headers, content_type)\n    await send({\"type\": \"http.response.body\", \"body\": content.encode(\"utf-8\")})\n\n\nasync def asgi_start(send, status, headers=None, content_type=\"text/plain\"):\n    headers = headers or {}\n    # Remove any existing content-type header\n    headers = {k: v for k, v in headers.items() if k.lower() != \"content-type\"}\n    headers[\"content-type\"] = content_type\n    await send(\n        {\n            \"type\": \"http.response.start\",\n            \"status\": status,\n            \"headers\": [\n                [key.encode(\"latin1\"), value.encode(\"latin1\")]\n                for key, value in headers.items()\n            ],\n        }\n    )\n\n\nasync def asgi_send_file(\n    send, filepath, filename=None, content_type=None, chunk_size=4096, headers=None\n):\n    headers = headers or {}\n    if filename:\n        headers[\"content-disposition\"] = f'attachment; filename=\"{filename}\"'\n\n    first = True\n    headers[\"content-length\"] = str((await aiofiles.os.stat(str(filepath))).st_size)\n    async with aiofiles.open(str(filepath), mode=\"rb\") as fp:\n        if first:\n            await asgi_start(\n                send,\n                200,\n                headers,\n                content_type or guess_type(str(filepath))[0] or \"text/plain\",\n            )\n            first = False\n        more_body = True\n        while more_body:\n            chunk = await fp.read(chunk_size)\n            more_body = len(chunk) == chunk_size\n            await send(\n                {\"type\": \"http.response.body\", \"body\": chunk, \"more_body\": more_body}\n            )\n\n\ndef asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):\n    root_path = Path(root_path)\n    static_headers = {}\n\n    if headers:\n        static_headers = headers.copy()\n\n    async def inner_static(request, send):\n        path = request.scope[\"url_route\"][\"kwargs\"][\"path\"]\n        headers = static_headers.copy()\n        try:\n            full_path = (root_path / path).resolve().absolute()\n        except FileNotFoundError:\n            await asgi_send_html(send, \"404: Directory not found\", 404)\n            return\n        if full_path.is_dir():\n            await asgi_send_html(send, \"403: Directory listing is not allowed\", 403)\n            return\n        # Ensure full_path is within root_path to avoid weird \"../\" tricks\n        try:\n            full_path.relative_to(root_path.resolve())\n        except ValueError:\n            await asgi_send_html(send, \"404: Path not inside root path\", 404)\n            return\n        try:\n            # Calculate ETag for filepath\n            etag = await calculate_etag(full_path, chunk_size=chunk_size)\n            headers[\"ETag\"] = etag\n            if_none_match = request.headers.get(\"if-none-match\")\n            if if_none_match and if_none_match == etag:\n                return await asgi_send(send, \"\", 304)\n            await asgi_send_file(\n                send, full_path, chunk_size=chunk_size, headers=headers\n            )\n        except FileNotFoundError:\n            await asgi_send_html(send, \"404: File not found\", 404)\n            return\n\n    return inner_static\n\n\nclass Response:\n    def __init__(self, body=None, status=200, headers=None, content_type=\"text/plain\"):\n        self.body = body\n        self.status = status\n        self.headers = headers or {}\n        self._set_cookie_headers = []\n        self.content_type = content_type\n\n    async def asgi_send(self, send):\n        headers = {}\n        headers.update(self.headers)\n        headers[\"content-type\"] = self.content_type\n        raw_headers = [\n            [key.encode(\"utf-8\"), value.encode(\"utf-8\")]\n            for key, value in headers.items()\n        ]\n        for set_cookie in self._set_cookie_headers:\n            raw_headers.append([b\"set-cookie\", set_cookie.encode(\"utf-8\")])\n        await send(\n            {\n                \"type\": \"http.response.start\",\n                \"status\": self.status,\n                \"headers\": raw_headers,\n            }\n        )\n        body = self.body\n        if not isinstance(body, bytes):\n            body = body.encode(\"utf-8\")\n        await send({\"type\": \"http.response.body\", \"body\": body})\n\n    def set_cookie(\n        self,\n        key,\n        value=\"\",\n        max_age=None,\n        expires=None,\n        path=\"/\",\n        domain=None,\n        secure=False,\n        httponly=False,\n        samesite=\"lax\",\n    ):\n        assert samesite in SAMESITE_VALUES, \"samesite should be one of {}\".format(\n            SAMESITE_VALUES\n        )\n        cookie = SimpleCookie()\n        cookie[key] = value\n        for prop_name, prop_value in (\n            (\"max_age\", max_age),\n            (\"expires\", expires),\n            (\"path\", path),\n            (\"domain\", domain),\n            (\"samesite\", samesite),\n        ):\n            if prop_value is not None:\n                cookie[key][prop_name.replace(\"_\", \"-\")] = prop_value\n        for prop_name, prop_value in ((\"secure\", secure), (\"httponly\", httponly)):\n            if prop_value:\n                cookie[key][prop_name] = True\n        self._set_cookie_headers.append(cookie.output(header=\"\").strip())\n\n    @classmethod\n    def html(cls, body, status=200, headers=None):\n        return cls(\n            body,\n            status=status,\n            headers=headers,\n            content_type=\"text/html; charset=utf-8\",\n        )\n\n    @classmethod\n    def text(cls, body, status=200, headers=None):\n        return cls(\n            str(body),\n            status=status,\n            headers=headers,\n            content_type=\"text/plain; charset=utf-8\",\n        )\n\n    @classmethod\n    def json(cls, body, status=200, headers=None, default=None):\n        return cls(\n            json.dumps(body, default=default),\n            status=status,\n            headers=headers,\n            content_type=\"application/json; charset=utf-8\",\n        )\n\n    @classmethod\n    def redirect(cls, path, status=302, headers=None):\n        headers = headers or {}\n        headers[\"Location\"] = path\n        return cls(\"\", status=status, headers=headers)\n\n\nclass AsgiFileDownload:\n    def __init__(\n        self,\n        filepath,\n        filename=None,\n        content_type=\"application/octet-stream\",\n        headers=None,\n    ):\n        self.headers = headers or {}\n        self.filepath = filepath\n        self.filename = filename\n        self.content_type = content_type\n\n    async def asgi_send(self, send):\n        return await asgi_send_file(\n            send,\n            self.filepath,\n            filename=self.filename,\n            content_type=self.content_type,\n            headers=self.headers,\n        )\n\n\nclass AsgiRunOnFirstRequest:\n    def __init__(self, asgi, on_startup):\n        assert isinstance(on_startup, list)\n        self.asgi = asgi\n        self.on_startup = on_startup\n        self._started = False\n\n    async def __call__(self, scope, receive, send):\n        if not self._started:\n            self._started = True\n            for hook in self.on_startup:\n                await hook()\n        return await self.asgi(scope, receive, send)\n"
  },
  {
    "path": "datasette/utils/baseconv.py",
    "content": "\"\"\"\nConvert numbers from base 10 integers to base X strings and back again.\n\nSample usage:\n\n>>> base20 = BaseConverter('0123456789abcdefghij')\n>>> base20.from_decimal(1234)\n'31e'\n>>> base20.to_decimal('31e')\n1234\n\nOriginally shared here: https://www.djangosnippets.org/snippets/1431/\n\"\"\"\n\n\nclass BaseConverter(object):\n    decimal_digits = \"0123456789\"\n\n    def __init__(self, digits):\n        self.digits = digits\n\n    def encode(self, i):\n        return self.convert(i, self.decimal_digits, self.digits)\n\n    def decode(self, s):\n        return int(self.convert(s, self.digits, self.decimal_digits))\n\n    def convert(number, fromdigits, todigits):\n        # Based on http://code.activestate.com/recipes/111286/\n        if str(number)[0] == \"-\":\n            number = str(number)[1:]\n            neg = 1\n        else:\n            neg = 0\n\n        # make an integer out of the number\n        x = 0\n        for digit in str(number):\n            x = x * len(fromdigits) + fromdigits.index(digit)\n\n        # create the result in base 'len(todigits)'\n        if x == 0:\n            res = todigits[0]\n        else:\n            res = \"\"\n            while x > 0:\n                digit = x % len(todigits)\n                res = todigits[digit] + res\n                x = int(x / len(todigits))\n            if neg:\n                res = \"-\" + res\n        return res\n\n    convert = staticmethod(convert)\n\n\nbin = BaseConverter(\"01\")\nhexconv = BaseConverter(\"0123456789ABCDEF\")\nbase62 = BaseConverter(\"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\")\n"
  },
  {
    "path": "datasette/utils/check_callable.py",
    "content": "import inspect\nimport types\nfrom typing import NamedTuple, Any\n\n\nclass CallableStatus(NamedTuple):\n    is_callable: bool\n    is_async_callable: bool\n\n\ndef check_callable(obj: Any) -> CallableStatus:\n    if not callable(obj):\n        return CallableStatus(False, False)\n\n    if isinstance(obj, type):\n        # It's a class\n        return CallableStatus(True, False)\n\n    if isinstance(obj, types.FunctionType):\n        return CallableStatus(True, inspect.iscoroutinefunction(obj))\n\n    if hasattr(obj, \"__call__\"):\n        return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))\n\n    assert False, \"obj {} is somehow callable with no __call__ method\".format(repr(obj))\n"
  },
  {
    "path": "datasette/utils/internal_db.py",
    "content": "import textwrap\nfrom datasette.utils import table_column_details\n\n\nasync def init_internal_db(db):\n    create_tables_sql = textwrap.dedent(\"\"\"\n    CREATE TABLE IF NOT EXISTS catalog_databases (\n        database_name TEXT PRIMARY KEY,\n        path TEXT,\n        is_memory INTEGER,\n        schema_version INTEGER\n    );\n    CREATE TABLE IF NOT EXISTS catalog_tables (\n        database_name TEXT,\n        table_name TEXT,\n        rootpage INTEGER,\n        sql TEXT,\n        PRIMARY KEY (database_name, table_name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)\n    );\n    CREATE TABLE IF NOT EXISTS catalog_views (\n        database_name TEXT,\n        view_name TEXT,\n        rootpage INTEGER,\n        sql TEXT,\n        PRIMARY KEY (database_name, view_name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)\n    );\n    CREATE TABLE IF NOT EXISTS catalog_columns (\n        database_name TEXT,\n        table_name TEXT,\n        cid INTEGER,\n        name TEXT,\n        type TEXT,\n        \"notnull\" INTEGER,\n        default_value TEXT, -- renamed from dflt_value\n        is_pk INTEGER, -- renamed from pk\n        hidden INTEGER,\n        PRIMARY KEY (database_name, table_name, name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    CREATE TABLE IF NOT EXISTS catalog_indexes (\n        database_name TEXT,\n        table_name TEXT,\n        seq INTEGER,\n        name TEXT,\n        \"unique\" INTEGER,\n        origin TEXT,\n        partial INTEGER,\n        PRIMARY KEY (database_name, table_name, name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    CREATE TABLE IF NOT EXISTS catalog_foreign_keys (\n        database_name TEXT,\n        table_name TEXT,\n        id INTEGER,\n        seq INTEGER,\n        \"table\" TEXT,\n        \"from\" TEXT,\n        \"to\" TEXT,\n        on_update TEXT,\n        on_delete TEXT,\n        match TEXT,\n        PRIMARY KEY (database_name, table_name, id, seq),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    \"\"\").strip()\n    await db.execute_write_script(create_tables_sql)\n    await initialize_metadata_tables(db)\n\n\nasync def initialize_metadata_tables(db):\n    await db.execute_write_script(textwrap.dedent(\"\"\"\n        CREATE TABLE IF NOT EXISTS metadata_instance (\n            key text,\n            value text,\n            unique(key)\n        );\n\n        CREATE TABLE IF NOT EXISTS metadata_databases (\n            database_name text,\n            key text,\n            value text,\n            unique(database_name, key)\n        );\n\n        CREATE TABLE IF NOT EXISTS metadata_resources (\n            database_name text,\n            resource_name text,\n            key text,\n            value text,\n            unique(database_name, resource_name, key)\n        );\n\n        CREATE TABLE IF NOT EXISTS metadata_columns (\n            database_name text,\n            resource_name text,\n            column_name text,\n            key text,\n            value text,\n            unique(database_name, resource_name, column_name, key)\n        );\n\n        CREATE TABLE IF NOT EXISTS column_types (\n            database_name TEXT NOT NULL,\n            resource_name TEXT NOT NULL,\n            column_name TEXT NOT NULL,\n            column_type TEXT NOT NULL,\n            config TEXT,\n            PRIMARY KEY (database_name, resource_name, column_name)\n        );\n            \"\"\"))\n\n\nasync def populate_schema_tables(internal_db, db):\n    database_name = db.name\n\n    def delete_everything(conn):\n        conn.execute(\n            \"DELETE FROM catalog_tables WHERE database_name = ?\", [database_name]\n        )\n        conn.execute(\n            \"DELETE FROM catalog_views WHERE database_name = ?\", [database_name]\n        )\n        conn.execute(\n            \"DELETE FROM catalog_columns WHERE database_name = ?\", [database_name]\n        )\n        conn.execute(\n            \"DELETE FROM catalog_foreign_keys WHERE database_name = ?\",\n            [database_name],\n        )\n        conn.execute(\n            \"DELETE FROM catalog_indexes WHERE database_name = ?\", [database_name]\n        )\n\n    await internal_db.execute_write_fn(delete_everything)\n\n    tables = (await db.execute(\"select * from sqlite_master WHERE type = 'table'\")).rows\n    views = (await db.execute(\"select * from sqlite_master WHERE type = 'view'\")).rows\n\n    def collect_info(conn):\n        tables_to_insert = []\n        views_to_insert = []\n        columns_to_insert = []\n        foreign_keys_to_insert = []\n        indexes_to_insert = []\n\n        for view in views:\n            view_name = view[\"name\"]\n            views_to_insert.append(\n                (database_name, view_name, view[\"rootpage\"], view[\"sql\"])\n            )\n\n        for table in tables:\n            table_name = table[\"name\"]\n            tables_to_insert.append(\n                (database_name, table_name, table[\"rootpage\"], table[\"sql\"])\n            )\n            columns = table_column_details(conn, table_name)\n            columns_to_insert.extend(\n                {\n                    **{\"database_name\": database_name, \"table_name\": table_name},\n                    **column._asdict(),\n                }\n                for column in columns\n            )\n            foreign_keys = conn.execute(\n                f\"PRAGMA foreign_key_list([{table_name}])\"\n            ).fetchall()\n            foreign_keys_to_insert.extend(\n                {\n                    **{\"database_name\": database_name, \"table_name\": table_name},\n                    **dict(foreign_key),\n                }\n                for foreign_key in foreign_keys\n            )\n            indexes = conn.execute(f\"PRAGMA index_list([{table_name}])\").fetchall()\n            indexes_to_insert.extend(\n                {\n                    **{\"database_name\": database_name, \"table_name\": table_name},\n                    **dict(index),\n                }\n                for index in indexes\n            )\n        return (\n            tables_to_insert,\n            views_to_insert,\n            columns_to_insert,\n            foreign_keys_to_insert,\n            indexes_to_insert,\n        )\n\n    (\n        tables_to_insert,\n        views_to_insert,\n        columns_to_insert,\n        foreign_keys_to_insert,\n        indexes_to_insert,\n    ) = await db.execute_fn(collect_info)\n\n    await internal_db.execute_write_many(\n        \"\"\"\n        INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)\n        values (?, ?, ?, ?)\n    \"\"\",\n        tables_to_insert,\n    )\n    await internal_db.execute_write_many(\n        \"\"\"\n        INSERT INTO catalog_views (database_name, view_name, rootpage, sql)\n        values (?, ?, ?, ?)\n    \"\"\",\n        views_to_insert,\n    )\n    await internal_db.execute_write_many(\n        \"\"\"\n        INSERT INTO catalog_columns (\n            database_name, table_name, cid, name, type, \"notnull\", default_value, is_pk, hidden\n        ) VALUES (\n            :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden\n        )\n    \"\"\",\n        columns_to_insert,\n    )\n    await internal_db.execute_write_many(\n        \"\"\"\n        INSERT INTO catalog_foreign_keys (\n            database_name, table_name, \"id\", seq, \"table\", \"from\", \"to\", on_update, on_delete, match\n        ) VALUES (\n            :database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match\n        )\n    \"\"\",\n        foreign_keys_to_insert,\n    )\n    await internal_db.execute_write_many(\n        \"\"\"\n        INSERT INTO catalog_indexes (\n            database_name, table_name, seq, name, \"unique\", origin, partial\n        ) VALUES (\n            :database_name, :table_name, :seq, :name, :unique, :origin, :partial\n        )\n    \"\"\",\n        indexes_to_insert,\n    )\n"
  },
  {
    "path": "datasette/utils/multipart.py",
    "content": "\"\"\"\nStreaming multipart/form-data parser for ASGI applications.\n\nSupports:\n- Streaming parsing without buffering entire body in memory\n- Files spill to disk above configurable threshold\n- Security limits on request size, file size, field count\n- Both multipart/form-data and application/x-www-form-urlencoded\n\"\"\"\n\nimport asyncio\nimport shutil\nimport tempfile\nfrom dataclasses import dataclass, field\nfrom typing import (\n    Any,\n    Callable,\n    Dict,\n    List,\n    Optional,\n    Tuple,\n    Union,\n)\nfrom urllib.parse import parse_qsl\n\n# Centralized defaults for multipart/form-data parsing\nDEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024  # 50MB\nDEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024  # 100MB\nDEFAULT_MAX_FIELDS = 1000\nDEFAULT_MAX_FILES = 100\n# If max_parts is not specified, it defaults to max_fields + max_files\nDEFAULT_MAX_PARTS: Optional[int] = None\nDEFAULT_MAX_FIELD_SIZE = 100 * 1024  # 100KB\nDEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024  # 1MB\nDEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024  # 16KB\nDEFAULT_MAX_PART_HEADER_LINES = 100\nDEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024  # 50MB\n\n\nclass MultipartParseError(Exception):\n    \"\"\"Raised when multipart parsing fails.\"\"\"\n\n    pass\n\n\n@dataclass\nclass UploadedFile:\n    \"\"\"\n    Represents an uploaded file from a multipart form.\n\n    Attributes:\n        name: The form field name\n        filename: The original filename from the upload\n        content_type: The MIME type of the file\n        size: Size in bytes\n    \"\"\"\n\n    name: str\n    filename: str\n    content_type: Optional[str]\n    size: int\n    _file: tempfile.SpooledTemporaryFile = field(repr=False)\n\n    async def read(self, size: int = -1) -> bytes:\n        \"\"\"Read file contents.\"\"\"\n        return await asyncio.to_thread(self._file.read, size)\n\n    async def seek(self, offset: int, whence: int = 0) -> int:\n        \"\"\"Seek to position in file.\"\"\"\n        return await asyncio.to_thread(self._file.seek, offset, whence)\n\n    async def close(self) -> None:\n        \"\"\"Close the underlying file.\"\"\"\n        await asyncio.to_thread(self._file.close)\n\n    def close_sync(self) -> None:\n        \"\"\"Close the underlying file synchronously.\"\"\"\n        self._file.close()\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        await self.close()\n\n    def __del__(self):\n        try:\n            self._file.close()\n        except Exception:\n            pass\n\n\nclass FormData:\n    \"\"\"\n    Container for parsed form data, supporting both fields and files.\n\n    Provides dict-like access with support for multiple values per key.\n    \"\"\"\n\n    def __init__(self):\n        self._data: List[Tuple[str, Union[str, UploadedFile]]] = []\n\n    def append(self, key: str, value: Union[str, UploadedFile]) -> None:\n        \"\"\"Add a key-value pair.\"\"\"\n        self._data.append((key, value))\n\n    def __getitem__(self, key: str) -> Union[str, UploadedFile]:\n        \"\"\"Get the first value for a key.\"\"\"\n        for k, v in self._data:\n            if k == key:\n                return v\n        raise KeyError(key)\n\n    def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:\n        \"\"\"Get the first value for a key, or default if not found.\"\"\"\n        try:\n            return self[key]\n        except KeyError:\n            return default\n\n    def getlist(self, key: str) -> List[Union[str, UploadedFile]]:\n        \"\"\"Get all values for a key.\"\"\"\n        return [v for k, v in self._data if k == key]\n\n    def __contains__(self, key: str) -> bool:\n        \"\"\"Check if key exists.\"\"\"\n        return any(k == key for k, _ in self._data)\n\n    def __len__(self) -> int:\n        \"\"\"Return number of items.\"\"\"\n        return len(self._data)\n\n    def __iter__(self):\n        \"\"\"Iterate over unique keys.\"\"\"\n        seen = set()\n        for k, _ in self._data:\n            if k not in seen:\n                seen.add(k)\n                yield k\n\n    def keys(self):\n        \"\"\"Return unique keys.\"\"\"\n        return list(self)\n\n    def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:\n        \"\"\"Return all key-value pairs.\"\"\"\n        return list(self._data)\n\n    def values(self) -> List[Union[str, UploadedFile]]:\n        \"\"\"Return all values.\"\"\"\n        return [v for _, v in self._data]\n\n    def _uploaded_files(self) -> List[UploadedFile]:\n        \"\"\"Return UploadedFile instances contained in this form.\"\"\"\n        return [v for _, v in self._data if isinstance(v, UploadedFile)]\n\n    def close(self) -> None:\n        \"\"\"\n        Close any uploaded files.\n\n        This provides deterministic cleanup for spooled temp files.\n        \"\"\"\n        for uploaded in self._uploaded_files():\n            try:\n                uploaded.close_sync()\n            except Exception:\n                # Best-effort cleanup; ignore close errors\n                pass\n\n    async def aclose(self) -> None:\n        \"\"\"Asynchronously close any uploaded files.\"\"\"\n        for uploaded in self._uploaded_files():\n            try:\n                await uploaded.close()\n            except Exception:\n                # Best-effort cleanup; ignore close errors\n                pass\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc, tb):\n        self.close()\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        await self.aclose()\n\n\ndef parse_content_disposition(header: str) -> Dict[str, Optional[str]]:\n    \"\"\"\n    Parse Content-Disposition header value.\n\n    Returns dict with 'name', 'filename' keys (filename may be None).\n    \"\"\"\n    result: Dict[str, Optional[str]] = {\"name\": None, \"filename\": None}\n\n    # Split on semicolons, handling quoted strings\n    parts = []\n    current = \"\"\n    in_quotes = False\n    i = 0\n    while i < len(header):\n        char = header[i]\n        if char == '\"' and (i == 0 or header[i - 1] != \"\\\\\"):\n            in_quotes = not in_quotes\n            current += char\n        elif char == \";\" and not in_quotes:\n            parts.append(current.strip())\n            current = \"\"\n        else:\n            current += char\n        i += 1\n    if current.strip():\n        parts.append(current.strip())\n\n    for part in parts[1:]:  # Skip the \"form-data\" part\n        if \"=\" not in part:\n            continue\n\n        key, _, value = part.partition(\"=\")\n        key = key.strip().lower()\n        value = value.strip()\n\n        # Handle filename* (RFC 5987 encoding)\n        if key == \"filename*\":\n            # Format: utf-8''encoded_filename or charset'language'encoded_filename\n            if \"'\" in value:\n                parts_star = value.split(\"'\", 2)\n                if len(parts_star) >= 3:\n                    # charset = parts_star[0]\n                    # language = parts_star[1]\n                    encoded = parts_star[2]\n                    # URL decode\n                    try:\n                        from urllib.parse import unquote\n\n                        result[\"filename\"] = unquote(encoded, encoding=\"utf-8\")\n                    except Exception:\n                        pass\n            continue\n\n        # Remove quotes if present\n        if value.startswith('\"') and value.endswith('\"'):\n            value = value[1:-1]\n            # Unescape backslash sequences\n            value = value.replace('\\\\\"', '\"').replace(\"\\\\\\\\\", \"\\\\\")\n\n        if key == \"name\":\n            result[\"name\"] = value\n        elif key == \"filename\":\n            # Only set if filename* hasn't already set it\n            if result[\"filename\"] is None:\n                # Strip path components (security)\n                # Handle both Unix and Windows paths\n                value = value.replace(\"\\\\\", \"/\")\n                if \"/\" in value:\n                    value = value.rsplit(\"/\", 1)[-1]\n                result[\"filename\"] = value\n\n    return result\n\n\ndef parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:\n    \"\"\"\n    Parse Content-Type header value.\n\n    Returns (media_type, parameters_dict).\n    \"\"\"\n    parts = header.split(\";\")\n    media_type = parts[0].strip().lower()\n    params = {}\n\n    for part in parts[1:]:\n        part = part.strip()\n        if \"=\" in part:\n            key, _, value = part.partition(\"=\")\n            key = key.strip().lower()\n            value = value.strip()\n            # Remove quotes if present\n            if value.startswith('\"') and value.endswith('\"'):\n                value = value[1:-1]\n            params[key] = value\n\n    return media_type, params\n\n\nclass MultipartParser:\n    \"\"\"\n    Streaming multipart/form-data parser.\n\n    Processes the body chunk by chunk without loading everything into memory.\n    \"\"\"\n\n    # Parser states\n    STATE_PREAMBLE = 0\n    STATE_HEADER = 1\n    STATE_BODY = 2\n    STATE_DONE = 3\n\n    def __init__(\n        self,\n        boundary: bytes,\n        max_file_size: int = DEFAULT_MAX_FILE_SIZE,\n        max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,\n        max_fields: int = DEFAULT_MAX_FIELDS,\n        max_files: int = DEFAULT_MAX_FILES,\n        max_parts: Optional[int] = DEFAULT_MAX_PARTS,\n        max_field_size: int = DEFAULT_MAX_FIELD_SIZE,\n        max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,\n        max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,\n        max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,\n        min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,\n        handle_files: bool = False,\n    ):\n        self.boundary = b\"--\" + boundary\n        self.end_boundary = self.boundary + b\"--\"\n        self.max_file_size = max_file_size\n        self.max_request_size = max_request_size\n        self.max_fields = max_fields\n        self.max_files = max_files\n        # If not specified, tie max_parts to the other cardinality limits\n        if max_parts is None:\n            max_parts = max_fields + max_files\n        self.max_parts = max_parts\n        self.max_field_size = max_field_size\n        self.max_memory_file_size = max_memory_file_size\n        self.max_part_header_bytes = max_part_header_bytes\n        self.max_part_header_lines = max_part_header_lines\n        self.min_free_disk_bytes = min_free_disk_bytes\n        self.handle_files = handle_files\n\n        self.state = self.STATE_PREAMBLE\n        self.buffer = bytearray()\n        self.total_bytes = 0\n        self.field_count = 0\n        self.file_count = 0\n        self.part_count = 0\n        self.current_part_size = 0\n        self.current_header_bytes = 0\n        self.current_header_lines = 0\n\n        self.form_data = FormData()\n        self._disk_check_interval_bytes = 1024 * 1024  # 1MB between disk checks\n        self._bytes_since_disk_check = 0\n        self._tempdir = tempfile.gettempdir()\n\n        # Current part state\n        self.current_headers: Dict[str, str] = {}\n        self.current_file: Optional[tempfile.SpooledTemporaryFile] = None\n        self.current_body = bytearray()\n        self.current_name: Optional[str] = None\n        self.current_filename: Optional[str] = None\n        self.current_content_type: Optional[str] = None\n\n    def feed(self, chunk: bytes) -> None:\n        \"\"\"Feed a chunk of data to the parser.\"\"\"\n        self.total_bytes += len(chunk)\n        if self.total_bytes > self.max_request_size:\n            raise MultipartParseError(\"Request body too large\")\n\n        self.buffer.extend(chunk)\n        self._process()\n\n    def _process(self) -> None:\n        \"\"\"Process buffered data.\"\"\"\n        while True:\n            if self.state == self.STATE_PREAMBLE:\n                if not self._process_preamble():\n                    break\n            elif self.state == self.STATE_HEADER:\n                if not self._process_header():\n                    break\n            elif self.state == self.STATE_BODY:\n                if not self._process_body():\n                    break\n            elif self.state == self.STATE_DONE:\n                break\n\n    def _process_preamble(self) -> bool:\n        \"\"\"Skip preamble and find first boundary.\"\"\"\n        # Look for boundary (could be at start or after preamble)\n        # Try both \\r\\n prefixed and bare boundary at start\n        idx = self.buffer.find(self.boundary)\n        if idx == -1:\n            # Keep potential partial boundary at end\n            keep = len(self.boundary) - 1\n            if len(self.buffer) > keep:\n                self.buffer = self.buffer[-keep:]\n            return False\n\n        # Found boundary, skip to after it\n        after_boundary = idx + len(self.boundary)\n\n        # Check for end boundary\n        if self.buffer[idx : idx + len(self.end_boundary)] == self.end_boundary:\n            self.state = self.STATE_DONE\n            return False\n\n        # Skip CRLF or LF after boundary\n        if after_boundary < len(self.buffer):\n            if self.buffer[after_boundary : after_boundary + 2] == b\"\\r\\n\":\n                after_boundary += 2\n            elif self.buffer[after_boundary : after_boundary + 1] == b\"\\n\":\n                after_boundary += 1\n\n        self.buffer = self.buffer[after_boundary:]\n        self.state = self.STATE_HEADER\n        self.current_headers = {}\n        self.current_header_bytes = 0\n        self.current_header_lines = 0\n        return True\n\n    def _process_header(self) -> bool:\n        \"\"\"Parse part headers.\"\"\"\n        while True:\n            # Look for end of header line\n            crlf_idx = self.buffer.find(b\"\\r\\n\")\n            lf_idx = self.buffer.find(b\"\\n\")\n\n            if crlf_idx == -1 and lf_idx == -1:\n                # Guard against unbounded header buffering if no newline is ever sent\n                if len(self.buffer) > self.max_part_header_bytes:\n                    raise MultipartParseError(\"Part headers too large\")\n                return False  # Need more data\n\n            # Use whichever comes first\n            if crlf_idx != -1 and (lf_idx == -1 or crlf_idx < lf_idx):\n                idx = crlf_idx\n                line_end_len = 2\n            else:\n                idx = lf_idx\n                line_end_len = 1\n\n            line = self.buffer[:idx]\n            self.buffer = self.buffer[idx + line_end_len :]\n\n            self.current_header_lines += 1\n            self.current_header_bytes += idx + line_end_len\n            if (\n                self.current_header_lines > self.max_part_header_lines\n                or self.current_header_bytes > self.max_part_header_bytes\n            ):\n                raise MultipartParseError(\"Part headers too large\")\n\n            if not line:\n                # Empty line = end of headers\n                self._start_body()\n                self.state = self.STATE_BODY\n                return True\n\n            # Parse header\n            try:\n                line_str = line.decode(\"utf-8\", errors=\"replace\")\n            except Exception:\n                line_str = line.decode(\"latin-1\")\n\n            if \":\" in line_str:\n                name, _, value = line_str.partition(\":\")\n                self.current_headers[name.strip().lower()] = value.strip()\n\n    def _start_body(self) -> None:\n        \"\"\"Initialize body parsing for current part.\"\"\"\n        self.part_count += 1\n        if self.part_count > self.max_parts:\n            raise MultipartParseError(\"Too many parts\")\n\n        # Parse Content-Disposition\n        cd = self.current_headers.get(\"content-disposition\", \"\")\n        parsed = parse_content_disposition(cd)\n        self.current_name = parsed.get(\"name\")\n        self.current_filename = parsed.get(\"filename\")\n        self.current_content_type = self.current_headers.get(\"content-type\")\n        self.current_part_size = 0\n\n        if self.current_filename is not None:\n            # It's a file\n            self.file_count += 1\n            if self.file_count > self.max_files:\n                raise MultipartParseError(\"Too many files\")\n            if self.handle_files:\n                self.current_file = tempfile.SpooledTemporaryFile(\n                    max_size=self.max_memory_file_size\n                )\n            else:\n                # Will discard file content\n                self.current_file = None\n        else:\n            # It's a text field\n            self.field_count += 1\n            if self.field_count > self.max_fields:\n                raise MultipartParseError(\"Too many fields\")\n            self.current_body = bytearray()\n            self.current_file = None\n\n        # Check disk space before allocating a spooled temp file\n        if self.current_filename is not None and self.handle_files:\n            self._ensure_disk_space()\n\n    def _process_body(self) -> bool:\n        \"\"\"Process body data for current part.\"\"\"\n        # Look for boundary in buffer\n        # Need to handle boundary potentially split across chunks\n\n        # The boundary is preceded by \\r\\n (or \\n for lenient parsing)\n        search_boundary = b\"\\r\\n\" + self.boundary\n\n        idx = self.buffer.find(search_boundary)\n        if idx == -1:\n            # Try LF-only boundary (lenient)\n            search_boundary_lf = b\"\\n\" + self.boundary\n            idx = self.buffer.find(search_boundary_lf)\n            if idx != -1:\n                search_boundary = search_boundary_lf\n\n        if idx == -1:\n            # No boundary found yet\n            # Keep potential partial boundary at end of buffer\n            safe_len = len(self.buffer) - len(search_boundary) - 1\n            if safe_len > 0:\n                safe_data = self.buffer[:safe_len]\n                self._write_body_data(bytes(safe_data))\n                self.buffer = self.buffer[safe_len:]\n            return False\n\n        # Found boundary - write remaining body data\n        body_data = self.buffer[:idx]\n        self._write_body_data(bytes(body_data))\n\n        # Move past the boundary\n        after_boundary = idx + len(search_boundary)\n\n        # Check for end boundary\n        remaining = self.buffer[after_boundary:]\n        if remaining.startswith(b\"--\"):\n            # End boundary\n            self._finish_part()\n            self.state = self.STATE_DONE\n            return False\n\n        # Skip CRLF or LF after boundary\n        if remaining.startswith(b\"\\r\\n\"):\n            after_boundary += 2\n        elif remaining.startswith(b\"\\n\"):\n            after_boundary += 1\n\n        self.buffer = self.buffer[after_boundary:]\n        self._finish_part()\n        self.state = self.STATE_HEADER\n        self.current_headers = {}\n        self.current_header_bytes = 0\n        self.current_header_lines = 0\n        return True\n\n    def _write_body_data(self, data: bytes) -> None:\n        \"\"\"Write data to current part body.\"\"\"\n        if not data:\n            return\n\n        self.current_part_size += len(data)\n\n        if self.current_filename is not None:\n            # File data\n            if self.current_part_size > self.max_file_size:\n                raise MultipartParseError(\"File too large\")\n            if self.handle_files and self.current_file:\n                self._bytes_since_disk_check += len(data)\n                if self._bytes_since_disk_check >= self._disk_check_interval_bytes:\n                    self._ensure_disk_space()\n                    self._bytes_since_disk_check = 0\n                self.current_file.write(data)\n            # else: discard file data\n        else:\n            # Field data\n            if self.current_part_size > self.max_field_size:\n                raise MultipartParseError(\"Field value too large\")\n            self.current_body.extend(data)\n\n    def _finish_part(self) -> None:\n        \"\"\"Finalize current part and add to form data.\"\"\"\n        if self.current_name is None:\n            return\n\n        if self.current_filename is not None:\n            # File\n            if self.handle_files and self.current_file:\n                self.current_file.seek(0)\n                uploaded = UploadedFile(\n                    name=self.current_name,\n                    filename=self.current_filename,\n                    content_type=self.current_content_type,\n                    size=self.current_part_size,\n                    _file=self.current_file,\n                )\n                self.form_data.append(self.current_name, uploaded)\n            # else: file was discarded\n        else:\n            # Text field\n            try:\n                value = bytes(self.current_body).decode(\"utf-8\")\n            except UnicodeDecodeError:\n                value = bytes(self.current_body).decode(\"latin-1\")\n            self.form_data.append(self.current_name, value)\n\n        # Reset part state\n        self.current_file = None\n        self.current_body = bytearray()\n        self.current_name = None\n        self.current_filename = None\n        self.current_content_type = None\n\n    def finalize(self) -> FormData:\n        \"\"\"Finalize parsing and return form data.\"\"\"\n        # Process any remaining data\n        self._process()\n        if self.state != self.STATE_DONE:\n            raise MultipartParseError(\n                \"Truncated multipart body (missing closing boundary)\"\n            )\n        return self.form_data\n\n    def _ensure_disk_space(self) -> None:\n        \"\"\"\n        Ensure there is enough free space on the temp filesystem.\n\n        This is a best-effort guard against filling the disk with uploads.\n        \"\"\"\n        if not self.handle_files:\n            return\n        if self.min_free_disk_bytes <= 0:\n            return\n        free_bytes = shutil.disk_usage(self._tempdir).free\n        if free_bytes < self.min_free_disk_bytes:\n            raise MultipartParseError(\"Insufficient disk space for uploads\")\n\n\nasync def parse_form_data(\n    receive: Callable,\n    content_type: str,\n    files: bool = False,\n    max_file_size: int = DEFAULT_MAX_FILE_SIZE,\n    max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,\n    max_fields: int = DEFAULT_MAX_FIELDS,\n    max_files: int = DEFAULT_MAX_FILES,\n    max_parts: Optional[int] = DEFAULT_MAX_PARTS,\n    max_field_size: int = DEFAULT_MAX_FIELD_SIZE,\n    max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,\n    max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,\n    max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,\n    min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,\n) -> FormData:\n    \"\"\"\n    Parse form data from an ASGI receive callable.\n\n    Supports both application/x-www-form-urlencoded and multipart/form-data.\n\n    Args:\n        receive: ASGI receive callable\n        content_type: Content-Type header value\n        files: If True, store file uploads; if False, discard them\n        max_file_size: Maximum size per file in bytes\n        max_request_size: Maximum total request size in bytes\n        max_fields: Maximum number of form fields\n        max_files: Maximum number of file uploads\n        max_field_size: Maximum size of a text field value\n        max_memory_file_size: File size threshold before spilling to disk\n\n    Returns:\n        FormData object containing parsed fields and files\n    \"\"\"\n    media_type, params = parse_content_type(content_type)\n\n    if media_type == \"application/x-www-form-urlencoded\":\n        # Read entire body for URL-encoded forms (they're typically small)\n        body = bytearray()\n        total = 0\n        while True:\n            message = await receive()\n            message_type = message.get(\"type\")\n            if message_type == \"http.disconnect\":\n                raise MultipartParseError(\"Client disconnected during request body\")\n            if message_type is not None and message_type != \"http.request\":\n                continue\n            chunk = message.get(\"body\", b\"\")\n            total += len(chunk)\n            if total > max_request_size:\n                raise MultipartParseError(\"Request body too large\")\n            body.extend(chunk)\n            if not message.get(\"more_body\", False):\n                break\n\n        form_data = FormData()\n        try:\n            pairs = parse_qsl(bytes(body).decode(\"utf-8\"), keep_blank_values=True)\n        except UnicodeDecodeError:\n            pairs = parse_qsl(bytes(body).decode(\"latin-1\"), keep_blank_values=True)\n\n        for key, value in pairs:\n            form_data.append(key, value)\n\n        return form_data\n\n    elif media_type == \"multipart/form-data\":\n        boundary = params.get(\"boundary\")\n        if not boundary:\n            raise MultipartParseError(\"Missing boundary in Content-Type\")\n\n        parser = MultipartParser(\n            boundary=boundary.encode(\"utf-8\"),\n            max_file_size=max_file_size,\n            max_request_size=max_request_size,\n            max_fields=max_fields,\n            max_files=max_files,\n            max_parts=max_parts,\n            max_field_size=max_field_size,\n            max_memory_file_size=max_memory_file_size,\n            max_part_header_bytes=max_part_header_bytes,\n            max_part_header_lines=max_part_header_lines,\n            min_free_disk_bytes=min_free_disk_bytes,\n            handle_files=files,\n        )\n\n        # Stream body through parser\n        batch_target = 64 * 1024\n        batch = bytearray()\n\n        async def flush_batch() -> None:\n            if batch:\n                data = bytes(batch)\n                batch.clear()\n                await asyncio.to_thread(parser.feed, data)\n\n        while True:\n            message = await receive()\n            message_type = message.get(\"type\")\n            if message_type == \"http.disconnect\":\n                raise MultipartParseError(\"Client disconnected during request body\")\n            if message_type is not None and message_type != \"http.request\":\n                continue\n            chunk = message.get(\"body\", b\"\")\n            if chunk:\n                batch.extend(chunk)\n                if len(batch) >= batch_target:\n                    await flush_batch()\n            if not message.get(\"more_body\", False):\n                break\n\n        await flush_batch()\n        return await asyncio.to_thread(parser.finalize)\n\n    else:\n        raise MultipartParseError(\n            f\"Unsupported Content-Type: {media_type}. \"\n            \"Expected application/x-www-form-urlencoded or multipart/form-data\"\n        )\n"
  },
  {
    "path": "datasette/utils/permissions.py",
    "content": "# perm_utils.py\nfrom __future__ import annotations\n\nimport json\nfrom typing import Any, Dict, Iterable, List, Sequence, Tuple\nimport sqlite3\n\nfrom datasette.permissions import PermissionSQL\nfrom datasette.plugins import pm\nfrom datasette.utils import await_me_maybe\n\n# Sentinel object to indicate permission checks should be skipped\nSKIP_PERMISSION_CHECKS = object()\n\n\nasync def gather_permission_sql_from_hooks(\n    *, datasette, actor: dict | None, action: str\n) -> List[PermissionSQL] | object:\n    \"\"\"Collect PermissionSQL objects from the permission_resources_sql hook.\n\n    Ensures that each returned PermissionSQL has a populated ``source``.\n\n    Returns SKIP_PERMISSION_CHECKS sentinel if skip_permission_checks context variable\n    is set, signaling that all permission checks should be bypassed.\n    \"\"\"\n    from datasette.permissions import _skip_permission_checks\n\n    # Check if we should skip permission checks BEFORE calling hooks\n    # This avoids creating unawaited coroutines\n    if _skip_permission_checks.get():\n        return SKIP_PERMISSION_CHECKS\n\n    hook_caller = pm.hook.permission_resources_sql\n    hookimpls = hook_caller.get_hookimpls()\n    hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))\n\n    collected: List[PermissionSQL] = []\n    actor_json = json.dumps(actor) if actor is not None else None\n    actor_id = actor.get(\"id\") if isinstance(actor, dict) else None\n\n    for index, result in enumerate(hook_results):\n        hookimpl = hookimpls[index]\n        resolved = await await_me_maybe(result)\n        default_source = _plugin_name_from_hookimpl(hookimpl)\n        for permission_sql in _iter_permission_sql_from_result(resolved, action=action):\n            if not permission_sql.source:\n                permission_sql.source = default_source\n            params = permission_sql.params or {}\n            params.setdefault(\"action\", action)\n            params.setdefault(\"actor\", actor_json)\n            params.setdefault(\"actor_id\", actor_id)\n            collected.append(permission_sql)\n\n    return collected\n\n\ndef _plugin_name_from_hookimpl(hookimpl) -> str:\n    if getattr(hookimpl, \"plugin_name\", None):\n        return hookimpl.plugin_name\n    plugin = getattr(hookimpl, \"plugin\", None)\n    if hasattr(plugin, \"__name__\"):\n        return plugin.__name__\n    return repr(plugin)\n\n\ndef _iter_permission_sql_from_result(\n    result: Any, *, action: str\n) -> Iterable[PermissionSQL]:\n    if result is None:\n        return []\n    if isinstance(result, PermissionSQL):\n        return [result]\n    if isinstance(result, (list, tuple)):\n        collected: List[PermissionSQL] = []\n        for item in result:\n            collected.extend(_iter_permission_sql_from_result(item, action=action))\n        return collected\n    if callable(result):\n        permission_sql = result(action)  # type: ignore[call-arg]\n        return _iter_permission_sql_from_result(permission_sql, action=action)\n    raise TypeError(\n        \"Plugin providers must return PermissionSQL instances, sequences, or callables\"\n    )\n\n\n# -----------------------------\n# Plugin interface & utilities\n# -----------------------------\n\n\ndef build_rules_union(\n    actor: dict | None, plugins: Sequence[PermissionSQL]\n) -> Tuple[str, Dict[str, Any]]:\n    \"\"\"\n    Compose plugin SQL into a UNION ALL.\n\n    Returns:\n      union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin)\n      params:    dict of bound parameters including :actor (JSON), :actor_id, and plugin params\n\n    Note: Plugins are responsible for ensuring their parameter names don't conflict.\n    The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent\n    Plugin parameters should be prefixed with a unique identifier (e.g., source name).\n    \"\"\"\n    parts: List[str] = []\n    actor_json = json.dumps(actor) if actor else None\n    actor_id = actor.get(\"id\") if actor else None\n    params: Dict[str, Any] = {\"actor\": actor_json, \"actor_id\": actor_id}\n\n    for p in plugins:\n        # No namespacing - just use plugin params as-is\n        params.update(p.params or {})\n\n        # Skip plugins that only provide restriction_sql (no permission rules)\n        if p.sql is None:\n            continue\n\n        parts.append(f\"\"\"\n            SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM (\n                {p.sql}\n            )\n            \"\"\".strip())\n\n    if not parts:\n        # Empty UNION that returns no rows\n        union_sql = \"SELECT NULL parent, NULL child, NULL allow, NULL reason, 'none' source_plugin WHERE 0\"\n    else:\n        union_sql = \"\\nUNION ALL\\n\".join(parts)\n\n    return union_sql, params\n\n\n# -----------------------------------------------\n# Core resolvers (no temp tables, no custom UDFs)\n# -----------------------------------------------\n\n\nasync def resolve_permissions_from_catalog(\n    db,\n    actor: dict | None,\n    plugins: Sequence[Any],\n    action: str,\n    candidate_sql: str,\n    candidate_params: Dict[str, Any] | None = None,\n    *,\n    implicit_deny: bool = True,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Resolve permissions by embedding the provided *candidate_sql* in a CTE.\n\n    Expectations:\n      - candidate_sql SELECTs: parent TEXT, child TEXT\n        (Use child=NULL for parent-scoped actions like \"execute-sql\".)\n      - *db* exposes: rows = await db.execute(sql, params)\n        where rows is an iterable of sqlite3.Row\n      - plugins: hook results handled by await_me_maybe - can be sync/async,\n        single PermissionSQL, list, or callable returning PermissionSQL\n      - actor is the actor dict (or None), made available as :actor (JSON), :actor_id, and :action\n\n    Decision policy:\n      1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0)\n      2) Within the same depth: deny (0) beats allow (1)\n      3) If no matching rule:\n         - implicit_deny=True  -> treat as allow=0, reason='implicit deny'\n         - implicit_deny=False -> allow=None, reason=None\n\n    Returns: list of dict rows\n      - parent, child, allow, reason, source_plugin, depth\n      - resource (rendered \"/parent/child\" or \"/parent\" or \"/\")\n    \"\"\"\n    resolved_plugins: List[PermissionSQL] = []\n    restriction_sqls: List[str] = []\n\n    for plugin in plugins:\n        if callable(plugin) and not isinstance(plugin, PermissionSQL):\n            resolved = plugin(action)  # type: ignore[arg-type]\n        else:\n            resolved = plugin  # type: ignore[assignment]\n        if not isinstance(resolved, PermissionSQL):\n            raise TypeError(\"Plugin providers must return PermissionSQL instances\")\n        resolved_plugins.append(resolved)\n\n        # Collect restriction SQL filters\n        if resolved.restriction_sql:\n            restriction_sqls.append(resolved.restriction_sql)\n\n    union_sql, rule_params = build_rules_union(actor, resolved_plugins)\n    all_params = {\n        **(candidate_params or {}),\n        **rule_params,\n        \"action\": action,\n    }\n\n    sql = f\"\"\"\n    WITH\n    cands AS (\n        {candidate_sql}\n    ),\n    rules AS (\n        {union_sql}\n    ),\n    matched AS (\n        SELECT\n            c.parent, c.child,\n            r.allow, r.reason, r.source_plugin,\n            CASE\n              WHEN r.child  IS NOT NULL THEN 2  -- child-level (most specific)\n              WHEN r.parent IS NOT NULL THEN 1  -- parent-level\n              ELSE 0                            -- root/global\n            END AS depth\n        FROM cands c\n        JOIN rules r\n          ON (r.parent IS NULL OR r.parent = c.parent)\n         AND (r.child  IS NULL OR r.child  = c.child)\n    ),\n    ranked AS (\n        SELECT *,\n               ROW_NUMBER() OVER (\n                 PARTITION BY parent, child\n                 ORDER BY\n                   depth DESC,                          -- specificity first\n                   CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth\n                   source_plugin                        -- stable tie-break\n               ) AS rn\n        FROM matched\n    ),\n    winner AS (\n        SELECT parent, child,\n               allow, reason, source_plugin, depth\n        FROM ranked WHERE rn = 1\n    )\n    SELECT\n      c.parent, c.child,\n      COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,\n      COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,\n      w.source_plugin,\n      COALESCE(w.depth, -1) AS depth,\n      :action AS action,\n      CASE\n        WHEN c.parent IS NULL THEN '/'\n        WHEN c.child  IS NULL THEN '/' || c.parent\n        ELSE '/' || c.parent || '/' || c.child\n      END AS resource\n    FROM cands c\n    LEFT JOIN winner w\n      ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))\n     AND ((w.child  = c.child ) OR (w.child  IS NULL AND c.child  IS NULL))\n    ORDER BY c.parent, c.child\n    \"\"\"\n\n    # If there are restriction filters, wrap the query with INTERSECT\n    # This ensures only resources in the restriction allowlist are returned\n    if restriction_sqls:\n        # Start with the main query, but select only parent/child for the INTERSECT\n        main_query_for_intersect = f\"\"\"\n        WITH\n        cands AS (\n            {candidate_sql}\n        ),\n        rules AS (\n            {union_sql}\n        ),\n        matched AS (\n            SELECT\n                c.parent, c.child,\n                r.allow, r.reason, r.source_plugin,\n                CASE\n                  WHEN r.child  IS NOT NULL THEN 2  -- child-level (most specific)\n                  WHEN r.parent IS NOT NULL THEN 1  -- parent-level\n                  ELSE 0                            -- root/global\n                END AS depth\n            FROM cands c\n            JOIN rules r\n              ON (r.parent IS NULL OR r.parent = c.parent)\n             AND (r.child  IS NULL OR r.child  = c.child)\n        ),\n        ranked AS (\n            SELECT *,\n                   ROW_NUMBER() OVER (\n                     PARTITION BY parent, child\n                     ORDER BY\n                       depth DESC,                          -- specificity first\n                       CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth\n                       source_plugin                        -- stable tie-break\n                   ) AS rn\n            FROM matched\n        ),\n        winner AS (\n            SELECT parent, child,\n                   allow, reason, source_plugin, depth\n            FROM ranked WHERE rn = 1\n        ),\n        permitted_resources AS (\n            SELECT c.parent, c.child\n            FROM cands c\n            LEFT JOIN winner w\n              ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))\n             AND ((w.child  = c.child ) OR (w.child  IS NULL AND c.child  IS NULL))\n            WHERE COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) = 1\n        )\n        SELECT parent, child FROM permitted_resources\n        \"\"\"\n\n        # Build restriction list with INTERSECT (all must match)\n        # Then filter to resources that match hierarchically\n        # Wrap each restriction_sql in a subquery to avoid operator precedence issues\n        # with UNION ALL inside the restriction SQL statements\n        restriction_intersect = \"\\nINTERSECT\\n\".join(\n            f\"SELECT * FROM ({sql})\" for sql in restriction_sqls\n        )\n\n        # Combine: resources allowed by permissions AND in restriction allowlist\n        # Database-level restrictions (parent, NULL) should match all children (parent, *)\n        filtered_resources = f\"\"\"\n        WITH restriction_list AS (\n            {restriction_intersect}\n        ),\n        permitted AS (\n            {main_query_for_intersect}\n        ),\n        filtered AS (\n            SELECT p.parent, p.child\n            FROM permitted p\n            WHERE EXISTS (\n                SELECT 1 FROM restriction_list r\n                WHERE (r.parent = p.parent OR r.parent IS NULL)\n                  AND (r.child = p.child OR r.child IS NULL)\n            )\n        )\n        \"\"\"\n\n        # Now join back to get full results for only the filtered resources\n        sql = f\"\"\"\n        {filtered_resources}\n        , cands AS (\n            {candidate_sql}\n        ),\n        rules AS (\n            {union_sql}\n        ),\n        matched AS (\n            SELECT\n                c.parent, c.child,\n                r.allow, r.reason, r.source_plugin,\n                CASE\n                  WHEN r.child  IS NOT NULL THEN 2  -- child-level (most specific)\n                  WHEN r.parent IS NOT NULL THEN 1  -- parent-level\n                  ELSE 0                            -- root/global\n                END AS depth\n            FROM cands c\n            JOIN rules r\n              ON (r.parent IS NULL OR r.parent = c.parent)\n             AND (r.child  IS NULL OR r.child  = c.child)\n        ),\n        ranked AS (\n            SELECT *,\n                   ROW_NUMBER() OVER (\n                     PARTITION BY parent, child\n                     ORDER BY\n                       depth DESC,                          -- specificity first\n                       CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth\n                       source_plugin                        -- stable tie-break\n                   ) AS rn\n            FROM matched\n        ),\n        winner AS (\n            SELECT parent, child,\n                   allow, reason, source_plugin, depth\n            FROM ranked WHERE rn = 1\n        )\n        SELECT\n          c.parent, c.child,\n          COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,\n          COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,\n          w.source_plugin,\n          COALESCE(w.depth, -1) AS depth,\n          :action AS action,\n          CASE\n            WHEN c.parent IS NULL THEN '/'\n            WHEN c.child  IS NULL THEN '/' || c.parent\n            ELSE '/' || c.parent || '/' || c.child\n          END AS resource\n        FROM filtered c\n        LEFT JOIN winner w\n          ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))\n         AND ((w.child  = c.child ) OR (w.child  IS NULL AND c.child  IS NULL))\n        ORDER BY c.parent, c.child\n        \"\"\"\n\n    rows_iter: Iterable[sqlite3.Row] = await db.execute(\n        sql,\n        {**all_params, \"implicit_deny\": 1 if implicit_deny else 0},\n    )\n    return [dict(r) for r in rows_iter]\n\n\nasync def resolve_permissions_with_candidates(\n    db,\n    actor: dict | None,\n    plugins: Sequence[Any],\n    candidates: List[Tuple[str, str | None]],\n    action: str,\n    *,\n    implicit_deny: bool = True,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Resolve permissions without any external candidate table by embedding\n    the candidates as a UNION of parameterized SELECTs in a CTE.\n\n    candidates: list of (parent, child) where child can be None for parent-scoped actions.\n    actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action\n    \"\"\"\n    # Build a small CTE for candidates.\n    cand_rows_sql: List[str] = []\n    cand_params: Dict[str, Any] = {}\n    for i, (parent, child) in enumerate(candidates):\n        pkey = f\"cand_p_{i}\"\n        ckey = f\"cand_c_{i}\"\n        cand_params[pkey] = parent\n        cand_params[ckey] = child\n        cand_rows_sql.append(f\"SELECT :{pkey} AS parent, :{ckey} AS child\")\n    candidate_sql = (\n        \"\\nUNION ALL\\n\".join(cand_rows_sql)\n        if cand_rows_sql\n        else \"SELECT NULL AS parent, NULL AS child WHERE 0\"\n    )\n\n    return await resolve_permissions_from_catalog(\n        db,\n        actor,\n        plugins,\n        action,\n        candidate_sql=candidate_sql,\n        candidate_params=cand_params,\n        implicit_deny=implicit_deny,\n    )\n"
  },
  {
    "path": "datasette/utils/shutil_backport.py",
    "content": "\"\"\"\nBackported from Python 3.8.\n\nThis code is licensed under the Python License:\nhttps://github.com/python/cpython/blob/v3.8.3/LICENSE\n\"\"\"\n\nimport os\nfrom shutil import copy, copy2, copystat, Error\n\n\ndef _copytree(\n    entries,\n    src,\n    dst,\n    symlinks,\n    ignore,\n    copy_function,\n    ignore_dangling_symlinks,\n    dirs_exist_ok=False,\n):\n    if ignore is not None:\n        ignored_names = ignore(src, set(os.listdir(src)))\n    else:\n        ignored_names = set()\n\n    os.makedirs(dst, exist_ok=dirs_exist_ok)\n    errors = []\n    use_srcentry = copy_function is copy2 or copy_function is copy\n\n    for srcentry in entries:\n        if srcentry.name in ignored_names:\n            continue\n        srcname = os.path.join(src, srcentry.name)\n        dstname = os.path.join(dst, srcentry.name)\n        srcobj = srcentry if use_srcentry else srcname\n        try:\n            if srcentry.is_symlink():\n                linkto = os.readlink(srcname)\n                if symlinks:\n                    os.symlink(linkto, dstname)\n                    copystat(srcobj, dstname, follow_symlinks=not symlinks)\n                else:\n                    if not os.path.exists(linkto) and ignore_dangling_symlinks:\n                        continue\n                    if srcentry.is_dir():\n                        copytree(\n                            srcobj,\n                            dstname,\n                            symlinks,\n                            ignore,\n                            copy_function,\n                            dirs_exist_ok=dirs_exist_ok,\n                        )\n                    else:\n                        copy_function(srcobj, dstname)\n            elif srcentry.is_dir():\n                copytree(\n                    srcobj,\n                    dstname,\n                    symlinks,\n                    ignore,\n                    copy_function,\n                    dirs_exist_ok=dirs_exist_ok,\n                )\n            else:\n                copy_function(srcentry, dstname)\n        except Error as err:\n            errors.extend(err.args[0])\n        except OSError as why:\n            errors.append((srcname, dstname, str(why)))\n    try:\n        copystat(src, dst)\n    except OSError as why:\n        # Copying file access times may fail on Windows\n        if getattr(why, \"winerror\", None) is None:\n            errors.append((src, dst, str(why)))\n    if errors:\n        raise Error(errors)\n    return dst\n\n\ndef copytree(\n    src,\n    dst,\n    symlinks=False,\n    ignore=None,\n    copy_function=copy2,\n    ignore_dangling_symlinks=False,\n    dirs_exist_ok=False,\n):\n    with os.scandir(src) as entries:\n        return _copytree(\n            entries=entries,\n            src=src,\n            dst=dst,\n            symlinks=symlinks,\n            ignore=ignore,\n            copy_function=copy_function,\n            ignore_dangling_symlinks=ignore_dangling_symlinks,\n            dirs_exist_ok=dirs_exist_ok,\n        )\n"
  },
  {
    "path": "datasette/utils/sqlite.py",
    "content": "using_pysqlite3 = False\ntry:\n    import pysqlite3 as sqlite3\n\n    using_pysqlite3 = True\nexcept ImportError:\n    import sqlite3\n\nif hasattr(sqlite3, \"enable_callback_tracebacks\"):\n    sqlite3.enable_callback_tracebacks(True)\n\n_cached_sqlite_version = None\n\n\ndef sqlite_version():\n    global _cached_sqlite_version\n    if _cached_sqlite_version is None:\n        _cached_sqlite_version = _sqlite_version()\n    return _cached_sqlite_version\n\n\ndef _sqlite_version():\n    return tuple(\n        map(\n            int,\n            sqlite3.connect(\":memory:\")\n            .execute(\"select sqlite_version()\")\n            .fetchone()[0]\n            .split(\".\"),\n        )\n    )\n\n\ndef supports_table_xinfo():\n    return sqlite_version() >= (3, 26, 0)\n\n\ndef supports_generated_columns():\n    return sqlite_version() >= (3, 31, 0)\n"
  },
  {
    "path": "datasette/utils/testing.py",
    "content": "from asgiref.sync import async_to_sync\nfrom urllib.parse import urlencode\nimport json\n\n# These wrapper classes pre-date the introduction of\n# datasette.client and httpx to Datasette. They could\n# be removed if the Datasette tests are modified to\n# call datasette.client directly.\n\n\nclass TestResponse:\n    def __init__(self, httpx_response):\n        self.httpx_response = httpx_response\n\n    @property\n    def status(self):\n        return self.httpx_response.status_code\n\n    # Supports both for test-writing convenience\n    @property\n    def status_code(self):\n        return self.status\n\n    @property\n    def headers(self):\n        return self.httpx_response.headers\n\n    @property\n    def body(self):\n        return self.httpx_response.content\n\n    @property\n    def content(self):\n        return self.body\n\n    @property\n    def cookies(self):\n        return dict(self.httpx_response.cookies)\n\n    @property\n    def json(self):\n        return json.loads(self.text)\n\n    @property\n    def text(self):\n        return self.body.decode(\"utf8\")\n\n\nclass TestClient:\n    max_redirects = 5\n\n    def __init__(self, ds):\n        self.ds = ds\n\n    def actor_cookie(self, actor):\n        return self.ds.sign({\"a\": actor}, \"actor\")\n\n    @async_to_sync\n    async def get(\n        self,\n        path,\n        follow_redirects=False,\n        redirect_count=0,\n        method=\"GET\",\n        params=None,\n        cookies=None,\n        if_none_match=None,\n        headers=None,\n    ):\n        if params:\n            path += \"?\" + urlencode(params, doseq=True)\n        return await self._request(\n            path=path,\n            follow_redirects=follow_redirects,\n            redirect_count=redirect_count,\n            method=method,\n            cookies=cookies,\n            if_none_match=if_none_match,\n            headers=headers,\n        )\n\n    @async_to_sync\n    async def post(\n        self,\n        path,\n        post_data=None,\n        body=None,\n        follow_redirects=False,\n        redirect_count=0,\n        content_type=\"application/x-www-form-urlencoded\",\n        cookies=None,\n        headers=None,\n        csrftoken_from=None,\n    ):\n        cookies = cookies or {}\n        post_data = post_data or {}\n        assert not (post_data and body), \"Provide one or other of body= or post_data=\"\n        # Maybe fetch a csrftoken first\n        if csrftoken_from is not None:\n            assert body is None, \"body= is not compatible with csrftoken_from=\"\n            if csrftoken_from is True:\n                csrftoken_from = path\n            token_response = await self._request(csrftoken_from, cookies=cookies)\n            csrftoken = token_response.cookies[\"ds_csrftoken\"]\n            cookies[\"ds_csrftoken\"] = csrftoken\n            post_data[\"csrftoken\"] = csrftoken\n        if post_data:\n            body = urlencode(post_data, doseq=True)\n        return await self._request(\n            path=path,\n            follow_redirects=follow_redirects,\n            redirect_count=redirect_count,\n            method=\"POST\",\n            cookies=cookies,\n            headers=headers,\n            post_body=body,\n            content_type=content_type,\n        )\n\n    @async_to_sync\n    async def request(\n        self,\n        path,\n        follow_redirects=True,\n        redirect_count=0,\n        method=\"GET\",\n        cookies=None,\n        headers=None,\n        post_body=None,\n        content_type=None,\n        if_none_match=None,\n    ):\n        return await self._request(\n            path,\n            follow_redirects=follow_redirects,\n            redirect_count=redirect_count,\n            method=method,\n            cookies=cookies,\n            headers=headers,\n            post_body=post_body,\n            content_type=content_type,\n            if_none_match=if_none_match,\n        )\n\n    async def _request(\n        self,\n        path,\n        follow_redirects=True,\n        redirect_count=0,\n        method=\"GET\",\n        cookies=None,\n        headers=None,\n        post_body=None,\n        content_type=None,\n        if_none_match=None,\n    ):\n        await self.ds.invoke_startup()\n        headers = headers or {}\n        if content_type:\n            headers[\"content-type\"] = content_type\n        if if_none_match:\n            headers[\"if-none-match\"] = if_none_match\n        httpx_response = await self.ds.client.request(\n            method,\n            path,\n            follow_redirects=follow_redirects,\n            avoid_path_rewrites=True,\n            cookies=cookies,\n            headers=headers,\n            content=post_body,\n        )\n        response = TestResponse(httpx_response)\n        if follow_redirects and response.status in (301, 302):\n            assert (\n                redirect_count < self.max_redirects\n            ), f\"Redirected {redirect_count} times, max_redirects={self.max_redirects}\"\n            location = response.headers[\"Location\"]\n            return await self._request(\n                location, follow_redirects=True, redirect_count=redirect_count + 1\n            )\n        return response\n"
  },
  {
    "path": "datasette/version.py",
    "content": "__version__ = \"1.0a26\"\n__version_info__ = tuple(__version__.split(\".\"))\n"
  },
  {
    "path": "datasette/views/__init__.py",
    "content": "class Context:\n    \"Base class for all documented contexts\"\n"
  },
  {
    "path": "datasette/views/base.py",
    "content": "import asyncio\nimport csv\nimport hashlib\nimport sys\nimport textwrap\nimport time\nimport urllib\nfrom markupsafe import escape\n\n\nfrom datasette.database import QueryInterrupted\nfrom datasette.utils.asgi import Request\nfrom datasette.utils import (\n    add_cors_headers,\n    await_me_maybe,\n    EscapeHtmlWriter,\n    InvalidSql,\n    LimitedWriter,\n    call_with_supported_arguments,\n    path_from_row_pks,\n    path_with_added_args,\n    path_with_removed_args,\n    path_with_format,\n    sqlite3,\n)\nfrom datasette.utils.asgi import (\n    AsgiStream,\n    NotFound,\n    Response,\n    BadRequest,\n)\n\n\nclass DatasetteError(Exception):\n    def __init__(\n        self,\n        message,\n        title=None,\n        error_dict=None,\n        status=500,\n        template=None,\n        message_is_html=False,\n    ):\n        self.message = message\n        self.title = title\n        self.error_dict = error_dict or {}\n        self.status = status\n        self.message_is_html = message_is_html\n\n\nclass View:\n    async def head(self, request, datasette):\n        if not hasattr(self, \"get\"):\n            return await self.method_not_allowed(request)\n        response = await self.get(request, datasette)\n        response.body = \"\"\n        return response\n\n    async def method_not_allowed(self, request):\n        if (\n            request.path.endswith(\".json\")\n            or request.headers.get(\"content-type\") == \"application/json\"\n        ):\n            response = Response.json(\n                {\"ok\": False, \"error\": \"Method not allowed\"}, status=405\n            )\n        else:\n            response = Response.text(\"Method not allowed\", status=405)\n        return response\n\n    async def options(self, request, datasette):\n        response = Response.text(\"ok\")\n        response.headers[\"allow\"] = \", \".join(\n            method.upper()\n            for method in (\"head\", \"get\", \"post\", \"put\", \"patch\", \"delete\")\n            if hasattr(self, method)\n        )\n        return response\n\n    async def __call__(self, request, datasette):\n        try:\n            handler = getattr(self, request.method.lower())\n        except AttributeError:\n            return await self.method_not_allowed(request)\n        return await handler(request, datasette)\n\n\nclass BaseView:\n    ds = None\n    has_json_alternate = True\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def head(self, *args, **kwargs):\n        response = await self.get(*args, **kwargs)\n        response.body = b\"\"\n        return response\n\n    async def method_not_allowed(self, request):\n        if (\n            request.path.endswith(\".json\")\n            or request.headers.get(\"content-type\") == \"application/json\"\n        ):\n            response = Response.json(\n                {\"ok\": False, \"error\": \"Method not allowed\"}, status=405\n            )\n        else:\n            response = Response.text(\"Method not allowed\", status=405)\n        return response\n\n    async def options(self, request, *args, **kwargs):\n        return Response.text(\"ok\")\n\n    async def get(self, request, *args, **kwargs):\n        return await self.method_not_allowed(request)\n\n    async def post(self, request, *args, **kwargs):\n        return await self.method_not_allowed(request)\n\n    async def put(self, request, *args, **kwargs):\n        return await self.method_not_allowed(request)\n\n    async def patch(self, request, *args, **kwargs):\n        return await self.method_not_allowed(request)\n\n    async def delete(self, request, *args, **kwargs):\n        return await self.method_not_allowed(request)\n\n    async def dispatch_request(self, request):\n        if self.ds:\n            await self.ds.refresh_schemas()\n        handler = getattr(self, request.method.lower(), None)\n        response = await handler(request)\n        if self.ds.cors:\n            add_cors_headers(response.headers)\n        return response\n\n    async def render(self, templates, request, context=None):\n        context = context or {}\n        environment = self.ds.get_jinja_environment(request)\n        template = environment.select_template(templates)\n        template_context = {\n            **context,\n            **{\n                \"select_templates\": [\n                    f\"{'*' if template_name == template.name else ''}{template_name}\"\n                    for template_name in templates\n                ],\n            },\n        }\n        headers = {}\n        if self.has_json_alternate:\n            alternate_url_json = self.ds.absolute_url(\n                request,\n                self.ds.urls.path(path_with_format(request=request, format=\"json\")),\n            )\n            template_context[\"alternate_url_json\"] = alternate_url_json\n            headers.update(\n                {\n                    \"Link\": '<{}>; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\n                        alternate_url_json\n                    )\n                }\n            )\n        return Response.html(\n            await self.ds.render_template(\n                template,\n                template_context,\n                request=request,\n                view_name=self.name,\n            ),\n            headers=headers,\n        )\n\n    @classmethod\n    def as_view(cls, *class_args, **class_kwargs):\n        async def view(request, send):\n            self = view.view_class(*class_args, **class_kwargs)\n            return await self.dispatch_request(request)\n\n        view.view_class = cls\n        view.__doc__ = cls.__doc__\n        view.__module__ = cls.__module__\n        view.__name__ = cls.__name__\n        return view\n\n\nclass DataView(BaseView):\n    name = \"\"\n\n    def redirect(self, request, path, forward_querystring=True, remove_args=None):\n        if request.query_string and \"?\" not in path and forward_querystring:\n            path = f\"{path}?{request.query_string}\"\n        if remove_args:\n            path = path_with_removed_args(request, remove_args, path=path)\n        r = Response.redirect(path)\n        r.headers[\"Link\"] = f\"<{path}>; rel=preload\"\n        if self.ds.cors:\n            add_cors_headers(r.headers)\n        return r\n\n    async def data(self, request):\n        raise NotImplementedError\n\n    async def as_csv(self, request, database):\n        return await stream_csv(self.ds, self.data, request, database)\n\n    async def get(self, request):\n        db = await self.ds.resolve_database(request)\n        database = db.name\n        database_route = db.route\n\n        _format = request.url_vars[\"format\"]\n        data_kwargs = {}\n\n        if _format == \"csv\":\n            return await self.as_csv(request, database_route)\n\n        if _format is None:\n            # HTML views default to expanding all foreign key labels\n            data_kwargs[\"default_labels\"] = True\n\n        extra_template_data = {}\n        start = time.perf_counter()\n        status_code = None\n        templates = []\n        try:\n            response_or_template_contexts = await self.data(request, **data_kwargs)\n            if isinstance(response_or_template_contexts, Response):\n                return response_or_template_contexts\n            # If it has four items, it includes an HTTP status code\n            if len(response_or_template_contexts) == 4:\n                (\n                    data,\n                    extra_template_data,\n                    templates,\n                    status_code,\n                ) = response_or_template_contexts\n            else:\n                data, extra_template_data, templates = response_or_template_contexts\n        except QueryInterrupted as ex:\n            raise DatasetteError(\n                textwrap.dedent(\"\"\"\n                <p>SQL query took too long. The time limit is controlled by the\n                <a href=\"https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms\">sql_time_limit_ms</a>\n                configuration option.</p>\n                <textarea style=\"width: 90%\">{}</textarea>\n                <script>\n                let ta = document.querySelector(\"textarea\");\n                ta.style.height = ta.scrollHeight + \"px\";\n                </script>\n            \"\"\".format(escape(ex.sql))).strip(),\n                title=\"SQL Interrupted\",\n                status=400,\n                message_is_html=True,\n            )\n        except (sqlite3.OperationalError, InvalidSql) as e:\n            raise DatasetteError(str(e), title=\"Invalid SQL\", status=400)\n\n        except sqlite3.OperationalError as e:\n            raise DatasetteError(str(e))\n\n        except DatasetteError:\n            raise\n\n        end = time.perf_counter()\n        data[\"query_ms\"] = (end - start) * 1000\n\n        # Special case for .jsono extension - redirect to _shape=objects\n        if _format == \"jsono\":\n            return self.redirect(\n                request,\n                path_with_added_args(\n                    request,\n                    {\"_shape\": \"objects\"},\n                    path=request.path.rsplit(\".jsono\", 1)[0] + \".json\",\n                ),\n                forward_querystring=False,\n            )\n\n        if _format in self.ds.renderers.keys():\n            # Dispatch request to the correct output format renderer\n            # (CSV is not handled here due to streaming)\n            result = call_with_supported_arguments(\n                self.ds.renderers[_format][0],\n                datasette=self.ds,\n                columns=data.get(\"columns\") or [],\n                rows=data.get(\"rows\") or [],\n                sql=data.get(\"query\", {}).get(\"sql\", None),\n                query_name=data.get(\"query_name\"),\n                database=database,\n                table=data.get(\"table\"),\n                request=request,\n                view_name=self.name,\n                truncated=False,  # TODO: support this\n                error=data.get(\"error\"),\n                # These will be deprecated in Datasette 1.0:\n                args=request.args,\n                data=data,\n            )\n            if asyncio.iscoroutine(result):\n                result = await result\n            if result is None:\n                raise NotFound(\"No data\")\n            if isinstance(result, dict):\n                r = Response(\n                    body=result.get(\"body\"),\n                    status=result.get(\"status_code\", status_code or 200),\n                    content_type=result.get(\"content_type\", \"text/plain\"),\n                    headers=result.get(\"headers\"),\n                )\n            elif isinstance(result, Response):\n                r = result\n                if status_code is not None:\n                    # Over-ride the status code\n                    r.status = status_code\n            else:\n                assert False, f\"{result} should be dict or Response\"\n        else:\n            extras = {}\n            if callable(extra_template_data):\n                extras = extra_template_data()\n                if asyncio.iscoroutine(extras):\n                    extras = await extras\n            else:\n                extras = extra_template_data\n            url_labels_extra = {}\n            if data.get(\"expandable_columns\"):\n                url_labels_extra = {\"_labels\": \"on\"}\n\n            renderers = {}\n            for key, (_, can_render) in self.ds.renderers.items():\n                it_can_render = call_with_supported_arguments(\n                    can_render,\n                    datasette=self.ds,\n                    columns=data.get(\"columns\") or [],\n                    rows=data.get(\"rows\") or [],\n                    sql=data.get(\"query\", {}).get(\"sql\", None),\n                    query_name=data.get(\"query_name\"),\n                    database=database,\n                    table=data.get(\"table\"),\n                    request=request,\n                    view_name=self.name,\n                )\n                it_can_render = await await_me_maybe(it_can_render)\n                if it_can_render:\n                    renderers[key] = self.ds.urls.path(\n                        path_with_format(\n                            request=request, format=key, extra_qs={**url_labels_extra}\n                        )\n                    )\n\n            url_csv_args = {\"_size\": \"max\", **url_labels_extra}\n            url_csv = self.ds.urls.path(\n                path_with_format(request=request, format=\"csv\", extra_qs=url_csv_args)\n            )\n            url_csv_path = url_csv.split(\"?\")[0]\n            context = {\n                **data,\n                **extras,\n                **{\n                    \"renderers\": renderers,\n                    \"url_csv\": url_csv,\n                    \"url_csv_path\": url_csv_path,\n                    \"url_csv_hidden_args\": [\n                        (key, value)\n                        for key, value in urllib.parse.parse_qsl(request.query_string)\n                        if key not in (\"_labels\", \"_facet\", \"_size\")\n                    ]\n                    + [(\"_size\", \"max\")],\n                    \"settings\": self.ds.settings_dict(),\n                },\n            }\n            if \"metadata\" not in context:\n                context[\"metadata\"] = await self.ds.get_instance_metadata()\n            r = await self.render(templates, request=request, context=context)\n            if status_code is not None:\n                r.status = status_code\n\n        ttl = request.args.get(\"_ttl\", None)\n        if ttl is None or not ttl.isdigit():\n            ttl = self.ds.setting(\"default_cache_ttl\")\n\n        return self.set_response_headers(r, ttl)\n\n    def set_response_headers(self, response, ttl):\n        # Set far-future cache expiry\n        if self.ds.cache_headers and response.status == 200:\n            ttl = int(ttl)\n            if ttl == 0:\n                ttl_header = \"no-cache\"\n            else:\n                ttl_header = f\"max-age={ttl}\"\n            response.headers[\"Cache-Control\"] = ttl_header\n        response.headers[\"Referrer-Policy\"] = \"no-referrer\"\n        if self.ds.cors:\n            add_cors_headers(response.headers)\n        return response\n\n\ndef _error(messages, status=400):\n    return Response.json({\"ok\": False, \"errors\": messages}, status=status)\n\n\nasync def stream_csv(datasette, fetch_data, request, database):\n    kwargs = {}\n    stream = request.args.get(\"_stream\")\n    # Do not calculate facets or counts:\n    extra_parameters = [\n        \"{}=1\".format(key)\n        for key in (\"_nofacet\", \"_nocount\")\n        if not request.args.get(key)\n    ]\n    if extra_parameters:\n        # Replace request object with a new one with modified scope\n        if not request.query_string:\n            new_query_string = \"&\".join(extra_parameters)\n        else:\n            new_query_string = request.query_string + \"&\" + \"&\".join(extra_parameters)\n        new_scope = dict(request.scope, query_string=new_query_string.encode(\"latin-1\"))\n        receive = request.receive\n        request = Request(new_scope, receive)\n    if stream:\n        # Some quick soundness checks\n        if not datasette.setting(\"allow_csv_stream\"):\n            raise BadRequest(\"CSV streaming is disabled\")\n        if request.args.get(\"_next\"):\n            raise BadRequest(\"_next not allowed for CSV streaming\")\n        kwargs[\"_size\"] = \"max\"\n    # Fetch the first page\n    try:\n        response_or_template_contexts = await fetch_data(request)\n        if isinstance(response_or_template_contexts, Response):\n            return response_or_template_contexts\n        elif len(response_or_template_contexts) == 4:\n            data, _, _, _ = response_or_template_contexts\n        else:\n            data, _, _ = response_or_template_contexts\n    except (sqlite3.OperationalError, InvalidSql) as e:\n        raise DatasetteError(str(e), title=\"Invalid SQL\", status=400)\n\n    except sqlite3.OperationalError as e:\n        raise DatasetteError(str(e))\n\n    except DatasetteError:\n        raise\n\n    # Convert rows and columns to CSV\n    headings = data[\"columns\"]\n    # if there are expanded_columns we need to add additional headings\n    expanded_columns = set(data.get(\"expanded_columns\") or [])\n    if expanded_columns:\n        headings = []\n        for column in data[\"columns\"]:\n            headings.append(column)\n            if column in expanded_columns:\n                headings.append(f\"{column}_label\")\n\n    content_type = \"text/plain; charset=utf-8\"\n    preamble = \"\"\n    postamble = \"\"\n\n    trace = request.args.get(\"_trace\")\n    if trace:\n        content_type = \"text/html; charset=utf-8\"\n        preamble = (\n            \"<html><head><title>CSV debug</title></head>\"\n            '<body><textarea style=\"width: 90%; height: 70vh\">'\n        )\n        postamble = \"</textarea></body></html>\"\n\n    async def stream_fn(r):\n        nonlocal data, trace\n        limited_writer = LimitedWriter(r, datasette.setting(\"max_csv_mb\"))\n        if trace:\n            await limited_writer.write(preamble)\n            writer = csv.writer(EscapeHtmlWriter(limited_writer))\n        else:\n            writer = csv.writer(limited_writer)\n        first = True\n        next = None\n        while first or (next and stream):\n            try:\n                kwargs = {}\n                if next:\n                    kwargs[\"_next\"] = next\n                if not first:\n                    data, _, _ = await fetch_data(request, **kwargs)\n                if first:\n                    if request.args.get(\"_header\") != \"off\":\n                        await writer.writerow(headings)\n                    first = False\n                next = data.get(\"next\")\n                for row in data[\"rows\"]:\n                    if any(isinstance(r, bytes) for r in row):\n                        new_row = []\n                        for column, cell in zip(headings, row):\n                            if isinstance(cell, bytes):\n                                # If this is a table page, use .urls.row_blob()\n                                if data.get(\"table\"):\n                                    pks = data.get(\"primary_keys\") or []\n                                    cell = datasette.absolute_url(\n                                        request,\n                                        datasette.urls.row_blob(\n                                            database,\n                                            data[\"table\"],\n                                            path_from_row_pks(row, pks, not pks),\n                                            column,\n                                        ),\n                                    )\n                                else:\n                                    # Otherwise generate URL for this query\n                                    url = datasette.absolute_url(\n                                        request,\n                                        path_with_format(\n                                            request=request,\n                                            format=\"blob\",\n                                            extra_qs={\n                                                \"_blob_column\": column,\n                                                \"_blob_hash\": hashlib.sha256(\n                                                    cell\n                                                ).hexdigest(),\n                                            },\n                                            replace_format=\"csv\",\n                                        ),\n                                    )\n                                    cell = url.replace(\"&_nocount=1\", \"\").replace(\n                                        \"&_nofacet=1\", \"\"\n                                    )\n                            new_row.append(cell)\n                        row = new_row\n                    if not expanded_columns:\n                        # Simple path\n                        await writer.writerow(row)\n                    else:\n                        # Look for {\"value\": \"label\": } dicts and expand\n                        new_row = []\n                        for heading, cell in zip(data[\"columns\"], row):\n                            if heading in expanded_columns:\n                                if cell is None:\n                                    new_row.extend((\"\", \"\"))\n                                else:\n                                    if not isinstance(cell, dict):\n                                        new_row.extend((cell, \"\"))\n                                    else:\n                                        new_row.append(cell[\"value\"])\n                                        new_row.append(cell[\"label\"])\n                            else:\n                                new_row.append(cell)\n                        await writer.writerow(new_row)\n            except Exception as ex:\n                sys.stderr.write(\"Caught this error: {}\\n\".format(ex))\n                sys.stderr.flush()\n                await r.write(str(ex))\n                return\n        await limited_writer.write(postamble)\n\n    headers = {}\n    if datasette.cors:\n        add_cors_headers(headers)\n    if request.args.get(\"_dl\", None):\n        if not trace:\n            content_type = \"text/csv; charset=utf-8\"\n        disposition = 'attachment; filename=\"{}.csv\"'.format(\n            request.url_vars.get(\"table\", database)\n        )\n        headers[\"content-disposition\"] = disposition\n\n    return AsgiStream(stream_fn, headers=headers, content_type=content_type)\n"
  },
  {
    "path": "datasette/views/database.py",
    "content": "from dataclasses import dataclass, field\nfrom urllib.parse import parse_qsl, urlencode\nimport asyncio\nimport hashlib\nimport itertools\nimport json\nimport markupsafe\nimport os\nimport re\nimport sqlite_utils\nimport textwrap\n\nfrom datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent\nfrom datasette.database import QueryInterrupted\nfrom datasette.resources import DatabaseResource, QueryResource\nfrom datasette.utils import (\n    add_cors_headers,\n    await_me_maybe,\n    call_with_supported_arguments,\n    named_parameters as derive_named_parameters,\n    format_bytes,\n    make_slot_function,\n    tilde_decode,\n    to_css_class,\n    validate_sql_select,\n    is_url,\n    path_with_added_args,\n    path_with_format,\n    path_with_removed_args,\n    sqlite3,\n    truncate_url,\n    InvalidSql,\n)\nfrom datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden\nfrom datasette.plugins import pm\n\nfrom .base import BaseView, DatasetteError, View, _error, stream_csv\nfrom . import Context\n\n\nclass DatabaseView(View):\n    async def get(self, request, datasette):\n        format_ = request.url_vars.get(\"format\") or \"html\"\n\n        await datasette.refresh_schemas()\n\n        db = await datasette.resolve_database(request)\n        database = db.name\n\n        visible, private = await datasette.check_visibility(\n            request.actor,\n            action=\"view-database\",\n            resource=DatabaseResource(database=database),\n        )\n        if not visible:\n            raise Forbidden(\"You do not have permission to view this database\")\n\n        sql = (request.args.get(\"sql\") or \"\").strip()\n        if sql:\n            redirect_url = \"/\" + request.url_vars.get(\"database\") + \"/-/query\"\n            if request.url_vars.get(\"format\"):\n                redirect_url += \".\" + request.url_vars.get(\"format\")\n            redirect_url += \"?\" + request.query_string\n            return Response.redirect(redirect_url)\n            return await QueryView()(request, datasette)\n\n        if format_ not in (\"html\", \"json\"):\n            raise NotFound(\"Invalid format: {}\".format(format_))\n\n        metadata = await datasette.get_database_metadata(database)\n\n        # Get all tables/views this actor can see in bulk with private flag\n        allowed_tables_page = await datasette.allowed_resources(\n            \"view-table\",\n            request.actor,\n            parent=database,\n            include_is_private=True,\n            limit=1000,\n        )\n        # Create lookup dict for quick access\n        allowed_dict = {r.child: r for r in allowed_tables_page.resources}\n\n        # Filter to just views\n        view_names_set = set(await db.view_names())\n        sql_views = [\n            {\"name\": name, \"private\": allowed_dict[name].private}\n            for name in allowed_dict\n            if name in view_names_set\n        ]\n\n        tables = await get_tables(datasette, request, db, allowed_dict)\n\n        # Get allowed queries using the new permission system\n        allowed_query_page = await datasette.allowed_resources(\n            \"view-query\",\n            request.actor,\n            parent=database,\n            include_is_private=True,\n            limit=1000,\n        )\n\n        # Build canned_queries list by looking up each allowed query\n        all_queries = await datasette.get_canned_queries(database, request.actor)\n        canned_queries = []\n        for query_resource in allowed_query_page.resources:\n            query_name = query_resource.child\n            if query_name in all_queries:\n                canned_queries.append(\n                    dict(all_queries[query_name], private=query_resource.private)\n                )\n\n        async def database_actions():\n            links = []\n            for hook in pm.hook.database_actions(\n                datasette=datasette,\n                database=database,\n                actor=request.actor,\n                request=request,\n            ):\n                extra_links = await await_me_maybe(hook)\n                if extra_links:\n                    links.extend(extra_links)\n            return links\n\n        attached_databases = [d.name for d in await db.attached_databases()]\n\n        allow_execute_sql = await datasette.allowed(\n            action=\"execute-sql\",\n            resource=DatabaseResource(database=database),\n            actor=request.actor,\n        )\n        json_data = {\n            \"database\": database,\n            \"private\": private,\n            \"path\": datasette.urls.database(database),\n            \"size\": db.size,\n            \"tables\": tables,\n            \"hidden_count\": len([t for t in tables if t[\"hidden\"]]),\n            \"views\": sql_views,\n            \"queries\": canned_queries,\n            \"allow_execute_sql\": allow_execute_sql,\n            \"table_columns\": (\n                await _table_columns(datasette, database) if allow_execute_sql else {}\n            ),\n            \"metadata\": await datasette.get_database_metadata(database),\n        }\n\n        if format_ == \"json\":\n            response = Response.json(json_data)\n            if datasette.cors:\n                add_cors_headers(response.headers)\n            return response\n\n        assert format_ == \"html\"\n        alternate_url_json = datasette.absolute_url(\n            request,\n            datasette.urls.path(path_with_format(request=request, format=\"json\")),\n        )\n        templates = (f\"database-{to_css_class(database)}.html\", \"database.html\")\n        environment = datasette.get_jinja_environment(request)\n        template = environment.select_template(templates)\n        return Response.html(\n            await datasette.render_template(\n                templates,\n                DatabaseContext(\n                    database=database,\n                    private=private,\n                    path=datasette.urls.database(database),\n                    size=db.size,\n                    tables=tables,\n                    hidden_count=len([t for t in tables if t[\"hidden\"]]),\n                    views=sql_views,\n                    queries=canned_queries,\n                    allow_execute_sql=allow_execute_sql,\n                    table_columns=(\n                        await _table_columns(datasette, database)\n                        if allow_execute_sql\n                        else {}\n                    ),\n                    metadata=metadata,\n                    database_color=db.color,\n                    database_actions=database_actions,\n                    show_hidden=request.args.get(\"_show_hidden\"),\n                    editable=True,\n                    count_limit=db.count_limit,\n                    allow_download=datasette.setting(\"allow_download\")\n                    and not db.is_mutable\n                    and not db.is_memory,\n                    attached_databases=attached_databases,\n                    alternate_url_json=alternate_url_json,\n                    select_templates=[\n                        f\"{'*' if template_name == template.name else ''}{template_name}\"\n                        for template_name in templates\n                    ],\n                    top_database=make_slot_function(\n                        \"top_database\", datasette, request, database=database\n                    ),\n                ),\n                request=request,\n                view_name=\"database\",\n            ),\n            headers={\n                \"Link\": '<{}>; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\n                    alternate_url_json\n                )\n            },\n        )\n\n\n@dataclass\nclass DatabaseContext(Context):\n    database: str = field(metadata={\"help\": \"The name of the database\"})\n    private: bool = field(\n        metadata={\"help\": \"Boolean indicating if this is a private database\"}\n    )\n    path: str = field(metadata={\"help\": \"The URL path to this database\"})\n    size: int = field(metadata={\"help\": \"The size of the database in bytes\"})\n    tables: list = field(metadata={\"help\": \"List of table objects in the database\"})\n    hidden_count: int = field(metadata={\"help\": \"Count of hidden tables\"})\n    views: list = field(metadata={\"help\": \"List of view objects in the database\"})\n    queries: list = field(metadata={\"help\": \"List of canned query objects\"})\n    allow_execute_sql: bool = field(\n        metadata={\"help\": \"Boolean indicating if custom SQL can be executed\"}\n    )\n    table_columns: dict = field(\n        metadata={\"help\": \"Dictionary mapping table names to their column lists\"}\n    )\n    metadata: dict = field(metadata={\"help\": \"Metadata for the database\"})\n    database_color: str = field(metadata={\"help\": \"The color assigned to the database\"})\n    database_actions: callable = field(\n        metadata={\n            \"help\": \"Callable returning list of action links for the database menu\"\n        }\n    )\n    show_hidden: str = field(metadata={\"help\": \"Value of _show_hidden query parameter\"})\n    editable: bool = field(\n        metadata={\"help\": \"Boolean indicating if the database is editable\"}\n    )\n    count_limit: int = field(metadata={\"help\": \"The maximum number of rows to count\"})\n    allow_download: bool = field(\n        metadata={\"help\": \"Boolean indicating if database download is allowed\"}\n    )\n    attached_databases: list = field(\n        metadata={\"help\": \"List of names of attached databases\"}\n    )\n    alternate_url_json: str = field(\n        metadata={\"help\": \"URL for the alternate JSON version of this page\"}\n    )\n    select_templates: list = field(\n        metadata={\n            \"help\": \"List of templates that were considered for rendering this page\"\n        }\n    )\n    top_database: callable = field(\n        metadata={\"help\": \"Callable to render the top_database slot\"}\n    )\n\n\n@dataclass\nclass QueryContext(Context):\n    database: str = field(metadata={\"help\": \"The name of the database being queried\"})\n    database_color: str = field(metadata={\"help\": \"The color of the database\"})\n    query: dict = field(\n        metadata={\"help\": \"The SQL query object containing the `sql` string\"}\n    )\n    canned_query: str = field(\n        metadata={\"help\": \"The name of the canned query if this is a canned query\"}\n    )\n    private: bool = field(\n        metadata={\"help\": \"Boolean indicating if this is a private database\"}\n    )\n    # urls: dict = field(\n    #     metadata={\"help\": \"Object containing URL helpers like `database()`\"}\n    # )\n    canned_query_write: bool = field(\n        metadata={\n            \"help\": \"Boolean indicating if this is a canned query that allows writes\"\n        }\n    )\n    metadata: dict = field(\n        metadata={\"help\": \"Metadata about the database or the canned query\"}\n    )\n    db_is_immutable: bool = field(\n        metadata={\"help\": \"Boolean indicating if this database is immutable\"}\n    )\n    error: str = field(metadata={\"help\": \"Any query error message\"})\n    hide_sql: bool = field(\n        metadata={\"help\": \"Boolean indicating if the SQL should be hidden\"}\n    )\n    show_hide_link: str = field(\n        metadata={\"help\": \"The URL to toggle showing/hiding the SQL\"}\n    )\n    show_hide_text: str = field(\n        metadata={\"help\": \"The text for the show/hide SQL link\"}\n    )\n    editable: bool = field(\n        metadata={\"help\": \"Boolean indicating if the SQL can be edited\"}\n    )\n    allow_execute_sql: bool = field(\n        metadata={\"help\": \"Boolean indicating if custom SQL can be executed\"}\n    )\n    tables: list = field(metadata={\"help\": \"List of table objects in the database\"})\n    named_parameter_values: dict = field(\n        metadata={\"help\": \"Dictionary of parameter names/values\"}\n    )\n    edit_sql_url: str = field(\n        metadata={\"help\": \"URL to edit the SQL for a canned query\"}\n    )\n    display_rows: list = field(metadata={\"help\": \"List of result rows to display\"})\n    columns: list = field(metadata={\"help\": \"List of column names\"})\n    renderers: dict = field(metadata={\"help\": \"Dictionary of renderer name to URL\"})\n    url_csv: str = field(metadata={\"help\": \"URL for CSV export\"})\n    show_hide_hidden: str = field(\n        metadata={\"help\": \"Hidden input field for the _show_sql parameter\"}\n    )\n    table_columns: dict = field(\n        metadata={\"help\": \"Dictionary of table name to list of column names\"}\n    )\n    alternate_url_json: str = field(\n        metadata={\"help\": \"URL for alternate JSON version of this page\"}\n    )\n    # TODO: refactor this to somewhere else, probably ds.render_template()\n    select_templates: list = field(\n        metadata={\n            \"help\": \"List of templates that were considered for rendering this page\"\n        }\n    )\n    top_query: callable = field(\n        metadata={\"help\": \"Callable to render the top_query slot\"}\n    )\n    top_canned_query: callable = field(\n        metadata={\"help\": \"Callable to render the top_canned_query slot\"}\n    )\n    query_actions: callable = field(\n        metadata={\n            \"help\": \"Callable returning a list of links for the query action menu\"\n        }\n    )\n\n\nasync def get_tables(datasette, request, db, allowed_dict):\n    \"\"\"\n    Get list of tables with metadata for the database view.\n\n    Args:\n        datasette: The Datasette instance\n        request: The current request\n        db: The database\n        allowed_dict: Dict mapping table name -> Resource object with .private attribute\n    \"\"\"\n    tables = []\n    table_counts = await db.table_counts(100)\n    hidden_table_names = set(await db.hidden_table_names())\n    all_foreign_keys = await db.get_all_foreign_keys()\n\n    for table in table_counts:\n        if table not in allowed_dict:\n            continue\n\n        table_columns = await db.table_columns(table)\n        tables.append(\n            {\n                \"name\": table,\n                \"columns\": table_columns,\n                \"primary_keys\": await db.primary_keys(table),\n                \"count\": table_counts[table],\n                \"hidden\": table in hidden_table_names,\n                \"fts_table\": await db.fts_table(table),\n                \"foreign_keys\": all_foreign_keys[table],\n                \"private\": allowed_dict[table].private,\n            }\n        )\n    tables.sort(key=lambda t: (t[\"hidden\"], t[\"name\"]))\n    return tables\n\n\nasync def database_download(request, datasette):\n    from datasette.resources import DatabaseResource\n\n    database = tilde_decode(request.url_vars[\"database\"])\n    await datasette.ensure_permission(\n        action=\"view-database-download\",\n        resource=DatabaseResource(database=database),\n        actor=request.actor,\n    )\n    try:\n        db = datasette.get_database(route=database)\n    except KeyError:\n        raise DatasetteError(\"Invalid database\", status=404)\n\n    if db.is_memory:\n        raise DatasetteError(\"Cannot download in-memory databases\", status=404)\n    if not datasette.setting(\"allow_download\") or db.is_mutable:\n        raise Forbidden(\"Database download is forbidden\")\n    if not db.path:\n        raise DatasetteError(\"Cannot download database\", status=404)\n    filepath = db.path\n    headers = {}\n    if datasette.cors:\n        add_cors_headers(headers)\n    if db.hash:\n        etag = '\"{}\"'.format(db.hash)\n        headers[\"Etag\"] = etag\n        # Has user seen this already?\n        if_none_match = request.headers.get(\"if-none-match\")\n        if if_none_match and if_none_match == etag:\n            return Response(\"\", status=304)\n    headers[\"Transfer-Encoding\"] = \"chunked\"\n    return AsgiFileDownload(\n        filepath,\n        filename=os.path.basename(filepath),\n        content_type=\"application/octet-stream\",\n        headers=headers,\n    )\n\n\nclass QueryView(View):\n    async def post(self, request, datasette):\n        from datasette.app import TableNotFound\n\n        db = await datasette.resolve_database(request)\n\n        # We must be a canned query\n        table_found = False\n        try:\n            await datasette.resolve_table(request)\n            table_found = True\n        except TableNotFound as table_not_found:\n            canned_query = await datasette.get_canned_query(\n                table_not_found.database_name, table_not_found.table, request.actor\n            )\n            if canned_query is None:\n                raise\n        if table_found:\n            # That should not have happened\n            raise DatasetteError(\"Unexpected table found on POST\", status=404)\n\n        # If database is immutable, return an error\n        if not db.is_mutable:\n            raise Forbidden(\"Database is immutable\")\n\n        # Process the POST\n        body = await request.post_body()\n        body = body.decode(\"utf-8\").strip()\n        if body.startswith(\"{\") and body.endswith(\"}\"):\n            params = json.loads(body)\n            # But we want key=value strings\n            for key, value in params.items():\n                params[key] = str(value)\n        else:\n            params = dict(parse_qsl(body, keep_blank_values=True))\n\n        # Don't ever send csrftoken as a SQL parameter\n        params.pop(\"csrftoken\", None)\n\n        # Should we return JSON?\n        should_return_json = (\n            request.headers.get(\"accept\") == \"application/json\"\n            or request.args.get(\"_json\")\n            or params.get(\"_json\")\n        )\n        params_for_query = MagicParameters(\n            canned_query[\"sql\"], params, request, datasette\n        )\n        await params_for_query.execute_params()\n        ok = None\n        redirect_url = None\n        try:\n            cursor = await db.execute_write(\n                canned_query[\"sql\"], params_for_query, request=request\n            )\n            # success message can come from on_success_message or on_success_message_sql\n            message = None\n            message_type = datasette.INFO\n            on_success_message_sql = canned_query.get(\"on_success_message_sql\")\n            if on_success_message_sql:\n                try:\n                    message_result = (\n                        await db.execute(on_success_message_sql, params_for_query)\n                    ).first()\n                    if message_result:\n                        message = message_result[0]\n                except Exception as ex:\n                    message = \"Error running on_success_message_sql: {}\".format(ex)\n                    message_type = datasette.ERROR\n            if not message:\n                message = canned_query.get(\n                    \"on_success_message\"\n                ) or \"Query executed, {} row{} affected\".format(\n                    cursor.rowcount, \"\" if cursor.rowcount == 1 else \"s\"\n                )\n\n            redirect_url = canned_query.get(\"on_success_redirect\")\n            ok = True\n        except Exception as ex:\n            message = canned_query.get(\"on_error_message\") or str(ex)\n            message_type = datasette.ERROR\n            redirect_url = canned_query.get(\"on_error_redirect\")\n            ok = False\n        if should_return_json:\n            return Response.json(\n                {\n                    \"ok\": ok,\n                    \"message\": message,\n                    \"redirect\": redirect_url,\n                }\n            )\n        else:\n            datasette.add_message(request, message, message_type)\n            return Response.redirect(redirect_url or request.path)\n\n    async def get(self, request, datasette):\n        from datasette.app import TableNotFound\n\n        await datasette.refresh_schemas()\n\n        db = await datasette.resolve_database(request)\n        database = db.name\n\n        # Get all tables/views this actor can see in bulk with private flag\n        allowed_tables_page = await datasette.allowed_resources(\n            \"view-table\",\n            request.actor,\n            parent=database,\n            include_is_private=True,\n            limit=1000,\n        )\n        # Create lookup dict for quick access\n        allowed_dict = {r.child: r for r in allowed_tables_page.resources}\n\n        # Are we a canned query?\n        canned_query = None\n        canned_query_write = False\n        if \"table\" in request.url_vars:\n            try:\n                await datasette.resolve_table(request)\n            except TableNotFound as table_not_found:\n                # Was this actually a canned query?\n                canned_query = await datasette.get_canned_query(\n                    table_not_found.database_name, table_not_found.table, request.actor\n                )\n                if canned_query is None:\n                    raise\n                canned_query_write = bool(canned_query.get(\"write\"))\n\n        private = False\n        if canned_query:\n            # Respect canned query permissions\n            visible, private = await datasette.check_visibility(\n                request.actor,\n                action=\"view-query\",\n                resource=QueryResource(database=database, query=canned_query[\"name\"]),\n            )\n            if not visible:\n                raise Forbidden(\"You do not have permission to view this query\")\n\n        else:\n            await datasette.ensure_permission(\n                action=\"execute-sql\",\n                resource=DatabaseResource(database=database),\n                actor=request.actor,\n            )\n\n        # Flattened because of ?sql=&name1=value1&name2=value2 feature\n        params = {key: request.args.get(key) for key in request.args}\n        sql = None\n\n        if canned_query:\n            sql = canned_query[\"sql\"]\n        elif \"sql\" in params:\n            sql = params.pop(\"sql\")\n\n        # Extract any :named parameters\n        named_parameters = []\n        if canned_query and canned_query.get(\"params\"):\n            named_parameters = canned_query[\"params\"]\n        if not named_parameters:\n            named_parameters = derive_named_parameters(sql)\n        named_parameter_values = {\n            named_parameter: params.get(named_parameter) or \"\"\n            for named_parameter in named_parameters\n            if not named_parameter.startswith(\"_\")\n        }\n        # Set to blank string if missing from params\n        for named_parameter in named_parameters:\n            if named_parameter not in params and not named_parameter.startswith(\"_\"):\n                params[named_parameter] = \"\"\n\n        extra_args = {}\n        if params.get(\"_timelimit\"):\n            extra_args[\"custom_time_limit\"] = int(params[\"_timelimit\"])\n\n        format_ = request.url_vars.get(\"format\") or \"html\"\n\n        query_error = None\n        results = None\n        rows = []\n        columns = []\n\n        params_for_query = params\n\n        if not canned_query_write:\n            try:\n                if not canned_query:\n                    # For regular queries we only allow SELECT, plus other rules\n                    validate_sql_select(sql)\n                else:\n                    # Canned queries can run magic parameters\n                    params_for_query = MagicParameters(sql, params, request, datasette)\n                    await params_for_query.execute_params()\n                results = await datasette.execute(\n                    database, sql, params_for_query, truncate=True, **extra_args\n                )\n                columns = results.columns\n                rows = results.rows\n            except QueryInterrupted as ex:\n                raise DatasetteError(\n                    textwrap.dedent(\"\"\"\n                    <p>SQL query took too long. The time limit is controlled by the\n                    <a href=\"https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms\">sql_time_limit_ms</a>\n                    configuration option.</p>\n                    <textarea style=\"width: 90%\">{}</textarea>\n                    <script>\n                    let ta = document.querySelector(\"textarea\");\n                    ta.style.height = ta.scrollHeight + \"px\";\n                    </script>\n                \"\"\".format(markupsafe.escape(ex.sql))).strip(),\n                    title=\"SQL Interrupted\",\n                    status=400,\n                    message_is_html=True,\n                )\n            except sqlite3.DatabaseError as ex:\n                query_error = str(ex)\n                results = None\n                rows = []\n                columns = []\n            except (sqlite3.OperationalError, InvalidSql) as ex:\n                raise DatasetteError(str(ex), title=\"Invalid SQL\", status=400)\n            except sqlite3.OperationalError as ex:\n                raise DatasetteError(str(ex))\n            except DatasetteError:\n                raise\n\n        # Handle formats from plugins\n        if format_ == \"csv\":\n\n            async def fetch_data_for_csv(request, _next=None):\n                results = await db.execute(sql, params, truncate=True)\n                data = {\"rows\": results.rows, \"columns\": results.columns}\n                return data, None, None\n\n            return await stream_csv(datasette, fetch_data_for_csv, request, db.name)\n        elif format_ in datasette.renderers.keys():\n            # Dispatch request to the correct output format renderer\n            # (CSV is not handled here due to streaming)\n            result = call_with_supported_arguments(\n                datasette.renderers[format_][0],\n                datasette=datasette,\n                columns=columns,\n                rows=rows,\n                sql=sql,\n                query_name=canned_query[\"name\"] if canned_query else None,\n                database=database,\n                table=None,\n                request=request,\n                view_name=\"table\",\n                truncated=results.truncated if results else False,\n                error=query_error,\n                # These will be deprecated in Datasette 1.0:\n                args=request.args,\n                data={\"ok\": True, \"rows\": rows, \"columns\": columns},\n            )\n            if asyncio.iscoroutine(result):\n                result = await result\n            if result is None:\n                raise NotFound(\"No data\")\n            if isinstance(result, dict):\n                r = Response(\n                    body=result.get(\"body\"),\n                    status=result.get(\"status_code\") or 200,\n                    content_type=result.get(\"content_type\", \"text/plain\"),\n                    headers=result.get(\"headers\"),\n                )\n            elif isinstance(result, Response):\n                r = result\n                # if status_code is not None:\n                #     # Over-ride the status code\n                #     r.status = status_code\n            else:\n                assert False, f\"{result} should be dict or Response\"\n        elif format_ == \"html\":\n            headers = {}\n            templates = [f\"query-{to_css_class(database)}.html\", \"query.html\"]\n            if canned_query:\n                templates.insert(\n                    0,\n                    f\"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html\",\n                )\n\n            environment = datasette.get_jinja_environment(request)\n            template = environment.select_template(templates)\n            alternate_url_json = datasette.absolute_url(\n                request,\n                datasette.urls.path(path_with_format(request=request, format=\"json\")),\n            )\n            data = {}\n            headers.update(\n                {\n                    \"Link\": '<{}>; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\n                        alternate_url_json\n                    )\n                }\n            )\n            metadata = await datasette.get_database_metadata(database)\n\n            renderers = {}\n            for key, (_, can_render) in datasette.renderers.items():\n                it_can_render = call_with_supported_arguments(\n                    can_render,\n                    datasette=datasette,\n                    columns=data.get(\"columns\") or [],\n                    rows=data.get(\"rows\") or [],\n                    sql=data.get(\"query\", {}).get(\"sql\", None),\n                    query_name=data.get(\"query_name\"),\n                    database=database,\n                    table=data.get(\"table\"),\n                    request=request,\n                    view_name=\"database\",\n                )\n                it_can_render = await await_me_maybe(it_can_render)\n                if it_can_render:\n                    renderers[key] = datasette.urls.path(\n                        path_with_format(request=request, format=key)\n                    )\n\n            allow_execute_sql = await datasette.allowed(\n                action=\"execute-sql\",\n                resource=DatabaseResource(database=database),\n                actor=request.actor,\n            )\n\n            show_hide_hidden = \"\"\n            if canned_query and canned_query.get(\"hide_sql\"):\n                if bool(params.get(\"_show_sql\")):\n                    show_hide_link = path_with_removed_args(request, {\"_show_sql\"})\n                    show_hide_text = \"hide\"\n                    show_hide_hidden = (\n                        '<input type=\"hidden\" name=\"_show_sql\" value=\"1\">'\n                    )\n                else:\n                    show_hide_link = path_with_added_args(request, {\"_show_sql\": 1})\n                    show_hide_text = \"show\"\n            else:\n                if bool(params.get(\"_hide_sql\")):\n                    show_hide_link = path_with_removed_args(request, {\"_hide_sql\"})\n                    show_hide_text = \"show\"\n                    show_hide_hidden = (\n                        '<input type=\"hidden\" name=\"_hide_sql\" value=\"1\">'\n                    )\n                else:\n                    show_hide_link = path_with_added_args(request, {\"_hide_sql\": 1})\n                    show_hide_text = \"hide\"\n            hide_sql = show_hide_text == \"show\"\n\n            # Show 'Edit SQL' button only if:\n            # - User is allowed to execute SQL\n            # - SQL is an approved SELECT statement\n            # - No magic parameters, so no :_ in the SQL string\n            edit_sql_url = None\n            is_validated_sql = False\n            try:\n                validate_sql_select(sql)\n                is_validated_sql = True\n            except InvalidSql:\n                pass\n            if allow_execute_sql and is_validated_sql and \":_\" not in sql:\n                edit_sql_url = (\n                    datasette.urls.database(database)\n                    + \"/-/query\"\n                    + \"?\"\n                    + urlencode(\n                        {\n                            **{\n                                \"sql\": sql,\n                            },\n                            **named_parameter_values,\n                        }\n                    )\n                )\n\n            async def query_actions():\n                query_actions = []\n                for hook in pm.hook.query_actions(\n                    datasette=datasette,\n                    actor=request.actor,\n                    database=database,\n                    query_name=canned_query[\"name\"] if canned_query else None,\n                    request=request,\n                    sql=sql,\n                    params=params,\n                ):\n                    extra_links = await await_me_maybe(hook)\n                    if extra_links:\n                        query_actions.extend(extra_links)\n                return query_actions\n\n            r = Response.html(\n                await datasette.render_template(\n                    template,\n                    QueryContext(\n                        database=database,\n                        database_color=db.color,\n                        query={\n                            \"sql\": sql,\n                            \"params\": params,\n                        },\n                        canned_query=canned_query[\"name\"] if canned_query else None,\n                        private=private,\n                        canned_query_write=canned_query_write,\n                        db_is_immutable=not db.is_mutable,\n                        error=query_error,\n                        hide_sql=hide_sql,\n                        show_hide_link=datasette.urls.path(show_hide_link),\n                        show_hide_text=show_hide_text,\n                        editable=not canned_query,\n                        allow_execute_sql=allow_execute_sql,\n                        tables=await get_tables(datasette, request, db, allowed_dict),\n                        named_parameter_values=named_parameter_values,\n                        edit_sql_url=edit_sql_url,\n                        display_rows=await display_rows(\n                            datasette, database, request, rows, columns\n                        ),\n                        table_columns=(\n                            await _table_columns(datasette, database)\n                            if allow_execute_sql\n                            else {}\n                        ),\n                        columns=columns,\n                        renderers=renderers,\n                        url_csv=datasette.urls.path(\n                            path_with_format(\n                                request=request, format=\"csv\", extra_qs={\"_size\": \"max\"}\n                            )\n                        ),\n                        show_hide_hidden=markupsafe.Markup(show_hide_hidden),\n                        metadata=canned_query or metadata,\n                        alternate_url_json=alternate_url_json,\n                        select_templates=[\n                            f\"{'*' if template_name == template.name else ''}{template_name}\"\n                            for template_name in templates\n                        ],\n                        top_query=make_slot_function(\n                            \"top_query\", datasette, request, database=database, sql=sql\n                        ),\n                        top_canned_query=make_slot_function(\n                            \"top_canned_query\",\n                            datasette,\n                            request,\n                            database=database,\n                            query_name=canned_query[\"name\"] if canned_query else None,\n                        ),\n                        query_actions=query_actions,\n                    ),\n                    request=request,\n                    view_name=\"database\",\n                ),\n                headers=headers,\n            )\n        else:\n            assert False, \"Invalid format: {}\".format(format_)\n        if datasette.cors:\n            add_cors_headers(r.headers)\n        return r\n\n\nclass MagicParameters(dict):\n    def __init__(self, sql, data, request, datasette):\n        super().__init__(data)\n        self._sql = sql\n        self._request = request\n        self._magics = dict(\n            itertools.chain.from_iterable(\n                pm.hook.register_magic_parameters(datasette=datasette)\n            )\n        )\n        self._prepared = {}\n\n    async def execute_params(self):\n        for key in derive_named_parameters(self._sql):\n            if key.startswith(\"_\") and key.count(\"_\") >= 2:\n                prefix, suffix = key[1:].split(\"_\", 1)\n                if prefix in self._magics:\n                    result = await await_me_maybe(\n                        self._magics[prefix](suffix, self._request)\n                    )\n                    self._prepared[key] = result\n\n    def __len__(self):\n        # Workaround for 'Incorrect number of bindings' error\n        # https://github.com/simonw/datasette/issues/967#issuecomment-692951144\n        return super().__len__() or 1\n\n    def __getitem__(self, key):\n        if key.startswith(\"_\") and key.count(\"_\") >= 2:\n            if key in self._prepared:\n                return self._prepared[key]\n            # Try the other route\n            prefix, suffix = key[1:].split(\"_\", 1)\n            if prefix in self._magics:\n                try:\n                    return self._magics[prefix](suffix, self._request)\n                except KeyError:\n                    return super().__getitem__(key)\n        else:\n            return super().__getitem__(key)\n\n\nclass TableCreateView(BaseView):\n    name = \"table-create\"\n\n    _valid_keys = {\n        \"table\",\n        \"rows\",\n        \"row\",\n        \"columns\",\n        \"pk\",\n        \"pks\",\n        \"ignore\",\n        \"replace\",\n        \"alter\",\n    }\n    _supported_column_types = {\n        \"text\",\n        \"integer\",\n        \"float\",\n        \"blob\",\n    }\n    # Any string that does not contain a newline or start with sqlite_\n    _table_name_re = re.compile(r\"^(?!sqlite_)[^\\n]+$\")\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def post(self, request):\n        db = await self.ds.resolve_database(request)\n        database_name = db.name\n\n        # Must have create-table permission\n        if not await self.ds.allowed(\n            action=\"create-table\",\n            resource=DatabaseResource(database=database_name),\n            actor=request.actor,\n        ):\n            return _error([\"Permission denied\"], 403)\n\n        body = await request.post_body()\n        try:\n            data = json.loads(body)\n        except json.JSONDecodeError as e:\n            return _error([\"Invalid JSON: {}\".format(e)])\n\n        if not isinstance(data, dict):\n            return _error([\"JSON must be an object\"])\n\n        invalid_keys = set(data.keys()) - self._valid_keys\n        if invalid_keys:\n            return _error([\"Invalid keys: {}\".format(\", \".join(invalid_keys))])\n\n        # ignore and replace are mutually exclusive\n        if data.get(\"ignore\") and data.get(\"replace\"):\n            return _error([\"ignore and replace are mutually exclusive\"])\n\n        # ignore and replace only allowed with row or rows\n        if \"ignore\" in data or \"replace\" in data:\n            if not data.get(\"row\") and not data.get(\"rows\"):\n                return _error([\"ignore and replace require row or rows\"])\n\n        # ignore and replace require pk or pks\n        if \"ignore\" in data or \"replace\" in data:\n            if not data.get(\"pk\") and not data.get(\"pks\"):\n                return _error([\"ignore and replace require pk or pks\"])\n\n        ignore = data.get(\"ignore\")\n        replace = data.get(\"replace\")\n\n        if replace:\n            # Must have update-row permission\n            if not await self.ds.allowed(\n                action=\"update-row\",\n                resource=DatabaseResource(database=database_name),\n                actor=request.actor,\n            ):\n                return _error([\"Permission denied: need update-row\"], 403)\n\n        table_name = data.get(\"table\")\n        if not table_name:\n            return _error([\"Table is required\"])\n\n        if not self._table_name_re.match(table_name):\n            return _error([\"Invalid table name\"])\n\n        table_exists = await db.table_exists(data[\"table\"])\n        columns = data.get(\"columns\")\n        rows = data.get(\"rows\")\n        row = data.get(\"row\")\n        if not columns and not rows and not row:\n            return _error([\"columns, rows or row is required\"])\n\n        if rows and row:\n            return _error([\"Cannot specify both rows and row\"])\n\n        if rows or row:\n            # Must have insert-row permission\n            if not await self.ds.allowed(\n                action=\"insert-row\",\n                resource=DatabaseResource(database=database_name),\n                actor=request.actor,\n            ):\n                return _error([\"Permission denied: need insert-row\"], 403)\n\n        alter = False\n        if rows or row:\n            if not table_exists:\n                # if table is being created for the first time, alter=True\n                alter = True\n            else:\n                # alter=True only if they request it AND they have permission\n                if data.get(\"alter\"):\n                    if not await self.ds.allowed(\n                        action=\"alter-table\",\n                        resource=DatabaseResource(database=database_name),\n                        actor=request.actor,\n                    ):\n                        return _error([\"Permission denied: need alter-table\"], 403)\n                    alter = True\n\n        if columns:\n            if rows or row:\n                return _error([\"Cannot specify columns with rows or row\"])\n            if not isinstance(columns, list):\n                return _error([\"columns must be a list\"])\n            for column in columns:\n                if not isinstance(column, dict):\n                    return _error([\"columns must be a list of objects\"])\n                if not column.get(\"name\") or not isinstance(column.get(\"name\"), str):\n                    return _error([\"Column name is required\"])\n                if not column.get(\"type\"):\n                    column[\"type\"] = \"text\"\n                if column[\"type\"] not in self._supported_column_types:\n                    return _error(\n                        [\"Unsupported column type: {}\".format(column[\"type\"])]\n                    )\n            # No duplicate column names\n            dupes = {c[\"name\"] for c in columns if columns.count(c) > 1}\n            if dupes:\n                return _error([\"Duplicate column name: {}\".format(\", \".join(dupes))])\n\n        if row:\n            rows = [row]\n\n        if rows:\n            if not isinstance(rows, list):\n                return _error([\"rows must be a list\"])\n            for row in rows:\n                if not isinstance(row, dict):\n                    return _error([\"rows must be a list of objects\"])\n\n        pk = data.get(\"pk\")\n        pks = data.get(\"pks\")\n\n        if pk and pks:\n            return _error([\"Cannot specify both pk and pks\"])\n        if pk:\n            if not isinstance(pk, str):\n                return _error([\"pk must be a string\"])\n        if pks:\n            if not isinstance(pks, list):\n                return _error([\"pks must be a list\"])\n            for pk in pks:\n                if not isinstance(pk, str):\n                    return _error([\"pks must be a list of strings\"])\n\n        # If table exists already, read pks from that instead\n        if table_exists:\n            actual_pks = await db.primary_keys(table_name)\n            # if pk passed and table already exists check it does not change\n            bad_pks = False\n            if len(actual_pks) == 1 and data.get(\"pk\") and data[\"pk\"] != actual_pks[0]:\n                bad_pks = True\n            elif (\n                len(actual_pks) > 1\n                and data.get(\"pks\")\n                and set(data[\"pks\"]) != set(actual_pks)\n            ):\n                bad_pks = True\n            if bad_pks:\n                return _error([\"pk cannot be changed for existing table\"])\n            pks = actual_pks\n\n        initial_schema = None\n        if table_exists:\n            initial_schema = await db.execute_fn(\n                lambda conn: sqlite_utils.Database(conn)[table_name].schema\n            )\n\n        def create_table(conn):\n            table = sqlite_utils.Database(conn)[table_name]\n            if rows:\n                table.insert_all(\n                    rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter\n                )\n            else:\n                table.create(\n                    {c[\"name\"]: c[\"type\"] for c in columns},\n                    pk=pks or pk,\n                )\n            return table.schema\n\n        try:\n            schema = await db.execute_write_fn(create_table, request=request)\n        except Exception as e:\n            return _error([str(e)])\n\n        if initial_schema is not None and initial_schema != schema:\n            await self.ds.track_event(\n                AlterTableEvent(\n                    request.actor,\n                    database=database_name,\n                    table=table_name,\n                    before_schema=initial_schema,\n                    after_schema=schema,\n                )\n            )\n\n        table_url = self.ds.absolute_url(\n            request, self.ds.urls.table(db.name, table_name)\n        )\n        table_api_url = self.ds.absolute_url(\n            request, self.ds.urls.table(db.name, table_name, format=\"json\")\n        )\n        details = {\n            \"ok\": True,\n            \"database\": db.name,\n            \"table\": table_name,\n            \"table_url\": table_url,\n            \"table_api_url\": table_api_url,\n            \"schema\": schema,\n        }\n        if rows:\n            details[\"row_count\"] = len(rows)\n\n        if not table_exists:\n            # Only log creation if we created a table\n            await self.ds.track_event(\n                CreateTableEvent(\n                    request.actor, database=db.name, table=table_name, schema=schema\n                )\n            )\n        if rows:\n            await self.ds.track_event(\n                InsertRowsEvent(\n                    request.actor,\n                    database=db.name,\n                    table=table_name,\n                    num_rows=len(rows),\n                    ignore=ignore,\n                    replace=replace,\n                )\n            )\n        return Response.json(details, status=201)\n\n\nasync def _table_columns(datasette, database_name):\n    internal_db = datasette.get_internal_database()\n    result = await internal_db.execute(\n        \"select table_name, name from catalog_columns where database_name = ?\",\n        [database_name],\n    )\n    table_columns = {}\n    for row in result.rows:\n        table_columns.setdefault(row[\"table_name\"], []).append(row[\"name\"])\n    # Add views\n    db = datasette.get_database(database_name)\n    for view_name in await db.view_names():\n        table_columns[view_name] = []\n    return table_columns\n\n\nasync def display_rows(datasette, database, request, rows, columns):\n    display_rows = []\n    truncate_cells = datasette.setting(\"truncate_cells_html\")\n    for row in rows:\n        display_row = []\n        for column, value in zip(columns, row):\n            display_value = value\n            # Let the plugins have a go\n            # pylint: disable=no-member\n            plugin_display_value = None\n            for candidate in pm.hook.render_cell(\n                row=row,\n                value=value,\n                column=column,\n                table=None,\n                pks=[],\n                database=database,\n                datasette=datasette,\n                request=request,\n                column_type=None,\n            ):\n                candidate = await await_me_maybe(candidate)\n                if candidate is not None:\n                    plugin_display_value = candidate\n                    break\n            if plugin_display_value is not None:\n                display_value = plugin_display_value\n            else:\n                if value in (\"\", None):\n                    display_value = markupsafe.Markup(\"&nbsp;\")\n                elif is_url(str(display_value).strip()):\n                    display_value = markupsafe.Markup(\n                        '<a href=\"{url}\">{truncated_url}</a>'.format(\n                            url=markupsafe.escape(value.strip()),\n                            truncated_url=markupsafe.escape(\n                                truncate_url(value.strip(), truncate_cells)\n                            ),\n                        )\n                    )\n                elif isinstance(display_value, bytes):\n                    blob_url = path_with_format(\n                        request=request,\n                        format=\"blob\",\n                        extra_qs={\n                            \"_blob_column\": column,\n                            \"_blob_hash\": hashlib.sha256(display_value).hexdigest(),\n                        },\n                    )\n                    formatted = format_bytes(len(value))\n                    display_value = markupsafe.Markup(\n                        '<a class=\"blob-download\" href=\"{}\"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format(\n                            blob_url,\n                            (\n                                ' title=\"{}\"'.format(formatted)\n                                if \"bytes\" not in formatted\n                                else \"\"\n                            ),\n                            len(value),\n                            \"\" if len(value) == 1 else \"s\",\n                        )\n                    )\n                else:\n                    display_value = str(value)\n                    if truncate_cells and len(display_value) > truncate_cells:\n                        display_value = display_value[:truncate_cells] + \"\\u2026\"\n            display_row.append(display_value)\n        display_rows.append(display_row)\n    return display_rows\n"
  },
  {
    "path": "datasette/views/index.py",
    "content": "import json\n\nfrom datasette.plugins import pm\nfrom datasette.utils import (\n    add_cors_headers,\n    await_me_maybe,\n    make_slot_function,\n    CustomJSONEncoder,\n)\nfrom datasette.utils.asgi import Response\nfrom datasette.version import __version__\n\nfrom .base import BaseView\n\n# Truncate table list on homepage at:\nTRUNCATE_AT = 5\n\n# Only attempt counts if database less than this size in bytes:\nCOUNT_DB_SIZE_LIMIT = 100 * 1024 * 1024\n\n\nclass IndexView(BaseView):\n    name = \"index\"\n\n    async def get(self, request):\n        as_format = request.url_vars[\"format\"]\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n\n        # Get all allowed databases and tables in bulk\n        db_page = await self.ds.allowed_resources(\n            \"view-database\", request.actor, include_is_private=True\n        )\n        allowed_databases = [r async for r in db_page.all()]\n        allowed_db_dict = {r.parent: r for r in allowed_databases}\n\n        # Group tables by database\n        tables_by_db = {}\n        table_page = await self.ds.allowed_resources(\n            \"view-table\", request.actor, include_is_private=True\n        )\n        async for t in table_page.all():\n            if t.parent not in tables_by_db:\n                tables_by_db[t.parent] = {}\n            tables_by_db[t.parent][t.child] = t\n\n        databases = []\n        # Iterate over allowed databases instead of all databases\n        for name in allowed_db_dict.keys():\n            db = self.ds.databases[name]\n            database_private = allowed_db_dict[name].private\n\n            # Get allowed tables/views for this database\n            allowed_for_db = tables_by_db.get(name, {})\n\n            # Get table names from allowed set instead of db.table_names()\n            table_names = [child_name for child_name in allowed_for_db.keys()]\n\n            hidden_table_names = set(await db.hidden_table_names())\n\n            # Determine which allowed items are views\n            view_names_set = set(await db.view_names())\n            views = [\n                {\"name\": child_name, \"private\": resource.private}\n                for child_name, resource in allowed_for_db.items()\n                if child_name in view_names_set\n            ]\n\n            # Filter to just tables (not views) for table processing\n            table_names = [name for name in table_names if name not in view_names_set]\n\n            # Perform counts only for immutable or DBS with <= COUNT_TABLE_LIMIT tables\n            table_counts = {}\n            if not db.is_mutable or db.size < COUNT_DB_SIZE_LIMIT:\n                table_counts = await db.table_counts(10)\n                # If any of these are None it means at least one timed out - ignore them all\n                if any(v is None for v in table_counts.values()):\n                    table_counts = {}\n\n            tables = {}\n            for table in table_names:\n                # Check if table is in allowed set\n                if table not in allowed_for_db:\n                    continue\n\n                table_columns = await db.table_columns(table)\n                tables[table] = {\n                    \"name\": table,\n                    \"columns\": table_columns,\n                    \"primary_keys\": await db.primary_keys(table),\n                    \"count\": table_counts.get(table),\n                    \"hidden\": table in hidden_table_names,\n                    \"fts_table\": await db.fts_table(table),\n                    \"num_relationships_for_sorting\": 0,\n                    \"private\": allowed_for_db[table].private,\n                }\n\n            if request.args.get(\"_sort\") == \"relationships\" or not table_counts:\n                # We will be sorting by number of relationships, so populate that field\n                all_foreign_keys = await db.get_all_foreign_keys()\n                for table, foreign_keys in all_foreign_keys.items():\n                    if table in tables.keys():\n                        count = len(foreign_keys[\"incoming\"] + foreign_keys[\"outgoing\"])\n                        tables[table][\"num_relationships_for_sorting\"] = count\n\n            hidden_tables = [t for t in tables.values() if t[\"hidden\"]]\n            visible_tables = [t for t in tables.values() if not t[\"hidden\"]]\n\n            tables_and_views_truncated = list(\n                sorted(\n                    (t for t in tables.values() if t not in hidden_tables),\n                    key=lambda t: (\n                        t[\"num_relationships_for_sorting\"],\n                        t[\"count\"] or 0,\n                        t[\"name\"],\n                    ),\n                    reverse=True,\n                )[:TRUNCATE_AT]\n            )\n\n            # Only add views if this is less than TRUNCATE_AT\n            if len(tables_and_views_truncated) < TRUNCATE_AT:\n                num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)\n                for view in views[:num_views_to_add]:\n                    tables_and_views_truncated.append(view)\n\n            databases.append(\n                {\n                    \"name\": name,\n                    \"hash\": db.hash,\n                    \"color\": db.color,\n                    \"path\": self.ds.urls.database(name),\n                    \"tables_and_views_truncated\": tables_and_views_truncated,\n                    \"tables_and_views_more\": (len(visible_tables) + len(views))\n                    > TRUNCATE_AT,\n                    \"tables_count\": len(visible_tables),\n                    \"table_rows_sum\": sum((t[\"count\"] or 0) for t in visible_tables),\n                    \"show_table_row_counts\": bool(table_counts),\n                    \"hidden_table_rows_sum\": sum(\n                        t[\"count\"] for t in hidden_tables if t[\"count\"] is not None\n                    ),\n                    \"hidden_tables_count\": len(hidden_tables),\n                    \"views_count\": len(views),\n                    \"private\": database_private,\n                }\n            )\n\n        if as_format:\n            headers = {}\n            if self.ds.cors:\n                add_cors_headers(headers)\n            return Response(\n                json.dumps(\n                    {\n                        \"databases\": {db[\"name\"]: db for db in databases},\n                        \"metadata\": await self.ds.get_instance_metadata(),\n                    },\n                    cls=CustomJSONEncoder,\n                ),\n                content_type=\"application/json; charset=utf-8\",\n                headers=headers,\n            )\n        else:\n            homepage_actions = []\n            for hook in pm.hook.homepage_actions(\n                datasette=self.ds,\n                actor=request.actor,\n                request=request,\n            ):\n                extra_links = await await_me_maybe(hook)\n                if extra_links:\n                    homepage_actions.extend(extra_links)\n            alternative_homepage = request.path == \"/-/\"\n            return await self.render(\n                [\"default:index.html\" if alternative_homepage else \"index.html\"],\n                request=request,\n                context={\n                    \"databases\": databases,\n                    \"metadata\": await self.ds.get_instance_metadata(),\n                    \"datasette_version\": __version__,\n                    \"private\": not await self.ds.allowed(\n                        action=\"view-instance\", actor=None\n                    ),\n                    \"top_homepage\": make_slot_function(\n                        \"top_homepage\", self.ds, request\n                    ),\n                    \"homepage_actions\": homepage_actions,\n                    \"noindex\": request.path == \"/-/\",\n                },\n            )\n"
  },
  {
    "path": "datasette/views/row.py",
    "content": "from datasette.utils.asgi import NotFound, Forbidden, Response\nfrom datasette.database import QueryInterrupted\nfrom datasette.events import UpdateRowEvent, DeleteRowEvent\nfrom datasette.resources import TableResource\nfrom .base import DataView, BaseView, _error\nfrom datasette.utils import (\n    await_me_maybe,\n    CustomRow,\n    make_slot_function,\n    to_css_class,\n    escape_sqlite,\n)\nfrom datasette.plugins import pm\nimport json\nimport markupsafe\nimport sqlite_utils\nfrom .table import display_columns_and_rows, _get_extras\n\n\nclass RowView(DataView):\n    name = \"row\"\n\n    async def data(self, request, default_labels=False):\n        resolved = await self.ds.resolve_row(request)\n        db = resolved.db\n        database = db.name\n        table = resolved.table\n        pk_values = resolved.pk_values\n\n        # Ensure user has permission to view this row\n        visible, private = await self.ds.check_visibility(\n            request.actor,\n            action=\"view-table\",\n            resource=TableResource(database=database, table=table),\n        )\n        if not visible:\n            raise Forbidden(\"You do not have permission to view this table\")\n\n        results = await resolved.db.execute(\n            resolved.sql, resolved.params, truncate=True\n        )\n        columns = [r[0] for r in results.description]\n        rows = list(results.rows)\n        if not rows:\n            raise NotFound(f\"Record not found: {pk_values}\")\n\n        pks = resolved.pks\n\n        async def template_data():\n            # Reorder columns so primary keys come first\n            pk_set = set(pks)\n            pk_cols = [d for d in results.description if d[0] in pk_set]\n            non_pk_cols = [d for d in results.description if d[0] not in pk_set]\n            reordered_description = pk_cols + non_pk_cols\n            reordered_columns = [d[0] for d in reordered_description]\n\n            # Reorder row data to match\n            reordered_rows = []\n            for row in rows:\n                new_row = CustomRow(reordered_columns)\n                for col in reordered_columns:\n                    new_row[col] = row[col]\n                reordered_rows.append(new_row)\n\n            # Expand foreign key columns into dicts so display_columns_and_rows\n            # renders them as hyperlinks, matching the table view behavior\n            expanded_rows = reordered_rows\n            for fk in await db.foreign_keys_for_table(table):\n                column = fk[\"column\"]\n                if column not in reordered_columns:\n                    continue\n                column_index = reordered_columns.index(column)\n                values = [row[column_index] for row in expanded_rows]\n                expanded_labels = await self.ds.expand_foreign_keys(\n                    request.actor, database, table, column, values\n                )\n                if expanded_labels:\n                    new_rows = []\n                    for row in expanded_rows:\n                        new_row = CustomRow(reordered_columns)\n                        for col in reordered_columns:\n                            value = row[col]\n                            if (\n                                col == column\n                                and (col, value) in expanded_labels\n                                and value is not None\n                            ):\n                                new_row[col] = {\n                                    \"value\": value,\n                                    \"label\": expanded_labels[(col, value)],\n                                }\n                            else:\n                                new_row[col] = value\n                        new_rows.append(new_row)\n                    expanded_rows = new_rows\n\n            display_columns, display_rows = await display_columns_and_rows(\n                self.ds,\n                database,\n                table,\n                reordered_description,\n                expanded_rows,\n                link_column=False,\n                truncate_cells=0,\n                request=request,\n            )\n            for column in display_columns:\n                column[\"sortable\"] = False\n\n            # Bold primary key cell values\n            for row in display_rows:\n                for cell in row:\n                    if cell[\"column\"] in pk_set:\n                        cell[\"value\"] = markupsafe.Markup(\n                            \"<strong>{}</strong>\".format(cell[\"value\"])\n                        )\n\n            row_actions = []\n            for hook in pm.hook.row_actions(\n                datasette=self.ds,\n                actor=request.actor,\n                request=request,\n                database=database,\n                table=table,\n                row=rows[0],\n            ):\n                extra_links = await await_me_maybe(hook)\n                if extra_links:\n                    row_actions.extend(extra_links)\n\n            return {\n                \"private\": private,\n                \"columns\": reordered_columns,\n                \"foreign_key_tables\": await self.foreign_key_tables(\n                    database, table, pk_values\n                ),\n                \"database_color\": db.color,\n                \"display_columns\": display_columns,\n                \"display_rows\": display_rows,\n                \"custom_table_templates\": [\n                    f\"_table-{to_css_class(database)}-{to_css_class(table)}.html\",\n                    f\"_table-row-{to_css_class(database)}-{to_css_class(table)}.html\",\n                    \"_table.html\",\n                ],\n                \"row_actions\": row_actions,\n                \"top_row\": make_slot_function(\n                    \"top_row\",\n                    self.ds,\n                    request,\n                    database=resolved.db.name,\n                    table=resolved.table,\n                    row=rows[0],\n                ),\n                \"metadata\": {},\n            }\n\n        data = {\n            \"ok\": True,\n            \"database\": database,\n            \"table\": table,\n            \"rows\": rows,\n            \"columns\": columns,\n            \"primary_keys\": resolved.pks,\n            \"primary_key_values\": pk_values,\n        }\n\n        # Handle _extra parameter (new style)\n        extras = _get_extras(request)\n\n        # Also support legacy _extras parameter for backward compatibility\n        if \"foreign_key_tables\" in (request.args.get(\"_extras\") or \"\").split(\",\"):\n            extras.add(\"foreign_key_tables\")\n\n        # Process extras\n        if \"foreign_key_tables\" in extras:\n            data[\"foreign_key_tables\"] = await self.foreign_key_tables(\n                database, table, pk_values\n            )\n\n        if \"render_cell\" in extras:\n            # Call render_cell plugin hook for each cell\n            ct_map = await self.ds.get_column_types(database, table)\n            rendered_rows = []\n            for row in rows:\n                rendered_row = {}\n                for value, column in zip(row, columns):\n                    ct = ct_map.get(column)\n                    plugin_display_value = None\n                    # Try column type render_cell first\n                    if ct:\n                        candidate = await ct.render_cell(\n                            value=value,\n                            column=column,\n                            table=table,\n                            database=database,\n                            datasette=self.ds,\n                            request=request,\n                        )\n                        if candidate is not None:\n                            plugin_display_value = candidate\n                    if plugin_display_value is None:\n                        for candidate in pm.hook.render_cell(\n                            row=row,\n                            value=value,\n                            column=column,\n                            table=table,\n                            pks=resolved.pks,\n                            database=database,\n                            datasette=self.ds,\n                            request=request,\n                            column_type=ct,\n                        ):\n                            candidate = await await_me_maybe(candidate)\n                            if candidate is not None:\n                                plugin_display_value = candidate\n                                break\n                    if plugin_display_value:\n                        rendered_row[column] = str(plugin_display_value)\n                rendered_rows.append(rendered_row)\n            data[\"render_cell\"] = rendered_rows\n\n        return (\n            data,\n            template_data,\n            (\n                f\"row-{to_css_class(database)}-{to_css_class(table)}.html\",\n                \"row.html\",\n            ),\n        )\n\n    async def foreign_key_tables(self, database, table, pk_values):\n        if len(pk_values) != 1:\n            return []\n        db = self.ds.databases[database]\n        all_foreign_keys = await db.get_all_foreign_keys()\n        foreign_keys = all_foreign_keys[table][\"incoming\"]\n        if len(foreign_keys) == 0:\n            return []\n\n        sql = \"select \" + \", \".join(\n            [\n                \"(select count(*) from {table} where {column}=:id)\".format(\n                    table=escape_sqlite(fk[\"other_table\"]),\n                    column=escape_sqlite(fk[\"other_column\"]),\n                )\n                for fk in foreign_keys\n            ]\n        )\n        try:\n            rows = list(await db.execute(sql, {\"id\": pk_values[0]}))\n        except QueryInterrupted:\n            # Almost certainly hit the timeout\n            return []\n\n        foreign_table_counts = dict(\n            zip(\n                [(fk[\"other_table\"], fk[\"other_column\"]) for fk in foreign_keys],\n                list(rows[0]),\n            )\n        )\n        foreign_key_tables = []\n        for fk in foreign_keys:\n            count = (\n                foreign_table_counts.get((fk[\"other_table\"], fk[\"other_column\"])) or 0\n            )\n            key = fk[\"other_column\"]\n            if key.startswith(\"_\"):\n                key += \"__exact\"\n            link = \"{}?{}={}\".format(\n                self.ds.urls.table(database, fk[\"other_table\"]),\n                key,\n                \",\".join(pk_values),\n            )\n            foreign_key_tables.append({**fk, **{\"count\": count, \"link\": link}})\n        return foreign_key_tables\n\n\nclass RowError(Exception):\n    def __init__(self, error):\n        self.error = error\n\n\nasync def _resolve_row_and_check_permission(datasette, request, permission):\n    from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound\n\n    try:\n        resolved = await datasette.resolve_row(request)\n    except DatabaseNotFound as e:\n        return False, _error([\"Database not found: {}\".format(e.database_name)], 404)\n    except TableNotFound as e:\n        return False, _error([\"Table not found: {}\".format(e.table)], 404)\n    except RowNotFound as e:\n        return False, _error([\"Record not found: {}\".format(e.pk_values)], 404)\n\n    # Ensure user has permission to delete this row\n    if not await datasette.allowed(\n        action=permission,\n        resource=TableResource(database=resolved.db.name, table=resolved.table),\n        actor=request.actor,\n    ):\n        return False, _error([\"Permission denied\"], 403)\n\n    return True, resolved\n\n\nclass RowDeleteView(BaseView):\n    name = \"row-delete\"\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def post(self, request):\n        ok, resolved = await _resolve_row_and_check_permission(\n            self.ds, request, \"delete-row\"\n        )\n        if not ok:\n            return resolved\n\n        # Delete table\n        def delete_row(conn):\n            sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)\n\n        try:\n            await resolved.db.execute_write_fn(delete_row, request=request)\n        except Exception as e:\n            return _error([str(e)], 500)\n\n        await self.ds.track_event(\n            DeleteRowEvent(\n                actor=request.actor,\n                database=resolved.db.name,\n                table=resolved.table,\n                pks=resolved.pk_values,\n            )\n        )\n\n        return Response.json({\"ok\": True}, status=200)\n\n\nclass RowUpdateView(BaseView):\n    name = \"row-update\"\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def post(self, request):\n        ok, resolved = await _resolve_row_and_check_permission(\n            self.ds, request, \"update-row\"\n        )\n        if not ok:\n            return resolved\n\n        body = await request.post_body()\n        try:\n            data = json.loads(body)\n        except json.JSONDecodeError as e:\n            return _error([\"Invalid JSON: {}\".format(e)])\n\n        if not isinstance(data, dict):\n            return _error([\"JSON must be a dictionary\"])\n        if \"update\" not in data or not isinstance(data[\"update\"], dict):\n            return _error([\"JSON must contain an update dictionary\"])\n\n        invalid_keys = set(data.keys()) - {\"update\", \"return\", \"alter\"}\n        if invalid_keys:\n            return _error([\"Invalid keys: {}\".format(\", \".join(invalid_keys))])\n\n        update = data[\"update\"]\n\n        # Validate column types\n        from datasette.views.table import _validate_column_types\n\n        ct_errors = await _validate_column_types(\n            self.ds, resolved.db.name, resolved.table, [update]\n        )\n        if ct_errors:\n            return _error(ct_errors, 400)\n\n        alter = data.get(\"alter\")\n        if alter and not await self.ds.allowed(\n            action=\"alter-table\",\n            resource=TableResource(database=resolved.db.name, table=resolved.table),\n            actor=request.actor,\n        ):\n            return _error([\"Permission denied for alter-table\"], 403)\n\n        def update_row(conn):\n            sqlite_utils.Database(conn)[resolved.table].update(\n                resolved.pk_values, update, alter=alter\n            )\n\n        try:\n            await resolved.db.execute_write_fn(update_row, request=request)\n        except Exception as e:\n            return _error([str(e)], 400)\n\n        result = {\"ok\": True}\n        if data.get(\"return\"):\n            results = await resolved.db.execute(\n                resolved.sql, resolved.params, truncate=True\n            )\n            result[\"row\"] = results.dicts()[0]\n\n        await self.ds.track_event(\n            UpdateRowEvent(\n                actor=request.actor,\n                database=resolved.db.name,\n                table=resolved.table,\n                pks=resolved.pk_values,\n            )\n        )\n\n        return Response.json(result, status=200)\n"
  },
  {
    "path": "datasette/views/special.py",
    "content": "import json\nimport logging\nfrom datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent\nfrom datasette.resources import DatabaseResource, TableResource\nfrom datasette.utils.asgi import Response, Forbidden\nfrom datasette.utils import (\n    actor_matches_allow,\n    add_cors_headers,\n    tilde_encode,\n    tilde_decode,\n)\nfrom .base import BaseView, View\nimport secrets\nimport urllib\n\nlogger = logging.getLogger(__name__)\n\n\ndef _resource_path(parent, child):\n    if parent is None:\n        return \"/\"\n    if child is None:\n        return f\"/{parent}\"\n    return f\"/{parent}/{child}\"\n\n\nclass JsonDataView(BaseView):\n    name = \"json_data\"\n    template = \"show_json.html\"  # Can be overridden in subclasses\n\n    def __init__(\n        self,\n        datasette,\n        filename,\n        data_callback,\n        needs_request=False,\n        permission=\"view-instance\",\n        template=None,\n    ):\n        self.ds = datasette\n        self.filename = filename\n        self.data_callback = data_callback\n        self.needs_request = needs_request\n        self.permission = permission\n        if template is not None:\n            self.template = template\n\n    async def get(self, request):\n        if self.permission:\n            await self.ds.ensure_permission(action=self.permission, actor=request.actor)\n        if self.needs_request:\n            data = self.data_callback(request)\n        else:\n            data = self.data_callback()\n\n        # Return JSON or HTML depending on format parameter\n        as_format = request.url_vars.get(\"format\")\n        if as_format:\n            headers = {}\n            if self.ds.cors:\n                add_cors_headers(headers)\n            return Response.json(data, headers=headers)\n        else:\n            context = {\n                \"filename\": self.filename,\n                \"data\": data,\n                \"data_json\": json.dumps(data, indent=4, default=repr),\n            }\n            # Add has_debug_permission if this view requires permissions-debug\n            if self.permission == \"permissions-debug\":\n                context[\"has_debug_permission\"] = True\n            return await self.render(\n                [self.template],\n                request=request,\n                context=context,\n            )\n\n\nclass PatternPortfolioView(View):\n    async def get(self, request, datasette):\n        await datasette.ensure_permission(action=\"view-instance\", actor=request.actor)\n        return Response.html(\n            await datasette.render_template(\n                \"patterns.html\",\n                request=request,\n                view_name=\"patterns\",\n            )\n        )\n\n\nclass AuthTokenView(BaseView):\n    name = \"auth_token\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        # If already signed in as root, redirect\n        if request.actor and request.actor.get(\"id\") == \"root\":\n            return Response.redirect(self.ds.urls.instance())\n        token = request.args.get(\"token\") or \"\"\n        if not self.ds._root_token:\n            raise Forbidden(\"Root token has already been used\")\n        if secrets.compare_digest(token, self.ds._root_token):\n            self.ds._root_token = None\n            response = Response.redirect(self.ds.urls.instance())\n            root_actor = {\"id\": \"root\"}\n            self.ds.set_actor_cookie(response, root_actor)\n            await self.ds.track_event(LoginEvent(actor=root_actor))\n            return response\n        else:\n            raise Forbidden(\"Invalid token\")\n\n\nclass LogoutView(BaseView):\n    name = \"logout\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        if not request.actor:\n            return Response.redirect(self.ds.urls.instance())\n        return await self.render(\n            [\"logout.html\"],\n            request,\n            {\"actor\": request.actor},\n        )\n\n    async def post(self, request):\n        response = Response.redirect(self.ds.urls.instance())\n        self.ds.delete_actor_cookie(response)\n        self.ds.add_message(request, \"You are now logged out\", self.ds.WARNING)\n        await self.ds.track_event(LogoutEvent(actor=request.actor))\n        return response\n\n\nclass PermissionsDebugView(BaseView):\n    name = \"permissions_debug\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n        await self.ds.ensure_permission(action=\"permissions-debug\", actor=request.actor)\n        filter_ = request.args.get(\"filter\") or \"all\"\n        permission_checks = list(reversed(self.ds._permission_checks))\n        if filter_ == \"exclude-yours\":\n            permission_checks = [\n                check\n                for check in permission_checks\n                if (check.actor or {}).get(\"id\") != request.actor[\"id\"]\n            ]\n        elif filter_ == \"only-yours\":\n            permission_checks = [\n                check\n                for check in permission_checks\n                if (check.actor or {}).get(\"id\") == request.actor[\"id\"]\n            ]\n        return await self.render(\n            [\"debug_permissions_playground.html\"],\n            request,\n            # list() avoids error if check is performed during template render:\n            {\n                \"permission_checks\": permission_checks,\n                \"filter\": filter_,\n                \"has_debug_permission\": True,\n                \"permissions\": [\n                    {\n                        \"name\": p.name,\n                        \"abbr\": p.abbr,\n                        \"description\": p.description,\n                        \"takes_parent\": p.takes_parent,\n                        \"takes_child\": p.takes_child,\n                    }\n                    for p in self.ds.actions.values()\n                ],\n            },\n        )\n\n    async def post(self, request):\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n        await self.ds.ensure_permission(action=\"permissions-debug\", actor=request.actor)\n        form = await request.form()\n        actor = json.loads(form[\"actor\"])\n        permission = form[\"permission\"]\n        parent = form.get(\"resource_1\") or None\n        child = form.get(\"resource_2\") or None\n\n        response, status = await _check_permission_for_actor(\n            self.ds, permission, parent, child, actor\n        )\n        return Response.json(response, status=status)\n\n\nclass AllowedResourcesView(BaseView):\n    name = \"allowed\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        await self.ds.refresh_schemas()\n\n        # Check if user has permissions-debug (to show sensitive fields)\n        has_debug_permission = await self.ds.allowed(\n            action=\"permissions-debug\", actor=request.actor\n        )\n\n        # Check if this is a request for JSON (has .json extension)\n        as_format = request.url_vars.get(\"format\")\n\n        if not as_format:\n            # Render the HTML form (even if query parameters are present)\n            # Put most common/interesting actions first\n            priority_actions = [\n                \"view-instance\",\n                \"view-database\",\n                \"view-table\",\n                \"view-query\",\n                \"execute-sql\",\n                \"insert-row\",\n                \"update-row\",\n                \"delete-row\",\n            ]\n            actions = list(self.ds.actions.keys())\n            # Priority actions first (in order), then remaining alphabetically\n            sorted_actions = [a for a in priority_actions if a in actions]\n            sorted_actions.extend(\n                sorted(a for a in actions if a not in priority_actions)\n            )\n\n            return await self.render(\n                [\"debug_allowed.html\"],\n                request,\n                {\n                    \"supported_actions\": sorted_actions,\n                    \"has_debug_permission\": has_debug_permission,\n                },\n            )\n\n        payload, status = await self._allowed_payload(request, has_debug_permission)\n        headers = {}\n        if self.ds.cors:\n            add_cors_headers(headers)\n        return Response.json(payload, status=status, headers=headers)\n\n    async def _allowed_payload(self, request, has_debug_permission):\n        action = request.args.get(\"action\")\n        if not action:\n            return {\"error\": \"action parameter is required\"}, 400\n        if action not in self.ds.actions:\n            return {\"error\": f\"Unknown action: {action}\"}, 404\n\n        actor = request.actor if isinstance(request.actor, dict) else None\n        actor_id = actor.get(\"id\") if actor else None\n        parent_filter = request.args.get(\"parent\")\n        child_filter = request.args.get(\"child\")\n        if child_filter and not parent_filter:\n            return {\"error\": \"parent must be provided when child is specified\"}, 400\n\n        try:\n            page = int(request.args.get(\"page\", \"1\"))\n            page_size = int(request.args.get(\"page_size\", \"50\"))\n        except ValueError:\n            return {\"error\": \"page and page_size must be integers\"}, 400\n        if page < 1:\n            return {\"error\": \"page must be >= 1\"}, 400\n        if page_size < 1:\n            return {\"error\": \"page_size must be >= 1\"}, 400\n        max_page_size = 200\n        if page_size > max_page_size:\n            page_size = max_page_size\n        offset = (page - 1) * page_size\n\n        # Use the simplified allowed_resources method\n        # Collect all resources with optional reasons for debugging\n        try:\n            allowed_rows = []\n            result = await self.ds.allowed_resources(\n                action=action,\n                actor=actor,\n                parent=parent_filter,\n                include_reasons=has_debug_permission,\n            )\n            async for resource in result.all():\n                parent_val = resource.parent\n                child_val = resource.child\n\n                # Build resource path\n                if parent_val is None:\n                    resource_path = \"/\"\n                elif child_val is None:\n                    resource_path = f\"/{parent_val}\"\n                else:\n                    resource_path = f\"/{parent_val}/{child_val}\"\n\n                row = {\n                    \"parent\": parent_val,\n                    \"child\": child_val,\n                    \"resource\": resource_path,\n                }\n\n                # Add reason if we have it (from include_reasons=True)\n                if has_debug_permission and hasattr(resource, \"reasons\"):\n                    row[\"reason\"] = resource.reasons\n\n                allowed_rows.append(row)\n        except Exception:\n            # If catalog tables don't exist yet, return empty results\n            return (\n                {\n                    \"action\": action,\n                    \"actor_id\": actor_id,\n                    \"page\": page,\n                    \"page_size\": page_size,\n                    \"total\": 0,\n                    \"items\": [],\n                },\n                200,\n            )\n\n        # Apply child filter if specified\n        if child_filter is not None:\n            allowed_rows = [row for row in allowed_rows if row[\"child\"] == child_filter]\n\n        # Pagination\n        total = len(allowed_rows)\n        paged_rows = allowed_rows[offset : offset + page_size]\n\n        # Items are already in the right format\n        items = paged_rows\n\n        def build_page_url(page_number):\n            pairs = []\n            for key in request.args:\n                if key in {\"page\", \"page_size\"}:\n                    continue\n                for value in request.args.getlist(key):\n                    pairs.append((key, value))\n            pairs.append((\"page\", str(page_number)))\n            pairs.append((\"page_size\", str(page_size)))\n            query = urllib.parse.urlencode(pairs)\n            return f\"{request.path}?{query}\"\n\n        response = {\n            \"action\": action,\n            \"actor_id\": actor_id,\n            \"page\": page,\n            \"page_size\": page_size,\n            \"total\": total,\n            \"items\": items,\n        }\n\n        if total > offset + page_size:\n            response[\"next_url\"] = build_page_url(page + 1)\n        if page > 1:\n            response[\"previous_url\"] = build_page_url(page - 1)\n\n        return response, 200\n\n\nclass PermissionRulesView(BaseView):\n    name = \"permission_rules\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n        await self.ds.ensure_permission(action=\"permissions-debug\", actor=request.actor)\n\n        # Check if this is a request for JSON (has .json extension)\n        as_format = request.url_vars.get(\"format\")\n\n        if not as_format:\n            # Render the HTML form (even if query parameters are present)\n            return await self.render(\n                [\"debug_rules.html\"],\n                request,\n                {\n                    \"sorted_actions\": sorted(self.ds.actions.keys()),\n                    \"has_debug_permission\": True,\n                },\n            )\n\n        # JSON API - action parameter is required\n        action = request.args.get(\"action\")\n        if not action:\n            return Response.json({\"error\": \"action parameter is required\"}, status=400)\n        if action not in self.ds.actions:\n            return Response.json({\"error\": f\"Unknown action: {action}\"}, status=404)\n\n        actor = request.actor if isinstance(request.actor, dict) else None\n\n        try:\n            page = int(request.args.get(\"page\", \"1\"))\n            page_size = int(request.args.get(\"page_size\", \"50\"))\n        except ValueError:\n            return Response.json(\n                {\"error\": \"page and page_size must be integers\"}, status=400\n            )\n        if page < 1:\n            return Response.json({\"error\": \"page must be >= 1\"}, status=400)\n        if page_size < 1:\n            return Response.json({\"error\": \"page_size must be >= 1\"}, status=400)\n        max_page_size = 200\n        if page_size > max_page_size:\n            page_size = max_page_size\n        offset = (page - 1) * page_size\n\n        from datasette.utils.actions_sql import build_permission_rules_sql\n\n        union_sql, union_params, restriction_sqls = await build_permission_rules_sql(\n            self.ds, actor, action\n        )\n        await self.ds.refresh_schemas()\n        db = self.ds.get_internal_database()\n\n        count_query = f\"\"\"\n        WITH rules AS (\n            {union_sql}\n        )\n        SELECT COUNT(*) AS count\n        FROM rules\n        \"\"\"\n        count_row = (await db.execute(count_query, union_params)).first()\n        total = count_row[\"count\"] if count_row else 0\n\n        data_query = f\"\"\"\n        WITH rules AS (\n            {union_sql}\n        )\n        SELECT parent, child, allow, reason, source_plugin\n        FROM rules\n        ORDER BY allow DESC, (parent IS NOT NULL), parent, child\n        LIMIT :limit OFFSET :offset\n        \"\"\"\n        params = {**union_params, \"limit\": page_size, \"offset\": offset}\n        rows = await db.execute(data_query, params)\n\n        items = []\n        for row in rows:\n            parent = row[\"parent\"]\n            child = row[\"child\"]\n            items.append(\n                {\n                    \"parent\": parent,\n                    \"child\": child,\n                    \"resource\": _resource_path(parent, child),\n                    \"allow\": row[\"allow\"],\n                    \"reason\": row[\"reason\"],\n                    \"source_plugin\": row[\"source_plugin\"],\n                }\n            )\n\n        def build_page_url(page_number):\n            pairs = []\n            for key in request.args:\n                if key in {\"page\", \"page_size\"}:\n                    continue\n                for value in request.args.getlist(key):\n                    pairs.append((key, value))\n            pairs.append((\"page\", str(page_number)))\n            pairs.append((\"page_size\", str(page_size)))\n            query = urllib.parse.urlencode(pairs)\n            return f\"{request.path}?{query}\"\n\n        response = {\n            \"action\": action,\n            \"actor_id\": (actor or {}).get(\"id\") if actor else None,\n            \"page\": page,\n            \"page_size\": page_size,\n            \"total\": total,\n            \"items\": items,\n        }\n\n        if total > offset + page_size:\n            response[\"next_url\"] = build_page_url(page + 1)\n        if page > 1:\n            response[\"previous_url\"] = build_page_url(page - 1)\n\n        headers = {}\n        if self.ds.cors:\n            add_cors_headers(headers)\n        return Response.json(response, headers=headers)\n\n\nasync def _check_permission_for_actor(ds, action, parent, child, actor):\n    \"\"\"Shared logic for checking permissions. Returns a dict with check results.\"\"\"\n    if action not in ds.actions:\n        return {\"error\": f\"Unknown action: {action}\"}, 404\n\n    if child and not parent:\n        return {\"error\": \"parent is required when child is provided\"}, 400\n\n    # Use the action's properties to create the appropriate resource object\n    action_obj = ds.actions.get(action)\n    if not action_obj:\n        return {\"error\": f\"Unknown action: {action}\"}, 400\n\n    # Global actions (no resource_class) don't have a resource\n    if action_obj.resource_class is None:\n        resource_obj = None\n    elif action_obj.takes_parent and action_obj.takes_child:\n        # Child-level resource (e.g., TableResource, QueryResource)\n        resource_obj = action_obj.resource_class(database=parent, table=child)\n    elif action_obj.takes_parent:\n        # Parent-level resource (e.g., DatabaseResource)\n        resource_obj = action_obj.resource_class(database=parent)\n    else:\n        # This shouldn't happen given validation in Action.__post_init__\n        return {\"error\": f\"Invalid action configuration: {action}\"}, 500\n\n    allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)\n\n    response = {\n        \"action\": action,\n        \"allowed\": bool(allowed),\n        \"resource\": {\n            \"parent\": parent,\n            \"child\": child,\n            \"path\": _resource_path(parent, child),\n        },\n    }\n\n    if actor and \"id\" in actor:\n        response[\"actor_id\"] = actor[\"id\"]\n\n    return response, 200\n\n\nclass PermissionCheckView(BaseView):\n    name = \"permission_check\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        await self.ds.ensure_permission(action=\"permissions-debug\", actor=request.actor)\n        as_format = request.url_vars.get(\"format\")\n\n        if not as_format:\n            return await self.render(\n                [\"debug_check.html\"],\n                request,\n                {\n                    \"sorted_actions\": sorted(self.ds.actions.keys()),\n                    \"has_debug_permission\": True,\n                },\n            )\n\n        # JSON API - action parameter is required\n        action = request.args.get(\"action\")\n        if not action:\n            return Response.json({\"error\": \"action parameter is required\"}, status=400)\n\n        parent = request.args.get(\"parent\")\n        child = request.args.get(\"child\")\n\n        response, status = await _check_permission_for_actor(\n            self.ds, action, parent, child, request.actor\n        )\n        return Response.json(response, status=status)\n\n\nclass AllowDebugView(BaseView):\n    name = \"allow_debug\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        errors = []\n        actor_input = request.args.get(\"actor\") or '{\"id\": \"root\"}'\n        try:\n            actor = json.loads(actor_input)\n            actor_input = json.dumps(actor, indent=4)\n        except json.decoder.JSONDecodeError as ex:\n            errors.append(f\"Actor JSON error: {ex}\")\n        allow_input = request.args.get(\"allow\") or '{\"id\": \"*\"}'\n        try:\n            allow = json.loads(allow_input)\n            allow_input = json.dumps(allow, indent=4)\n        except json.decoder.JSONDecodeError as ex:\n            errors.append(f\"Allow JSON error: {ex}\")\n\n        result = None\n        if not errors:\n            result = str(actor_matches_allow(actor, allow))\n\n        return await self.render(\n            [\"allow_debug.html\"],\n            request,\n            {\n                \"result\": result,\n                \"error\": \"\\n\\n\".join(errors) if errors else \"\",\n                \"actor_input\": actor_input,\n                \"allow_input\": allow_input,\n                \"has_debug_permission\": await self.ds.allowed(\n                    action=\"permissions-debug\", actor=request.actor\n                ),\n            },\n        )\n\n\nclass MessagesDebugView(BaseView):\n    name = \"messages_debug\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n        return await self.render([\"messages_debug.html\"], request)\n\n    async def post(self, request):\n        await self.ds.ensure_permission(action=\"view-instance\", actor=request.actor)\n        form = await request.form()\n        message = form.get(\"message\", \"\")\n        message_type = form.get(\"message_type\") or \"INFO\"\n        assert message_type in (\"INFO\", \"WARNING\", \"ERROR\", \"all\")\n        datasette = self.ds\n        if message_type == \"all\":\n            datasette.add_message(request, message, datasette.INFO)\n            datasette.add_message(request, message, datasette.WARNING)\n            datasette.add_message(request, message, datasette.ERROR)\n        else:\n            datasette.add_message(request, message, getattr(datasette, message_type))\n        return Response.redirect(self.ds.urls.instance())\n\n\nclass CreateTokenView(BaseView):\n    name = \"create_token\"\n    has_json_alternate = False\n\n    def check_permission(self, request):\n        if not self.ds.setting(\"allow_signed_tokens\"):\n            raise Forbidden(\"Signed tokens are not enabled for this Datasette instance\")\n        if not request.actor:\n            raise Forbidden(\"You must be logged in to create a token\")\n        if not request.actor.get(\"id\"):\n            raise Forbidden(\n                \"You must be logged in as an actor with an ID to create a token\"\n            )\n        if request.actor.get(\"token\"):\n            raise Forbidden(\n                \"Token authentication cannot be used to create additional tokens\"\n            )\n\n    async def shared(self, request):\n        self.check_permission(request)\n        # Build list of databases and tables the user has permission to view\n        db_page = await self.ds.allowed_resources(\"view-database\", request.actor)\n        allowed_databases = [r async for r in db_page.all()]\n\n        table_page = await self.ds.allowed_resources(\"view-table\", request.actor)\n        allowed_tables = [r async for r in table_page.all()]\n\n        # Build database -> tables mapping\n        database_with_tables = []\n        for db_resource in allowed_databases:\n            database_name = db_resource.parent\n            if database_name == \"_memory\":\n                continue\n\n            # Find tables for this database\n            tables = []\n            for table_resource in allowed_tables:\n                if table_resource.parent == database_name:\n                    tables.append(\n                        {\n                            \"name\": table_resource.child,\n                            \"encoded\": tilde_encode(table_resource.child),\n                        }\n                    )\n\n            database_with_tables.append(\n                {\n                    \"name\": database_name,\n                    \"encoded\": tilde_encode(database_name),\n                    \"tables\": tables,\n                }\n            )\n        return {\n            \"actor\": request.actor,\n            \"all_actions\": self.ds.actions.keys(),\n            \"database_actions\": [\n                key for key, value in self.ds.actions.items() if value.takes_parent\n            ],\n            \"child_actions\": [\n                key for key, value in self.ds.actions.items() if value.takes_child\n            ],\n            \"database_with_tables\": database_with_tables,\n        }\n\n    async def get(self, request):\n        self.check_permission(request)\n        return await self.render(\n            [\"create_token.html\"], request, await self.shared(request)\n        )\n\n    async def post(self, request):\n        self.check_permission(request)\n        form = await request.form()\n        errors = []\n        expires_after = None\n        if form.get(\"expire_type\"):\n            duration_string = form.get(\"expire_duration\")\n            if (\n                not duration_string\n                or not duration_string.isdigit()\n                or not int(duration_string) > 0\n            ):\n                errors.append(\"Invalid expire duration\")\n            else:\n                unit = form[\"expire_type\"]\n                if unit == \"minutes\":\n                    expires_after = int(duration_string) * 60\n                elif unit == \"hours\":\n                    expires_after = int(duration_string) * 60 * 60\n                elif unit == \"days\":\n                    expires_after = int(duration_string) * 60 * 60 * 24\n                else:\n                    errors.append(\"Invalid expire duration unit\")\n\n        # Are there any restrictions?\n        from datasette.tokens import TokenRestrictions\n\n        restrictions = TokenRestrictions()\n\n        for key in form:\n            if key.startswith(\"all:\") and key.count(\":\") == 1:\n                restrictions.allow_all(key.split(\":\")[1])\n            elif key.startswith(\"database:\") and key.count(\":\") == 2:\n                bits = key.split(\":\")\n                restrictions.allow_database(tilde_decode(bits[1]), bits[2])\n            elif key.startswith(\"resource:\") and key.count(\":\") == 3:\n                bits = key.split(\":\")\n                restrictions.allow_resource(\n                    tilde_decode(bits[1]), tilde_decode(bits[2]), bits[3]\n                )\n\n        token = await self.ds.create_token(\n            request.actor[\"id\"],\n            expires_after=expires_after,\n            restrictions=restrictions,\n            handler=\"signed\",\n        )\n        token_bits = self.ds.unsign(token[len(\"dstok_\") :], namespace=\"token\")\n        await self.ds.track_event(\n            CreateTokenEvent(\n                actor=request.actor,\n                expires_after=expires_after,\n                restrict_all=restrictions.all,\n                restrict_database=restrictions.database,\n                restrict_resource=restrictions.resource,\n            )\n        )\n        context = await self.shared(request)\n        context.update({\"errors\": errors, \"token\": token, \"token_bits\": token_bits})\n        return await self.render([\"create_token.html\"], request, context)\n\n\nclass ApiExplorerView(BaseView):\n    name = \"api_explorer\"\n    has_json_alternate = False\n\n    async def example_links(self, request):\n        databases = []\n        for name, db in self.ds.databases.items():\n            database_visible, _ = await self.ds.check_visibility(\n                request.actor,\n                action=\"view-database\",\n                resource=DatabaseResource(database=name),\n            )\n            if not database_visible:\n                continue\n            tables = []\n            table_names = await db.table_names()\n            for table in table_names:\n                visible, _ = await self.ds.check_visibility(\n                    request.actor,\n                    action=\"view-table\",\n                    resource=TableResource(database=name, table=table),\n                )\n                if not visible:\n                    continue\n                table_links = []\n                tables.append({\"name\": table, \"links\": table_links})\n                table_links.append(\n                    {\n                        \"label\": \"Get rows for {}\".format(table),\n                        \"method\": \"GET\",\n                        \"path\": self.ds.urls.table(name, table, format=\"json\"),\n                    }\n                )\n                # If not mutable don't show any write APIs\n                if not db.is_mutable:\n                    continue\n\n                if await self.ds.allowed(\n                    action=\"insert-row\",\n                    resource=TableResource(database=name, table=table),\n                    actor=request.actor,\n                ):\n                    pks = await db.primary_keys(table)\n                    table_links.extend(\n                        [\n                            {\n                                \"path\": self.ds.urls.table(name, table) + \"/-/insert\",\n                                \"method\": \"POST\",\n                                \"label\": \"Insert rows into {}\".format(table),\n                                \"json\": {\n                                    \"rows\": [\n                                        {\n                                            column: None\n                                            for column in await db.table_columns(table)\n                                            if column not in pks\n                                        }\n                                    ]\n                                },\n                            },\n                            {\n                                \"path\": self.ds.urls.table(name, table) + \"/-/upsert\",\n                                \"method\": \"POST\",\n                                \"label\": \"Upsert rows into {}\".format(table),\n                                \"json\": {\n                                    \"rows\": [\n                                        {\n                                            column: None\n                                            for column in await db.table_columns(table)\n                                            if column not in pks\n                                        }\n                                    ]\n                                },\n                            },\n                        ]\n                    )\n                if await self.ds.allowed(\n                    action=\"drop-table\",\n                    resource=TableResource(database=name, table=table),\n                    actor=request.actor,\n                ):\n                    table_links.append(\n                        {\n                            \"path\": self.ds.urls.table(name, table) + \"/-/drop\",\n                            \"label\": \"Drop table {}\".format(table),\n                            \"json\": {\"confirm\": False},\n                            \"method\": \"POST\",\n                        }\n                    )\n            database_links = []\n            if (\n                await self.ds.allowed(\n                    action=\"create-table\",\n                    resource=DatabaseResource(database=name),\n                    actor=request.actor,\n                )\n                and db.is_mutable\n            ):\n                database_links.append(\n                    {\n                        \"path\": self.ds.urls.database(name) + \"/-/create\",\n                        \"label\": \"Create table in {}\".format(name),\n                        \"json\": {\n                            \"table\": \"new_table\",\n                            \"columns\": [\n                                {\"name\": \"id\", \"type\": \"integer\"},\n                                {\"name\": \"name\", \"type\": \"text\"},\n                            ],\n                            \"pk\": \"id\",\n                        },\n                        \"method\": \"POST\",\n                    }\n                )\n            if database_links or tables:\n                databases.append(\n                    {\n                        \"name\": name,\n                        \"links\": database_links,\n                        \"tables\": tables,\n                    }\n                )\n        # Sort so that mutable databases are first\n        databases.sort(key=lambda d: not self.ds.databases[d[\"name\"]].is_mutable)\n        return databases\n\n    async def get(self, request):\n        visible, private = await self.ds.check_visibility(\n            request.actor,\n            action=\"view-instance\",\n        )\n        if not visible:\n            raise Forbidden(\"You do not have permission to view this instance\")\n\n        def api_path(link):\n            return \"/-/api#{}\".format(\n                urllib.parse.urlencode(\n                    {\n                        key: json.dumps(value, indent=2) if key == \"json\" else value\n                        for key, value in link.items()\n                        if key in (\"path\", \"method\", \"json\")\n                    }\n                )\n            )\n\n        return await self.render(\n            [\"api_explorer.html\"],\n            request,\n            {\n                \"example_links\": await self.example_links(request),\n                \"api_path\": api_path,\n                \"private\": private,\n            },\n        )\n\n\nclass TablesView(BaseView):\n    \"\"\"\n    Simple endpoint that uses the new allowed_resources() API.\n    Returns JSON list of all tables the actor can view.\n\n    Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern,\n    ordered by shortest name first.\n    \"\"\"\n\n    name = \"tables\"\n    has_json_alternate = False\n\n    async def get(self, request):\n        # Get search query parameter\n        q = request.args.get(\"q\", \"\").strip()\n\n        # Get SQL for allowed resources using the permission system\n        permission_sql, params = await self.ds.allowed_resources_sql(\n            action=\"view-table\", actor=request.actor\n        )\n\n        # Build query based on whether we have a search query\n        if q:\n            # Build SQL LIKE pattern from search terms\n            # Split search terms by whitespace and build pattern: %term1%term2%term3%\n            terms = q.split()\n            pattern = \"%\" + \"%\".join(terms) + \"%\"\n\n            # Build query with CTE to filter by search pattern\n            sql = f\"\"\"\n            WITH allowed_tables AS (\n                {permission_sql}\n            )\n            SELECT parent, child\n            FROM allowed_tables\n            WHERE child LIKE :pattern COLLATE NOCASE\n            ORDER BY length(child), child\n            \"\"\"\n            all_params = {**params, \"pattern\": pattern}\n        else:\n            # No search query - return all tables, ordered by name\n            # Fetch 101 to detect if we need to truncate\n            sql = f\"\"\"\n            WITH allowed_tables AS (\n                {permission_sql}\n            )\n            SELECT parent, child\n            FROM allowed_tables\n            ORDER BY parent, child\n            LIMIT 101\n            \"\"\"\n            all_params = params\n\n        # Execute against internal database\n        result = await self.ds.get_internal_database().execute(sql, all_params)\n\n        # Build response with truncation\n        rows = list(result.rows)\n        truncated = len(rows) > 100\n        if truncated:\n            rows = rows[:100]\n\n        matches = [\n            {\n                \"name\": f\"{row['parent']}: {row['child']}\",\n                \"url\": self.ds.urls.table(row[\"parent\"], row[\"child\"]),\n            }\n            for row in rows\n        ]\n\n        return Response.json({\"matches\": matches, \"truncated\": truncated})\n\n\nclass SchemaBaseView(BaseView):\n    \"\"\"Base class for schema views with common response formatting.\"\"\"\n\n    has_json_alternate = False\n\n    async def get_database_schema(self, database_name):\n        \"\"\"Get schema SQL for a database.\"\"\"\n        db = self.ds.databases[database_name]\n        result = await db.execute(\n            \"select group_concat(sql, ';' || CHAR(10)) as schema from sqlite_master where sql is not null\"\n        )\n        row = result.first()\n        return row[\"schema\"] if row and row[\"schema\"] else \"\"\n\n    def format_json_response(self, data):\n        \"\"\"Format data as JSON response with CORS headers if needed.\"\"\"\n        headers = {}\n        if self.ds.cors:\n            add_cors_headers(headers)\n        return Response.json(data, headers=headers)\n\n    def format_error_response(self, error_message, format_, status=404):\n        \"\"\"Format error response based on requested format.\"\"\"\n        if format_ == \"json\":\n            headers = {}\n            if self.ds.cors:\n                add_cors_headers(headers)\n            return Response.json(\n                {\"ok\": False, \"error\": error_message}, status=status, headers=headers\n            )\n        else:\n            return Response.text(error_message, status=status)\n\n    def format_markdown_response(self, heading, schema):\n        \"\"\"Format schema as Markdown response.\"\"\"\n        md_output = f\"# {heading}\\n\\n```sql\\n{schema}\\n```\\n\"\n        return Response.text(\n            md_output, headers={\"content-type\": \"text/markdown; charset=utf-8\"}\n        )\n\n    async def format_html_response(\n        self, request, schemas, is_instance=False, table_name=None\n    ):\n        \"\"\"Format schema as HTML response.\"\"\"\n        context = {\n            \"schemas\": schemas,\n            \"is_instance\": is_instance,\n        }\n        if table_name:\n            context[\"table_name\"] = table_name\n        return await self.render([\"schema.html\"], request=request, context=context)\n\n\nclass InstanceSchemaView(SchemaBaseView):\n    \"\"\"\n    Displays schema for all databases in the instance.\n    Supports HTML, JSON, and Markdown formats.\n    \"\"\"\n\n    name = \"instance_schema\"\n\n    async def get(self, request):\n        format_ = request.url_vars.get(\"format\") or \"html\"\n\n        # Get all databases the actor can view\n        allowed_databases_page = await self.ds.allowed_resources(\n            \"view-database\",\n            request.actor,\n        )\n        allowed_databases = [r.parent async for r in allowed_databases_page.all()]\n\n        # Get schema for each database\n        schemas = []\n        for database_name in allowed_databases:\n            schema = await self.get_database_schema(database_name)\n            schemas.append({\"database\": database_name, \"schema\": schema})\n\n        if format_ == \"json\":\n            return self.format_json_response({\"schemas\": schemas})\n        elif format_ == \"md\":\n            md_parts = [\n                f\"# Schema for {item['database']}\\n\\n```sql\\n{item['schema']}\\n```\"\n                for item in schemas\n            ]\n            return Response.text(\n                \"\\n\\n\".join(md_parts),\n                headers={\"content-type\": \"text/markdown; charset=utf-8\"},\n            )\n        else:\n            return await self.format_html_response(request, schemas, is_instance=True)\n\n\nclass DatabaseSchemaView(SchemaBaseView):\n    \"\"\"\n    Displays schema for a specific database.\n    Supports HTML, JSON, and Markdown formats.\n    \"\"\"\n\n    name = \"database_schema\"\n\n    async def get(self, request):\n        database_name = request.url_vars[\"database\"]\n        format_ = request.url_vars.get(\"format\") or \"html\"\n\n        # Check if database exists\n        if database_name not in self.ds.databases:\n            return self.format_error_response(\"Database not found\", format_)\n\n        # Check view-database permission\n        await self.ds.ensure_permission(\n            action=\"view-database\",\n            resource=DatabaseResource(database=database_name),\n            actor=request.actor,\n        )\n\n        schema = await self.get_database_schema(database_name)\n\n        if format_ == \"json\":\n            return self.format_json_response(\n                {\"database\": database_name, \"schema\": schema}\n            )\n        elif format_ == \"md\":\n            return self.format_markdown_response(f\"Schema for {database_name}\", schema)\n        else:\n            schemas = [{\"database\": database_name, \"schema\": schema}]\n            return await self.format_html_response(request, schemas)\n\n\nclass TableSchemaView(SchemaBaseView):\n    \"\"\"\n    Displays schema for a specific table.\n    Supports HTML, JSON, and Markdown formats.\n    \"\"\"\n\n    name = \"table_schema\"\n\n    async def get(self, request):\n        database_name = request.url_vars[\"database\"]\n        table_name = request.url_vars[\"table\"]\n        format_ = request.url_vars.get(\"format\") or \"html\"\n\n        # Check view-table permission\n        await self.ds.ensure_permission(\n            action=\"view-table\",\n            resource=TableResource(database=database_name, table=table_name),\n            actor=request.actor,\n        )\n\n        # Get schema for the table\n        db = self.ds.databases[database_name]\n        result = await db.execute(\n            \"select sql from sqlite_master where name = ? and sql is not null\",\n            [table_name],\n        )\n        row = result.first()\n\n        # Return 404 if table doesn't exist\n        if not row or not row[\"sql\"]:\n            return self.format_error_response(\"Table not found\", format_)\n\n        schema = row[\"sql\"]\n\n        if format_ == \"json\":\n            return self.format_json_response(\n                {\"database\": database_name, \"table\": table_name, \"schema\": schema}\n            )\n        elif format_ == \"md\":\n            return self.format_markdown_response(\n                f\"Schema for {database_name}.{table_name}\", schema\n            )\n        else:\n            schemas = [{\"database\": database_name, \"schema\": schema}]\n            return await self.format_html_response(\n                request, schemas, table_name=table_name\n            )\n"
  },
  {
    "path": "datasette/views/table.py",
    "content": "import asyncio\nimport itertools\nimport json\nimport urllib\n\nfrom asyncinject import Registry\nimport markupsafe\n\nfrom datasette.plugins import pm\nfrom datasette.database import QueryInterrupted\nfrom datasette.events import (\n    AlterTableEvent,\n    DropTableEvent,\n    InsertRowsEvent,\n    UpsertRowsEvent,\n)\nfrom datasette import tracer\nfrom datasette.resources import DatabaseResource, TableResource\nfrom datasette.utils import (\n    add_cors_headers,\n    await_me_maybe,\n    call_with_supported_arguments,\n    CustomRow,\n    append_querystring,\n    compound_keys_after_sql,\n    format_bytes,\n    make_slot_function,\n    tilde_encode,\n    escape_sqlite,\n    filters_should_redirect,\n    is_url,\n    path_from_row_pks,\n    path_with_added_args,\n    path_with_format,\n    path_with_removed_args,\n    path_with_replaced_args,\n    to_css_class,\n    truncate_url,\n    urlsafe_components,\n    value_as_boolean,\n    InvalidSql,\n    sqlite3,\n)\nfrom datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response\nfrom datasette.filters import Filters\nimport sqlite_utils\nfrom .base import BaseView, DatasetteError, _error, stream_csv\nfrom .database import QueryView\n\nLINK_WITH_LABEL = (\n    '<a href=\"{base_url}{database}/{table}/{link_id}\">{label}</a>&nbsp;<em>{id}</em>'\n)\nLINK_WITH_VALUE = '<a href=\"{base_url}{database}/{table}/{link_id}\">{id}</a>'\n\n\nclass Row:\n    def __init__(self, cells):\n        self.cells = cells\n\n    def __iter__(self):\n        return iter(self.cells)\n\n    def __getitem__(self, key):\n        for cell in self.cells:\n            if cell[\"column\"] == key:\n                return cell[\"raw\"]\n        raise KeyError\n\n    def display(self, key):\n        for cell in self.cells:\n            if cell[\"column\"] == key:\n                return cell[\"value\"]\n        return None\n\n    def __str__(self):\n        d = {\n            key: self[key]\n            for key in [\n                c[\"column\"] for c in self.cells if not c.get(\"is_special_link_column\")\n            ]\n        }\n        return json.dumps(d, default=repr, indent=2)\n\n\nasync def run_sequential(*args):\n    # This used to be swappable for asyncio.gather() to run things in\n    # parallel, but this lead to hard-to-debug locking issues with\n    # in-memory databases: https://github.com/simonw/datasette/issues/2189\n    results = []\n    for fn in args:\n        results.append(await fn)\n    return results\n\n\ndef _redirect(datasette, request, path, forward_querystring=True, remove_args=None):\n    if request.query_string and \"?\" not in path and forward_querystring:\n        path = f\"{path}?{request.query_string}\"\n    if remove_args:\n        path = path_with_removed_args(request, remove_args, path=path)\n    r = Response.redirect(path)\n    r.headers[\"Link\"] = f\"<{path}>; rel=preload\"\n    if datasette.cors:\n        add_cors_headers(r.headers)\n    return r\n\n\nasync def _redirect_if_needed(datasette, request, resolved):\n    # Handle ?_filter_column\n    redirect_params = filters_should_redirect(request.args)\n    if redirect_params:\n        return _redirect(\n            datasette,\n            request,\n            datasette.urls.path(path_with_added_args(request, redirect_params)),\n            forward_querystring=False,\n        )\n\n    # If ?_sort_by_desc=on (from checkbox) redirect to _sort_desc=(_sort)\n    if \"_sort_by_desc\" in request.args:\n        return _redirect(\n            datasette,\n            request,\n            datasette.urls.path(\n                path_with_added_args(\n                    request,\n                    {\n                        \"_sort_desc\": request.args.get(\"_sort\"),\n                        \"_sort_by_desc\": None,\n                        \"_sort\": None,\n                    },\n                )\n            ),\n            forward_querystring=False,\n        )\n\n\nasync def _validate_column_types(datasette, database_name, table_name, rows):\n    \"\"\"Validate row values against assigned column types. Returns list of error strings.\"\"\"\n    ct_map = await datasette.get_column_types(database_name, table_name)\n    if not ct_map:\n        return []\n    errors = []\n    for row in rows:\n        for col_name, ct in ct_map.items():\n            if col_name not in row:\n                continue\n            error = await ct.validate(row[col_name], datasette)\n            if error:\n                errors.append(f\"{col_name}: {error}\")\n    return errors\n\n\nasync def display_columns_and_rows(\n    datasette,\n    database_name,\n    table_name,\n    description,\n    rows,\n    link_column=False,\n    truncate_cells=0,\n    sortable_columns=None,\n    request=None,\n):\n    \"\"\"Returns columns, rows for specified table - including fancy foreign key treatment\"\"\"\n    sortable_columns = sortable_columns or set()\n    db = datasette.databases[database_name]\n    column_descriptions = dict(\n        await datasette.get_internal_database().execute(\n            \"\"\"\n          SELECT\n            column_name,\n            value\n          FROM metadata_columns\n          WHERE database_name = ?\n            AND resource_name = ?\n            AND key = 'description'\n        \"\"\",\n            [database_name, table_name],\n        )\n    )\n\n    # Look up column types for this table\n    column_types_map = await datasette.get_column_types(database_name, table_name)\n\n    column_details = {\n        col.name: col for col in await db.table_column_details(table_name)\n    }\n    pks = await db.primary_keys(table_name)\n    pks_for_display = pks\n    if not pks_for_display:\n        pks_for_display = [\"rowid\"]\n\n    columns = []\n    for r in description:\n        if r[0] == \"rowid\" and \"rowid\" not in column_details:\n            type_ = \"integer\"\n            notnull = 0\n        else:\n            type_ = column_details[r[0]].type\n            notnull = column_details[r[0]].notnull\n        col_dict = {\n            \"name\": r[0],\n            \"sortable\": r[0] in sortable_columns,\n            \"is_pk\": r[0] in pks_for_display,\n            \"type\": type_,\n            \"notnull\": notnull,\n            \"description\": column_descriptions.get(r[0]),\n            \"column_type\": None,\n            \"column_type_config\": None,\n        }\n        ct = column_types_map.get(r[0])\n        if ct:\n            col_dict[\"column_type\"] = ct.name\n            col_dict[\"column_type_config\"] = ct.config\n        columns.append(col_dict)\n\n    column_to_foreign_key_table = {\n        fk[\"column\"]: fk[\"other_table\"]\n        for fk in await db.foreign_keys_for_table(table_name)\n    }\n\n    cell_rows = []\n    base_url = datasette.setting(\"base_url\")\n    for row in rows:\n        cells = []\n        # Unless we are a view, the first column is a link - either to the rowid\n        # or to the simple or compound primary key\n        if link_column:\n            is_special_link_column = len(pks) != 1\n            pk_path = path_from_row_pks(row, pks, not pks, False)\n            cells.append(\n                {\n                    \"column\": pks[0] if len(pks) == 1 else \"Link\",\n                    \"value_type\": \"pk\",\n                    \"is_special_link_column\": is_special_link_column,\n                    \"raw\": pk_path,\n                    \"value\": markupsafe.Markup(\n                        '<a href=\"{table_path}/{flat_pks_quoted}\">{flat_pks}</a>'.format(\n                            table_path=datasette.urls.table(database_name, table_name),\n                            flat_pks=str(markupsafe.escape(pk_path)),\n                            flat_pks_quoted=path_from_row_pks(row, pks, not pks),\n                        )\n                    ),\n                }\n            )\n\n        for value, column_dict in zip(row, columns):\n            column = column_dict[\"name\"]\n            if link_column and len(pks) == 1 and column == pks[0]:\n                # If there's a simple primary key, don't repeat the value as it's\n                # already shown in the link column.\n                continue\n\n            # First try column type render_cell, then plugins\n            # pylint: disable=no-member\n            plugin_display_value = None\n            ct = column_types_map.get(column)\n            if ct:\n                candidate = await ct.render_cell(\n                    value=value,\n                    column=column,\n                    table=table_name,\n                    database=database_name,\n                    datasette=datasette,\n                    request=request,\n                )\n                if candidate is not None:\n                    plugin_display_value = candidate\n            if plugin_display_value is None:\n                for candidate in pm.hook.render_cell(\n                    row=row,\n                    value=value,\n                    column=column,\n                    table=table_name,\n                    pks=pks_for_display,\n                    database=database_name,\n                    datasette=datasette,\n                    request=request,\n                    column_type=ct,\n                ):\n                    candidate = await await_me_maybe(candidate)\n                    if candidate is not None:\n                        plugin_display_value = candidate\n                        break\n            if plugin_display_value:\n                display_value = plugin_display_value\n            elif isinstance(value, bytes):\n                formatted = format_bytes(len(value))\n                display_value = markupsafe.Markup(\n                    '<a class=\"blob-download\" href=\"{}\"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format(\n                        datasette.urls.row_blob(\n                            database_name,\n                            table_name,\n                            path_from_row_pks(row, pks, not pks),\n                            column,\n                        ),\n                        (\n                            ' title=\"{}\"'.format(formatted)\n                            if \"bytes\" not in formatted\n                            else \"\"\n                        ),\n                        len(value),\n                        \"\" if len(value) == 1 else \"s\",\n                    )\n                )\n            elif isinstance(value, dict):\n                # It's an expanded foreign key - display link to other row\n                label = value[\"label\"]\n                value = value[\"value\"]\n                # The table we link to depends on the column\n                other_table = column_to_foreign_key_table[column]\n                link_template = LINK_WITH_LABEL if (label != value) else LINK_WITH_VALUE\n                display_value = markupsafe.Markup(\n                    link_template.format(\n                        database=tilde_encode(database_name),\n                        base_url=base_url,\n                        table=tilde_encode(other_table),\n                        link_id=tilde_encode(str(value)),\n                        id=str(markupsafe.escape(value)),\n                        label=str(markupsafe.escape(label)) or \"-\",\n                    )\n                )\n            elif value in (\"\", None):\n                display_value = markupsafe.Markup(\"&nbsp;\")\n            elif is_url(str(value).strip()):\n                display_value = markupsafe.Markup(\n                    '<a href=\"{url}\">{truncated_url}</a>'.format(\n                        url=markupsafe.escape(value.strip()),\n                        truncated_url=markupsafe.escape(\n                            truncate_url(value.strip(), truncate_cells)\n                        ),\n                    )\n                )\n            else:\n                display_value = str(value)\n                if truncate_cells and len(display_value) > truncate_cells:\n                    display_value = display_value[:truncate_cells] + \"\\u2026\"\n\n            cells.append(\n                {\n                    \"column\": column,\n                    \"value\": display_value,\n                    \"raw\": value,\n                    \"value_type\": (\n                        \"none\" if value is None else str(type(value).__name__)\n                    ),\n                }\n            )\n        cell_rows.append(Row(cells))\n\n    if link_column:\n        # Add the link column header.\n        # If it's a simple primary key, we have to remove and re-add that column name at\n        # the beginning of the header row.\n        first_column = None\n        if len(pks) == 1:\n            columns = [col for col in columns if col[\"name\"] != pks[0]]\n            first_column = {\n                \"name\": pks[0],\n                \"sortable\": len(pks) == 1,\n                \"is_pk\": True,\n                \"type\": column_details[pks[0]].type,\n                \"notnull\": column_details[pks[0]].notnull,\n            }\n        else:\n            first_column = {\n                \"name\": \"Link\",\n                \"sortable\": False,\n                \"is_pk\": False,\n                \"type\": \"\",\n                \"notnull\": 0,\n                \"is_special_link_column\": True,\n            }\n        columns = [first_column] + columns\n    return columns, cell_rows\n\n\nclass TableInsertView(BaseView):\n    name = \"table-insert\"\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def _validate_data(self, request, db, table_name, pks, upsert):\n        errors = []\n\n        pks_list = []\n        if isinstance(pks, str):\n            pks_list = [pks]\n        else:\n            pks_list = list(pks)\n\n        if not pks_list:\n            pks_list = [\"rowid\"]\n\n        def _errors(errors):\n            return None, errors, {}\n\n        if not request.headers.get(\"content-type\").startswith(\"application/json\"):\n            # TODO: handle form-encoded data\n            return _errors([\"Invalid content-type, must be application/json\"])\n        body = await request.post_body()\n        try:\n            data = json.loads(body)\n        except json.JSONDecodeError as e:\n            return _errors([\"Invalid JSON: {}\".format(e)])\n        if not isinstance(data, dict):\n            return _errors([\"JSON must be a dictionary\"])\n        keys = data.keys()\n\n        # keys must contain \"row\" or \"rows\"\n        if \"row\" not in keys and \"rows\" not in keys:\n            return _errors(['JSON must have one or other of \"row\" or \"rows\"'])\n        rows = []\n        if \"row\" in keys:\n            if \"rows\" in keys:\n                return _errors(['Cannot use \"row\" and \"rows\" at the same time'])\n            row = data[\"row\"]\n            if not isinstance(row, dict):\n                return _errors(['\"row\" must be a dictionary'])\n            rows = [row]\n            data[\"return\"] = True\n        else:\n            rows = data[\"rows\"]\n        if not isinstance(rows, list):\n            return _errors(['\"rows\" must be a list'])\n        for row in rows:\n            if not isinstance(row, dict):\n                return _errors(['\"rows\" must be a list of dictionaries'])\n\n        # Does this exceed max_insert_rows?\n        max_insert_rows = self.ds.setting(\"max_insert_rows\")\n        if len(rows) > max_insert_rows:\n            return _errors(\n                [\"Too many rows, maximum allowed is {}\".format(max_insert_rows)]\n            )\n\n        # Validate other parameters\n        extras = {\n            key: value for key, value in data.items() if key not in (\"row\", \"rows\")\n        }\n        valid_extras = {\"return\", \"ignore\", \"replace\", \"alter\"}\n        invalid_extras = extras.keys() - valid_extras\n        if invalid_extras:\n            return _errors(\n                ['Invalid parameter: \"{}\"'.format('\", \"'.join(sorted(invalid_extras)))]\n            )\n        if extras.get(\"ignore\") and extras.get(\"replace\"):\n            return _errors(['Cannot use \"ignore\" and \"replace\" at the same time'])\n\n        columns = set(await db.table_columns(table_name))\n        columns.update(pks_list)\n\n        for i, row in enumerate(rows):\n            if upsert:\n                # It MUST have the primary key\n                missing_pks = [pk for pk in pks_list if pk not in row]\n                if missing_pks:\n                    errors.append(\n                        'Row {} is missing primary key column(s): \"{}\"'.format(\n                            i, '\", \"'.join(missing_pks)\n                        )\n                    )\n            invalid_columns = set(row.keys()) - columns\n            if invalid_columns and not extras.get(\"alter\"):\n                errors.append(\n                    \"Row {} has invalid columns: {}\".format(\n                        i, \", \".join(sorted(invalid_columns))\n                    )\n                )\n        if errors:\n            return _errors(errors)\n        return rows, errors, extras\n\n    async def post(self, request, upsert=False):\n        try:\n            resolved = await self.ds.resolve_table(request)\n        except NotFound as e:\n            return _error([e.args[0]], 404)\n        db = resolved.db\n        database_name = db.name\n        table_name = resolved.table\n\n        # Table must exist (may handle table creation in the future)\n        db = self.ds.get_database(database_name)\n        if not await db.table_exists(table_name):\n            return _error([\"Table not found: {}\".format(table_name)], 404)\n\n        if upsert:\n            # Must have insert-row AND upsert-row permissions\n            if not (\n                await self.ds.allowed(\n                    action=\"insert-row\",\n                    resource=TableResource(database=database_name, table=table_name),\n                    actor=request.actor,\n                )\n                and await self.ds.allowed(\n                    action=\"update-row\",\n                    resource=TableResource(database=database_name, table=table_name),\n                    actor=request.actor,\n                )\n            ):\n                return _error(\n                    [\"Permission denied: need both insert-row and update-row\"], 403\n                )\n        else:\n            # Must have insert-row permission\n            if not await self.ds.allowed(\n                action=\"insert-row\",\n                resource=TableResource(database=database_name, table=table_name),\n                actor=request.actor,\n            ):\n                return _error([\"Permission denied\"], 403)\n\n        if not db.is_mutable:\n            return _error([\"Database is immutable\"], 403)\n\n        pks = await db.primary_keys(table_name)\n\n        rows, errors, extras = await self._validate_data(\n            request, db, table_name, pks, upsert\n        )\n        if errors:\n            return _error(errors, 400)\n\n        # Validate column types\n        ct_errors = await _validate_column_types(\n            self.ds, database_name, table_name, rows\n        )\n        if ct_errors:\n            return _error(ct_errors, 400)\n\n        num_rows = len(rows)\n\n        # No that we've passed pks to _validate_data it's safe to\n        # fix the rowids case:\n        if not pks:\n            pks = [\"rowid\"]\n\n        ignore = extras.get(\"ignore\")\n        replace = extras.get(\"replace\")\n        alter = extras.get(\"alter\")\n\n        if upsert and (ignore or replace):\n            return _error([\"Upsert does not support ignore or replace\"], 400)\n\n        if replace and not await self.ds.allowed(\n            action=\"update-row\",\n            resource=TableResource(database=database_name, table=table_name),\n            actor=request.actor,\n        ):\n            return _error(['Permission denied: need update-row to use \"replace\"'], 403)\n\n        initial_schema = None\n        if alter:\n            # Must have alter-table permission\n            if not await self.ds.allowed(\n                action=\"alter-table\",\n                resource=TableResource(database=database_name, table=table_name),\n                actor=request.actor,\n            ):\n                return _error([\"Permission denied for alter-table\"], 403)\n            # Track initial schema to check if it changed later\n            initial_schema = await db.execute_fn(\n                lambda conn: sqlite_utils.Database(conn)[table_name].schema\n            )\n\n        should_return = bool(extras.get(\"return\", False))\n        row_pk_values_for_later = []\n        if should_return and upsert:\n            row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]\n\n        def insert_or_upsert_rows(conn):\n            table = sqlite_utils.Database(conn)[table_name]\n            kwargs = {}\n            if upsert:\n                kwargs = {\n                    \"pk\": pks[0] if len(pks) == 1 else pks,\n                    \"alter\": alter,\n                }\n            else:\n                # Insert\n                kwargs = {\"ignore\": ignore, \"replace\": replace, \"alter\": alter}\n            if should_return and not upsert:\n                rowids = []\n                method = table.upsert if upsert else table.insert\n                for row in rows:\n                    rowids.append(method(row, **kwargs).last_rowid)\n                return list(\n                    table.rows_where(\n                        \"rowid in ({})\".format(\",\".join(\"?\" for _ in rowids)),\n                        rowids,\n                    )\n                )\n            else:\n                method_all = table.upsert_all if upsert else table.insert_all\n                method_all(rows, **kwargs)\n\n        try:\n            rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)\n        except Exception as e:\n            return _error([str(e)])\n        result = {\"ok\": True}\n        if should_return:\n            if upsert:\n                # Fetch based on initial input IDs\n                where_clause = \" OR \".join(\n                    [\"({})\".format(\" AND \".join(\"{} = ?\".format(pk) for pk in pks))]\n                    * len(row_pk_values_for_later)\n                )\n                args = list(itertools.chain.from_iterable(row_pk_values_for_later))\n                fetched_rows = await db.execute(\n                    \"select {}* from [{}] where {}\".format(\n                        \"rowid, \" if pks == [\"rowid\"] else \"\", table_name, where_clause\n                    ),\n                    args,\n                )\n                result[\"rows\"] = fetched_rows.dicts()\n            else:\n                result[\"rows\"] = rows\n        # We track the number of rows requested, but do not attempt to show which were actually\n        # inserted or upserted v.s. ignored\n        if upsert:\n            await self.ds.track_event(\n                UpsertRowsEvent(\n                    actor=request.actor,\n                    database=database_name,\n                    table=table_name,\n                    num_rows=num_rows,\n                )\n            )\n        else:\n            await self.ds.track_event(\n                InsertRowsEvent(\n                    actor=request.actor,\n                    database=database_name,\n                    table=table_name,\n                    num_rows=num_rows,\n                    ignore=bool(ignore),\n                    replace=bool(replace),\n                )\n            )\n\n        if initial_schema is not None:\n            after_schema = await db.execute_fn(\n                lambda conn: sqlite_utils.Database(conn)[table_name].schema\n            )\n            if initial_schema != after_schema:\n                await self.ds.track_event(\n                    AlterTableEvent(\n                        request.actor,\n                        database=database_name,\n                        table=table_name,\n                        before_schema=initial_schema,\n                        after_schema=after_schema,\n                    )\n                )\n\n        return Response.json(result, status=200 if upsert else 201)\n\n\nclass TableUpsertView(TableInsertView):\n    name = \"table-upsert\"\n\n    async def post(self, request):\n        return await super().post(request, upsert=True)\n\n\nclass TableSetColumnTypeView(BaseView):\n    name = \"table-set-column-type\"\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def post(self, request):\n        try:\n            resolved = await self.ds.resolve_table(request)\n        except NotFound as e:\n            return _error([e.args[0]], 404)\n\n        database_name = resolved.db.name\n        table_name = resolved.table\n\n        if not await self.ds.allowed(\n            action=\"set-column-type\",\n            resource=TableResource(database=database_name, table=table_name),\n            actor=request.actor,\n        ):\n            return _error([\"Permission denied\"], 403)\n\n        content_type = request.headers.get(\"content-type\") or \"\"\n        if not content_type.startswith(\"application/json\"):\n            return _error([\"Invalid content-type, must be application/json\"], 400)\n\n        try:\n            data = json.loads(await request.post_body())\n        except json.JSONDecodeError as e:\n            return _error([\"Invalid JSON: {}\".format(e)], 400)\n\n        if not isinstance(data, dict):\n            return _error([\"JSON must be a dictionary\"], 400)\n\n        invalid_keys = set(data.keys()) - {\"column\", \"column_type\"}\n        if invalid_keys:\n            return _error(\n                ['Invalid parameter: \"{}\"'.format('\", \"'.join(sorted(invalid_keys)))],\n                400,\n            )\n\n        if \"column\" not in data:\n            return _error(['\"column\" is required'], 400)\n        column = data[\"column\"]\n        if not isinstance(column, str):\n            return _error(['\"column\" must be a string'], 400)\n\n        if \"column_type\" not in data:\n            return _error(['\"column_type\" is required'], 400)\n\n        column_details = await self.ds._get_resource_column_details(\n            database_name, table_name\n        )\n        if column not in column_details:\n            return _error([\"Column not found: {}\".format(column)], 400)\n\n        column_type_data = data[\"column_type\"]\n        if column_type_data is None:\n            await self.ds.remove_column_type(database_name, table_name, column)\n            return Response.json(\n                {\n                    \"ok\": True,\n                    \"database\": database_name,\n                    \"table\": table_name,\n                    \"column\": column,\n                    \"column_type\": None,\n                },\n                status=200,\n            )\n\n        if not isinstance(column_type_data, dict):\n            return _error(['\"column_type\" must be an object or null'], 400)\n\n        invalid_column_type_keys = set(column_type_data.keys()) - {\"type\", \"config\"}\n        if invalid_column_type_keys:\n            return _error(\n                [\n                    'Invalid column_type parameter: \"{}\"'.format(\n                        '\", \"'.join(sorted(invalid_column_type_keys))\n                    )\n                ],\n                400,\n            )\n\n        if \"type\" not in column_type_data:\n            return _error(['\"column_type.type\" is required'], 400)\n        column_type = column_type_data[\"type\"]\n        if not isinstance(column_type, str):\n            return _error(['\"column_type.type\" must be a string'], 400)\n\n        config = column_type_data.get(\"config\")\n        if config is not None and not isinstance(config, dict):\n            return _error(['\"column_type.config\" must be a dictionary'], 400)\n\n        if column_type not in self.ds._column_types:\n            return _error([\"Unknown column type: {}\".format(column_type)], 400)\n\n        try:\n            await self.ds.set_column_type(\n                database_name, table_name, column, column_type, config\n            )\n        except ValueError as e:\n            return _error([str(e)], 400)\n\n        return Response.json(\n            {\n                \"ok\": True,\n                \"database\": database_name,\n                \"table\": table_name,\n                \"column\": column,\n                \"column_type\": {\"type\": column_type, \"config\": config},\n            },\n            status=200,\n        )\n\n\nclass TableDropView(BaseView):\n    name = \"table-drop\"\n\n    def __init__(self, datasette):\n        self.ds = datasette\n\n    async def post(self, request):\n        try:\n            resolved = await self.ds.resolve_table(request)\n        except NotFound as e:\n            return _error([e.args[0]], 404)\n        db = resolved.db\n        database_name = db.name\n        table_name = resolved.table\n        # Table must exist\n        db = self.ds.get_database(database_name)\n        if not await db.table_exists(table_name):\n            return _error([\"Table not found: {}\".format(table_name)], 404)\n        if not await self.ds.allowed(\n            action=\"drop-table\",\n            resource=TableResource(database=database_name, table=table_name),\n            actor=request.actor,\n        ):\n            return _error([\"Permission denied\"], 403)\n        if not db.is_mutable:\n            return _error([\"Database is immutable\"], 403)\n        confirm = False\n        try:\n            data = json.loads(await request.post_body())\n            confirm = data.get(\"confirm\")\n        except json.JSONDecodeError:\n            pass\n\n        if not confirm:\n            return Response.json(\n                {\n                    \"ok\": True,\n                    \"database\": database_name,\n                    \"table\": table_name,\n                    \"row_count\": (\n                        await db.execute(\"select count(*) from [{}]\".format(table_name))\n                    ).single_value(),\n                    \"message\": 'Pass \"confirm\": true to confirm',\n                },\n                status=200,\n            )\n\n        # Drop table\n        def drop_table(conn):\n            sqlite_utils.Database(conn)[table_name].drop()\n\n        await db.execute_write_fn(drop_table, request=request)\n        await self.ds.track_event(\n            DropTableEvent(\n                actor=request.actor, database=database_name, table=table_name\n            )\n        )\n        return Response.json({\"ok\": True}, status=200)\n\n\ndef _get_extras(request):\n    extra_bits = request.args.getlist(\"_extra\")\n    extras = set()\n    for bit in extra_bits:\n        extras.update(bit.split(\",\"))\n    return extras\n\n\nasync def _columns_to_select(table_columns, pks, request):\n    columns = list(table_columns)\n    if \"_col\" in request.args:\n        columns = list(pks)\n        _cols = request.args.getlist(\"_col\")\n        bad_columns = [column for column in _cols if column not in table_columns]\n        if bad_columns:\n            raise DatasetteError(\n                \"_col={} - invalid columns\".format(\", \".join(bad_columns)),\n                status=400,\n            )\n        # De-duplicate maintaining order:\n        columns.extend(dict.fromkeys(_cols))\n    if \"_nocol\" in request.args:\n        # Return all columns EXCEPT these\n        bad_columns = [\n            column\n            for column in request.args.getlist(\"_nocol\")\n            if (column not in table_columns) or (column in pks)\n        ]\n        if bad_columns:\n            raise DatasetteError(\n                \"_nocol={} - invalid columns\".format(\", \".join(bad_columns)),\n                status=400,\n            )\n        tmp_columns = [\n            column for column in columns if column not in request.args.getlist(\"_nocol\")\n        ]\n        columns = tmp_columns\n    return columns\n\n\nasync def _sortable_columns_for_table(datasette, database_name, table_name, use_rowid):\n    db = datasette.databases[database_name]\n    table_metadata = await datasette.table_config(database_name, table_name)\n    if \"sortable_columns\" in table_metadata:\n        sortable_columns = set(table_metadata[\"sortable_columns\"])\n    else:\n        sortable_columns = set(await db.table_columns(table_name))\n    if use_rowid:\n        sortable_columns.add(\"rowid\")\n    return sortable_columns\n\n\nasync def _sort_order(table_metadata, sortable_columns, request, order_by):\n    sort = request.args.get(\"_sort\")\n    sort_desc = request.args.get(\"_sort_desc\")\n\n    if not sort and not sort_desc:\n        sort = table_metadata.get(\"sort\")\n        sort_desc = table_metadata.get(\"sort_desc\")\n\n    if sort and sort_desc:\n        raise DatasetteError(\n            \"Cannot use _sort and _sort_desc at the same time\", status=400\n        )\n\n    if sort:\n        if sort not in sortable_columns:\n            raise DatasetteError(f\"Cannot sort table by {sort}\", status=400)\n\n        order_by = escape_sqlite(sort)\n\n    if sort_desc:\n        if sort_desc not in sortable_columns:\n            raise DatasetteError(f\"Cannot sort table by {sort_desc}\", status=400)\n\n        order_by = f\"{escape_sqlite(sort_desc)} desc\"\n\n    return sort, sort_desc, order_by\n\n\nasync def table_view(datasette, request):\n    await datasette.refresh_schemas()\n    with tracer.trace_child_tasks():\n        response = await table_view_traced(datasette, request)\n\n    # CORS\n    if datasette.cors:\n        add_cors_headers(response.headers)\n\n    # Cache TTL header\n    ttl = request.args.get(\"_ttl\", None)\n    if ttl is None or not ttl.isdigit():\n        ttl = datasette.setting(\"default_cache_ttl\")\n\n    if datasette.cache_headers and response.status == 200:\n        ttl = int(ttl)\n        if ttl == 0:\n            ttl_header = \"no-cache\"\n        else:\n            ttl_header = f\"max-age={ttl}\"\n        response.headers[\"Cache-Control\"] = ttl_header\n\n    # Referrer policy\n    response.headers[\"Referrer-Policy\"] = \"no-referrer\"\n\n    return response\n\n\nasync def table_view_traced(datasette, request):\n    from datasette.app import TableNotFound\n\n    try:\n        resolved = await datasette.resolve_table(request)\n    except TableNotFound as not_found:\n        # Was this actually a canned query?\n        canned_query = await datasette.get_canned_query(\n            not_found.database_name, not_found.table, request.actor\n        )\n        # If this is a canned query, not a table, then dispatch to QueryView instead\n        if canned_query:\n            return await QueryView()(request, datasette)\n        else:\n            raise\n\n    if request.method == \"POST\":\n        return Response.text(\"Method not allowed\", status=405)\n\n    format_ = request.url_vars.get(\"format\") or \"html\"\n    extra_extras = None\n    context_for_html_hack = False\n    default_labels = False\n    if format_ == \"html\":\n        extra_extras = {\"_html\"}\n        context_for_html_hack = True\n        default_labels = True\n\n    view_data = await table_view_data(\n        datasette,\n        request,\n        resolved,\n        extra_extras=extra_extras,\n        context_for_html_hack=context_for_html_hack,\n        default_labels=default_labels,\n    )\n    if isinstance(view_data, Response):\n        return view_data\n    data, rows, columns, expanded_columns, sql, next_url = view_data\n\n    # Handle formats from plugins\n    if format_ == \"csv\":\n\n        async def fetch_data(request, _next=None):\n            (\n                data,\n                rows,\n                columns,\n                expanded_columns,\n                sql,\n                next_url,\n            ) = await table_view_data(\n                datasette,\n                request,\n                resolved,\n                extra_extras=extra_extras,\n                context_for_html_hack=context_for_html_hack,\n                default_labels=default_labels,\n                _next=_next,\n            )\n            data[\"rows\"] = rows\n            data[\"table\"] = resolved.table\n            data[\"columns\"] = columns\n            data[\"expanded_columns\"] = expanded_columns\n            return data, None, None\n\n        return await stream_csv(datasette, fetch_data, request, resolved.db.name)\n    elif format_ in datasette.renderers.keys():\n        # Dispatch request to the correct output format renderer\n        # (CSV is not handled here due to streaming)\n        result = call_with_supported_arguments(\n            datasette.renderers[format_][0],\n            datasette=datasette,\n            columns=columns,\n            rows=rows,\n            sql=sql,\n            query_name=None,\n            database=resolved.db.name,\n            table=resolved.table,\n            request=request,\n            view_name=\"table\",\n            truncated=False,\n            error=None,\n            # These will be deprecated in Datasette 1.0:\n            args=request.args,\n            data=data,\n        )\n        if asyncio.iscoroutine(result):\n            result = await result\n        if result is None:\n            raise NotFound(\"No data\")\n        if isinstance(result, dict):\n            r = Response(\n                body=result.get(\"body\"),\n                status=result.get(\"status_code\") or 200,\n                content_type=result.get(\"content_type\", \"text/plain\"),\n                headers=result.get(\"headers\"),\n            )\n        elif isinstance(result, Response):\n            r = result\n            # if status_code is not None:\n            #     # Over-ride the status code\n            #     r.status = status_code\n        else:\n            assert False, f\"{result} should be dict or Response\"\n    elif format_ == \"html\":\n        headers = {}\n        templates = [\n            f\"table-{to_css_class(resolved.db.name)}-{to_css_class(resolved.table)}.html\",\n            \"table.html\",\n        ]\n        environment = datasette.get_jinja_environment(request)\n        template = environment.select_template(templates)\n        alternate_url_json = datasette.absolute_url(\n            request,\n            datasette.urls.path(path_with_format(request=request, format=\"json\")),\n        )\n        headers.update(\n            {\n                \"Link\": '<{}>; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\n                    alternate_url_json\n                )\n            }\n        )\n        r = Response.html(\n            await datasette.render_template(\n                template,\n                dict(\n                    data,\n                    append_querystring=append_querystring,\n                    path_with_replaced_args=path_with_replaced_args,\n                    fix_path=datasette.urls.path,\n                    settings=datasette.settings_dict(),\n                    # TODO: review up all of these hacks:\n                    alternate_url_json=alternate_url_json,\n                    datasette_allow_facet=(\n                        \"true\" if datasette.setting(\"allow_facet\") else \"false\"\n                    ),\n                    is_sortable=any(c[\"sortable\"] for c in data[\"display_columns\"]),\n                    allow_execute_sql=await datasette.allowed(\n                        action=\"execute-sql\",\n                        resource=DatabaseResource(database=resolved.db.name),\n                        actor=request.actor,\n                    ),\n                    query_ms=1.2,\n                    select_templates=[\n                        f\"{'*' if template_name == template.name else ''}{template_name}\"\n                        for template_name in templates\n                    ],\n                    top_table=make_slot_function(\n                        \"top_table\",\n                        datasette,\n                        request,\n                        database=resolved.db.name,\n                        table=resolved.table,\n                    ),\n                    count_limit=resolved.db.count_limit,\n                ),\n                request=request,\n                view_name=\"table\",\n            ),\n            headers=headers,\n        )\n    else:\n        assert False, \"Invalid format: {}\".format(format_)\n    if next_url:\n        r.headers[\"link\"] = f'<{next_url}>; rel=\"next\"'\n    return r\n\n\nasync def table_view_data(\n    datasette,\n    request,\n    resolved,\n    extra_extras=None,\n    context_for_html_hack=False,\n    default_labels=False,\n    _next=None,\n):\n    extra_extras = extra_extras or set()\n    # We have a table or view\n    db = resolved.db\n    database_name = resolved.db.name\n    table_name = resolved.table\n    is_view = resolved.is_view\n\n    # Can this user view it?\n    visible, private = await datasette.check_visibility(\n        request.actor,\n        action=\"view-table\",\n        resource=TableResource(database=database_name, table=table_name),\n    )\n    if not visible:\n        raise Forbidden(\"You do not have permission to view this table\")\n\n    # Redirect based on request.args, if necessary\n    redirect_response = await _redirect_if_needed(datasette, request, resolved)\n    if redirect_response:\n        return redirect_response\n\n    # Introspect columns and primary keys for table\n    pks = await db.primary_keys(table_name)\n    table_columns = await db.table_columns(table_name)\n\n    # Take ?_col= and ?_nocol= into account\n    specified_columns = await _columns_to_select(table_columns, pks, request)\n    select_specified_columns = \", \".join(escape_sqlite(t) for t in specified_columns)\n    select_all_columns = \", \".join(escape_sqlite(t) for t in table_columns)\n\n    # rowid tables (no specified primary key) need a different SELECT\n    use_rowid = not pks and not is_view\n    order_by = \"\"\n    if use_rowid:\n        select_specified_columns = f\"rowid, {select_specified_columns}\"\n        select_all_columns = f\"rowid, {select_all_columns}\"\n        order_by = \"rowid\"\n        order_by_pks = \"rowid\"\n    else:\n        order_by_pks = \", \".join([escape_sqlite(pk) for pk in pks])\n        order_by = order_by_pks\n\n    if is_view:\n        order_by = \"\"\n\n    # TODO: This logic should turn into logic about which ?_extras get\n    # executed instead:\n    nocount = request.args.get(\"_nocount\")\n    nofacet = request.args.get(\"_nofacet\")\n    nosuggest = request.args.get(\"_nosuggest\")\n    if request.args.get(\"_shape\") in (\"array\", \"object\"):\n        nocount = True\n        nofacet = True\n\n    table_metadata = await datasette.table_config(database_name, table_name)\n\n    # Arguments that start with _ and don't contain a __ are\n    # special - things like ?_search= - and should not be\n    # treated as filters.\n    filter_args = []\n    for key in request.args:\n        if not (key.startswith(\"_\") and \"__\" not in key):\n            for v in request.args.getlist(key):\n                filter_args.append((key, v))\n\n    # Build where clauses from query string arguments\n    filters = Filters(sorted(filter_args))\n    where_clauses, params = filters.build_where_clauses(table_name)\n\n    # Execute filters_from_request plugin hooks - including the default\n    # ones that live in datasette/filters.py\n    extra_context_from_filters = {}\n    extra_human_descriptions = []\n\n    for hook in pm.hook.filters_from_request(\n        request=request,\n        table=table_name,\n        database=database_name,\n        datasette=datasette,\n    ):\n        filter_arguments = await await_me_maybe(hook)\n        if filter_arguments:\n            where_clauses.extend(filter_arguments.where_clauses)\n            params.update(filter_arguments.params)\n            extra_human_descriptions.extend(filter_arguments.human_descriptions)\n            extra_context_from_filters.update(filter_arguments.extra_context)\n\n    # Deal with custom sort orders\n    sortable_columns = await _sortable_columns_for_table(\n        datasette, database_name, table_name, use_rowid\n    )\n\n    sort, sort_desc, order_by = await _sort_order(\n        table_metadata, sortable_columns, request, order_by\n    )\n\n    from_sql = \"from {table_name} {where}\".format(\n        table_name=escape_sqlite(table_name),\n        where=(\n            (\"where {} \".format(\" and \".join(where_clauses))) if where_clauses else \"\"\n        ),\n    )\n    # Copy of params so we can mutate them later:\n    from_sql_params = dict(**params)\n\n    count_sql = f\"select count(*) {from_sql}\"\n\n    # Handle pagination driven by ?_next=\n    _next = _next or request.args.get(\"_next\")\n\n    offset = \"\"\n    if _next:\n        sort_value = None\n        if is_view:\n            # _next is an offset\n            offset = f\" offset {int(_next)}\"\n        else:\n            components = urlsafe_components(_next)\n            # If a sort order is applied and there are multiple components,\n            # the first of these is the sort value\n            if (sort or sort_desc) and (len(components) > 1):\n                sort_value = components[0]\n                # Special case for if non-urlencoded first token was $null\n                if _next.split(\",\")[0] == \"$null\":\n                    sort_value = None\n                components = components[1:]\n\n            # Figure out the SQL for next-based-on-primary-key first\n            next_by_pk_clauses = []\n            if use_rowid:\n                next_by_pk_clauses.append(f\"rowid > :p{len(params)}\")\n                params[f\"p{len(params)}\"] = components[0]\n            else:\n                # Apply the tie-breaker based on primary keys\n                if len(components) == len(pks):\n                    param_len = len(params)\n                    next_by_pk_clauses.append(compound_keys_after_sql(pks, param_len))\n                    for i, pk_value in enumerate(components):\n                        params[f\"p{param_len + i}\"] = pk_value\n\n            # Now add the sort SQL, which may incorporate next_by_pk_clauses\n            if sort or sort_desc:\n                if sort_value is None:\n                    if sort_desc:\n                        # Just items where column is null ordered by pk\n                        where_clauses.append(\n                            \"({column} is null and {next_clauses})\".format(\n                                column=escape_sqlite(sort_desc),\n                                next_clauses=\" and \".join(next_by_pk_clauses),\n                            )\n                        )\n                    else:\n                        where_clauses.append(\n                            \"({column} is not null or ({column} is null and {next_clauses}))\".format(\n                                column=escape_sqlite(sort),\n                                next_clauses=\" and \".join(next_by_pk_clauses),\n                            )\n                        )\n                else:\n                    where_clauses.append(\n                        \"({column} {op} :p{p}{extra_desc_only} or ({column} = :p{p} and {next_clauses}))\".format(\n                            column=escape_sqlite(sort or sort_desc),\n                            op=\">\" if sort else \"<\",\n                            p=len(params),\n                            extra_desc_only=(\n                                \"\"\n                                if sort\n                                else \" or {column2} is null\".format(\n                                    column2=escape_sqlite(sort or sort_desc)\n                                )\n                            ),\n                            next_clauses=\" and \".join(next_by_pk_clauses),\n                        )\n                    )\n                    params[f\"p{len(params)}\"] = sort_value\n                order_by = f\"{order_by}, {order_by_pks}\"\n            else:\n                where_clauses.extend(next_by_pk_clauses)\n\n    where_clause = \"\"\n    if where_clauses:\n        where_clause = f\"where {' and '.join(where_clauses)} \"\n\n    if order_by:\n        order_by = f\"order by {order_by}\"\n\n    extra_args = {}\n    # Handle ?_size=500\n    # TODO: This was:\n    # page_size = _size or request.args.get(\"_size\") or table_metadata.get(\"size\")\n    page_size = request.args.get(\"_size\") or table_metadata.get(\"size\")\n    if page_size:\n        if page_size == \"max\":\n            page_size = datasette.max_returned_rows\n        try:\n            page_size = int(page_size)\n            if page_size < 0:\n                raise ValueError\n\n        except ValueError:\n            raise BadRequest(\"_size must be a positive integer\")\n\n        if page_size > datasette.max_returned_rows:\n            raise BadRequest(f\"_size must be <= {datasette.max_returned_rows}\")\n\n        extra_args[\"page_size\"] = page_size\n    else:\n        page_size = datasette.page_size\n\n    # Facets are calculated against SQL without order by or limit\n    sql_no_order_no_limit = (\n        \"select {select_all_columns} from {table_name} {where}\".format(\n            select_all_columns=select_all_columns,\n            table_name=escape_sqlite(table_name),\n            where=where_clause,\n        )\n    )\n\n    # This is the SQL that populates the main table on the page\n    sql = \"select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}\".format(\n        select_specified_columns=select_specified_columns,\n        table_name=escape_sqlite(table_name),\n        where=where_clause,\n        order_by=order_by,\n        page_size=page_size + 1,\n        offset=offset,\n    )\n\n    if request.args.get(\"_timelimit\"):\n        extra_args[\"custom_time_limit\"] = int(request.args.get(\"_timelimit\"))\n\n    # Execute the main query!\n    try:\n        results = await db.execute(sql, params, truncate=True, **extra_args)\n    except (sqlite3.OperationalError, InvalidSql) as e:\n        raise DatasetteError(str(e), title=\"Invalid SQL\", status=400)\n\n    except sqlite3.OperationalError as e:\n        raise DatasetteError(str(e))\n\n    columns = [r[0] for r in results.description]\n    rows = list(results.rows)\n\n    # Expand labeled columns if requested\n    expanded_columns = []\n    # List of (fk_dict, label_column-or-None) pairs for that table\n    expandable_columns = []\n    for fk in await db.foreign_keys_for_table(table_name):\n        label_column = await db.label_column_for_table(fk[\"other_table\"])\n        expandable_columns.append((fk, label_column))\n\n    columns_to_expand = None\n    try:\n        all_labels = value_as_boolean(request.args.get(\"_labels\", \"\"))\n    except ValueError:\n        all_labels = default_labels\n    # Check for explicit _label=\n    if \"_label\" in request.args:\n        columns_to_expand = request.args.getlist(\"_label\")\n    if columns_to_expand is None and all_labels:\n        # expand all columns with foreign keys\n        columns_to_expand = [fk[\"column\"] for fk, _ in expandable_columns]\n\n    if columns_to_expand:\n        expanded_labels = {}\n        for fk, _ in expandable_columns:\n            column = fk[\"column\"]\n            if column not in columns_to_expand:\n                continue\n            if column not in columns:\n                continue\n            expanded_columns.append(column)\n            # Gather the values\n            column_index = columns.index(column)\n            values = [row[column_index] for row in rows]\n            # Expand them\n            expanded_labels.update(\n                await datasette.expand_foreign_keys(\n                    request.actor, database_name, table_name, column, values\n                )\n            )\n        if expanded_labels:\n            # Rewrite the rows\n            new_rows = []\n            for row in rows:\n                new_row = CustomRow(columns)\n                for column in row.keys():\n                    value = row[column]\n                    if (column, value) in expanded_labels and value is not None:\n                        new_row[column] = {\n                            \"value\": value,\n                            \"label\": expanded_labels[(column, value)],\n                        }\n                    else:\n                        new_row[column] = value\n                new_rows.append(new_row)\n            rows = new_rows\n\n    _next = request.args.get(\"_next\")\n\n    # Pagination next link\n    next_value, next_url = await _next_value_and_url(\n        datasette,\n        db,\n        request,\n        table_name,\n        _next,\n        rows,\n        pks,\n        use_rowid,\n        sort,\n        sort_desc,\n        page_size,\n        is_view,\n    )\n    rows = rows[:page_size]\n\n    # Resolve extras\n    extras = _get_extras(request)\n    if any(k for k in request.args.keys() if k == \"_facet\" or k.startswith(\"_facet_\")):\n        extras.add(\"facet_results\")\n    if request.args.get(\"_shape\") == \"object\":\n        extras.add(\"primary_keys\")\n    if extra_extras:\n        extras.update(extra_extras)\n\n    async def extra_count_sql():\n        return count_sql\n\n    async def extra_count():\n        \"Total count of rows matching these filters\"\n        # Calculate the total count for this query\n        count = None\n        if (\n            not db.is_mutable\n            and datasette.inspect_data\n            and count_sql == f\"select count(*) from {table_name} \"\n        ):\n            # We can use a previously cached table row count\n            try:\n                count = datasette.inspect_data[database_name][\"tables\"][table_name][\n                    \"count\"\n                ]\n            except KeyError:\n                pass\n\n        # Otherwise run a select count(*) ...\n        if count_sql and count is None and not nocount:\n            count_sql_limited = (\n                f\"select count(*) from (select * {from_sql} limit 10001)\"\n            )\n            try:\n                count_rows = list(await db.execute(count_sql_limited, from_sql_params))\n                count = count_rows[0][0]\n            except QueryInterrupted:\n                pass\n        return count\n\n    async def facet_instances(extra_count):\n        facet_instances = []\n        facet_classes = list(\n            itertools.chain.from_iterable(pm.hook.register_facet_classes())\n        )\n        for facet_class in facet_classes:\n            facet_instances.append(\n                facet_class(\n                    datasette,\n                    request,\n                    database_name,\n                    sql=sql_no_order_no_limit,\n                    params=params,\n                    table=table_name,\n                    table_config=table_metadata,\n                    row_count=extra_count,\n                )\n            )\n        return facet_instances\n\n    async def extra_facet_results(facet_instances):\n        \"Results of facets calculated against this data\"\n        facet_results = {}\n        facets_timed_out = []\n\n        if not nofacet:\n            # Run them in parallel\n            facet_awaitables = [facet.facet_results() for facet in facet_instances]\n            facet_awaitable_results = await run_sequential(*facet_awaitables)\n            for (\n                instance_facet_results,\n                instance_facets_timed_out,\n            ) in facet_awaitable_results:\n                for facet_info in instance_facet_results:\n                    base_key = facet_info[\"name\"]\n                    key = base_key\n                    i = 1\n                    while key in facet_results:\n                        i += 1\n                        key = f\"{base_key}_{i}\"\n                    facet_results[key] = facet_info\n                facets_timed_out.extend(instance_facets_timed_out)\n\n        return {\n            \"results\": facet_results,\n            \"timed_out\": facets_timed_out,\n        }\n\n    async def extra_suggested_facets(facet_instances):\n        \"Suggestions for facets that might return interesting results\"\n        suggested_facets = []\n        # Calculate suggested facets\n        if (\n            datasette.setting(\"suggest_facets\")\n            and datasette.setting(\"allow_facet\")\n            and not _next\n            and not nofacet\n            and not nosuggest\n        ):\n            # Run them in parallel\n            facet_suggest_awaitables = [facet.suggest() for facet in facet_instances]\n            for suggest_result in await run_sequential(*facet_suggest_awaitables):\n                suggested_facets.extend(suggest_result)\n        return suggested_facets\n\n    # Faceting\n    if not datasette.setting(\"allow_facet\") and any(\n        arg.startswith(\"_facet\") for arg in request.args\n    ):\n        raise BadRequest(\"_facet= is not allowed\")\n\n    # human_description_en combines filters AND search, if provided\n    async def extra_human_description_en():\n        \"Human-readable description of the filters\"\n        human_description_en = filters.human_description_en(\n            extra=extra_human_descriptions\n        )\n        if sort or sort_desc:\n            human_description_en = \" \".join(\n                [b for b in [human_description_en, sorted_by] if b]\n            )\n        return human_description_en\n\n    if sort or sort_desc:\n        sorted_by = \"sorted by {}{}\".format(\n            (sort or sort_desc), \" descending\" if sort_desc else \"\"\n        )\n\n    async def extra_next_url():\n        \"Full URL for the next page of results\"\n        return next_url\n\n    async def extra_columns():\n        \"Column names returned by this query\"\n        return columns\n\n    async def extra_all_columns():\n        \"All columns in the table, regardless of _col/_nocol filtering\"\n        return list(table_columns)\n\n    async def extra_primary_keys():\n        \"Primary keys for this table\"\n        return pks\n\n    async def extra_actions():\n        async def actions():\n            links = []\n            kwargs = {\n                \"datasette\": datasette,\n                \"database\": database_name,\n                \"actor\": request.actor,\n                \"request\": request,\n            }\n            if is_view:\n                kwargs[\"view\"] = table_name\n                method = pm.hook.view_actions\n            else:\n                kwargs[\"table\"] = table_name\n                method = pm.hook.table_actions\n            for hook in method(**kwargs):\n                extra_links = await await_me_maybe(hook)\n                if extra_links:\n                    links.extend(extra_links)\n            return links\n\n        return actions\n\n    async def extra_is_view():\n        return is_view\n\n    async def extra_debug():\n        \"Extra debug information\"\n        return {\n            \"resolved\": repr(resolved),\n            \"url_vars\": request.url_vars,\n            \"nofacet\": nofacet,\n            \"nosuggest\": nosuggest,\n        }\n\n    async def extra_request():\n        \"Full information about the request\"\n        return {\n            \"url\": request.url,\n            \"path\": request.path,\n            \"full_path\": request.full_path,\n            \"host\": request.host,\n            \"args\": request.args._data,\n        }\n\n    async def run_display_columns_and_rows():\n        display_columns, display_rows = await display_columns_and_rows(\n            datasette,\n            database_name,\n            table_name,\n            results.description,\n            rows,\n            link_column=not is_view,\n            truncate_cells=datasette.setting(\"truncate_cells_html\"),\n            sortable_columns=sortable_columns,\n            request=request,\n        )\n        return {\n            \"columns\": display_columns,\n            \"rows\": display_rows,\n        }\n\n    async def extra_display_columns(run_display_columns_and_rows):\n        return run_display_columns_and_rows[\"columns\"]\n\n    async def extra_display_rows(run_display_columns_and_rows):\n        return run_display_columns_and_rows[\"rows\"]\n\n    async def extra_render_cell():\n        \"Rendered HTML for each cell using the render_cell plugin hook\"\n        pks_for_display = pks if pks else ([\"rowid\"] if not is_view else [])\n        col_names = [col[0] for col in results.description]\n        ct_map = await datasette.get_column_types(database_name, table_name)\n        rendered_rows = []\n        for row in rows:\n            rendered_row = {}\n            for value, column in zip(row, col_names):\n                ct = ct_map.get(column)\n                plugin_display_value = None\n                # Try column type render_cell first\n                if ct:\n                    candidate = await ct.render_cell(\n                        value=value,\n                        column=column,\n                        table=table_name,\n                        database=database_name,\n                        datasette=datasette,\n                        request=request,\n                    )\n                    if candidate is not None:\n                        plugin_display_value = candidate\n                if plugin_display_value is None:\n                    for candidate in pm.hook.render_cell(\n                        row=row,\n                        value=value,\n                        column=column,\n                        table=table_name,\n                        pks=pks_for_display,\n                        database=database_name,\n                        datasette=datasette,\n                        request=request,\n                        column_type=ct,\n                    ):\n                        candidate = await await_me_maybe(candidate)\n                        if candidate is not None:\n                            plugin_display_value = candidate\n                            break\n                if plugin_display_value:\n                    rendered_row[column] = str(plugin_display_value)\n            rendered_rows.append(rendered_row)\n        return rendered_rows\n\n    async def extra_query():\n        \"Details of the underlying SQL query\"\n        return {\n            \"sql\": sql,\n            \"params\": params,\n        }\n\n    async def extra_column_types():\n        \"Column type assignments for this table\"\n        ct_map = await datasette.get_column_types(database_name, table_name)\n        return {\n            col_name: {\n                \"type\": ct.name,\n                \"config\": ct.config,\n            }\n            for col_name, ct in ct_map.items()\n        }\n\n    async def extra_set_column_type_ui():\n        \"Column type UI metadata for this table\"\n        if is_view:\n            return None\n\n        if not await datasette.allowed(\n            action=\"set-column-type\",\n            resource=TableResource(database=database_name, table=table_name),\n            actor=request.actor,\n        ):\n            return None\n\n        column_details = await datasette._get_resource_column_details(\n            database_name, table_name\n        )\n        ct_map = await datasette.get_column_types(database_name, table_name)\n        columns = {}\n        for column_name, column_detail in column_details.items():\n            current = ct_map.get(column_name)\n            columns[column_name] = {\n                \"current\": (\n                    {\"type\": current.name, \"config\": current.config}\n                    if current is not None\n                    else None\n                ),\n                \"options\": [\n                    {\n                        \"name\": name,\n                        \"description\": ct_cls.description,\n                    }\n                    for name, ct_cls in sorted(datasette._column_types.items())\n                    if datasette._column_type_is_applicable(ct_cls, column_detail)\n                ],\n            }\n        return {\n            \"path\": \"{}/-/set-column-type\".format(\n                datasette.urls.table(database_name, table_name)\n            ),\n            \"columns\": columns,\n        }\n\n    async def extra_metadata():\n        \"Metadata about the table and database\"\n        tablemetadata = await datasette.get_resource_metadata(database_name, table_name)\n\n        rows = await datasette.get_internal_database().execute(\n            \"\"\"\n              SELECT\n                column_name,\n                value\n              FROM metadata_columns\n              WHERE database_name = ?\n                AND resource_name = ?\n                AND key = 'description'\n            \"\"\",\n            [database_name, table_name],\n        )\n        tablemetadata[\"columns\"] = dict(rows)\n        return tablemetadata\n\n    async def extra_database():\n        return database_name\n\n    async def extra_table():\n        return table_name\n\n    async def extra_database_color():\n        return db.color\n\n    async def extra_form_hidden_args():\n        form_hidden_args = []\n        for key in request.args:\n            if (\n                key.startswith(\"_\")\n                and key not in (\"_sort\", \"_sort_desc\", \"_search\", \"_next\")\n                and \"__\" not in key\n            ):\n                for value in request.args.getlist(key):\n                    form_hidden_args.append((key, value))\n        return form_hidden_args\n\n    async def extra_filters():\n        return filters\n\n    async def extra_custom_table_templates():\n        return [\n            f\"_table-{to_css_class(database_name)}-{to_css_class(table_name)}.html\",\n            f\"_table-table-{to_css_class(database_name)}-{to_css_class(table_name)}.html\",\n            \"_table.html\",\n        ]\n\n    async def extra_sorted_facet_results(extra_facet_results):\n        facet_configs = table_metadata.get(\"facets\", [])\n        if facet_configs:\n            # Build ordered list of facet names from metadata config\n            metadata_facet_names = []\n            for fc in facet_configs:\n                if isinstance(fc, str):\n                    metadata_facet_names.append(fc)\n                elif isinstance(fc, dict):\n                    metadata_facet_names.append(list(fc.values())[0])\n            metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}\n            metadata_facets = []\n            request_facets = []\n            for f in extra_facet_results[\"results\"].values():\n                if f[\"name\"] in metadata_order:\n                    metadata_facets.append(f)\n                else:\n                    request_facets.append(f)\n            metadata_facets.sort(key=lambda f: metadata_order[f[\"name\"]])\n            request_facets.sort(\n                key=lambda f: (len(f[\"results\"]), f[\"name\"]),\n                reverse=True,\n            )\n            return metadata_facets + request_facets\n        else:\n            return sorted(\n                extra_facet_results[\"results\"].values(),\n                key=lambda f: (len(f[\"results\"]), f[\"name\"]),\n                reverse=True,\n            )\n\n    async def extra_table_definition():\n        return await db.get_table_definition(table_name)\n\n    async def extra_view_definition():\n        return await db.get_view_definition(table_name)\n\n    async def extra_renderers(extra_expandable_columns, extra_query):\n        renderers = {}\n        url_labels_extra = {}\n        if extra_expandable_columns:\n            url_labels_extra = {\"_labels\": \"on\"}\n        for key, (_, can_render) in datasette.renderers.items():\n            it_can_render = call_with_supported_arguments(\n                can_render,\n                datasette=datasette,\n                columns=columns or [],\n                rows=rows or [],\n                sql=extra_query.get(\"sql\", None),\n                query_name=None,\n                database=database_name,\n                table=table_name,\n                request=request,\n                view_name=\"table\",\n            )\n            it_can_render = await await_me_maybe(it_can_render)\n            if it_can_render:\n                renderers[key] = datasette.urls.path(\n                    path_with_format(\n                        request=request, format=key, extra_qs={**url_labels_extra}\n                    )\n                )\n        return renderers\n\n    async def extra_private():\n        return private\n\n    async def extra_expandable_columns():\n        expandables = []\n        db = datasette.databases[database_name]\n        for fk in await db.foreign_keys_for_table(table_name):\n            label_column = await db.label_column_for_table(fk[\"other_table\"])\n            expandables.append((fk, label_column))\n        return expandables\n\n    async def extra_extras():\n        \"Available ?_extra= blocks\"\n        all_extras = [\n            (key[len(\"extra_\") :], fn.__doc__)\n            for key, fn in registry._registry.items()\n            if key.startswith(\"extra_\")\n        ]\n        return [\n            {\n                \"name\": name,\n                \"description\": doc,\n                \"toggle_url\": datasette.absolute_url(\n                    request,\n                    datasette.urls.path(\n                        path_with_added_args(request, {\"_extra\": name})\n                        if name not in extras\n                        else path_with_removed_args(request, {\"_extra\": name})\n                    ),\n                ),\n                \"selected\": name in extras,\n            }\n            for name, doc in all_extras\n        ]\n\n    async def extra_facets_timed_out(extra_facet_results):\n        return extra_facet_results[\"timed_out\"]\n\n    bundles = {\n        \"html\": [\n            \"suggested_facets\",\n            \"facet_results\",\n            \"facets_timed_out\",\n            \"count\",\n            \"count_sql\",\n            \"human_description_en\",\n            \"next_url\",\n            \"metadata\",\n            \"query\",\n            \"columns\",\n            \"display_columns\",\n            \"display_rows\",\n            \"database\",\n            \"table\",\n            \"database_color\",\n            \"actions\",\n            \"filters\",\n            \"renderers\",\n            \"custom_table_templates\",\n            \"sorted_facet_results\",\n            \"table_definition\",\n            \"view_definition\",\n            \"is_view\",\n            \"private\",\n            \"primary_keys\",\n            \"all_columns\",\n            \"expandable_columns\",\n            \"form_hidden_args\",\n            \"set_column_type_ui\",\n        ]\n    }\n\n    for key, values in bundles.items():\n        if f\"_{key}\" in extras:\n            extras.update(values)\n        extras.discard(f\"_{key}\")\n\n    registry = Registry(\n        extra_count,\n        extra_count_sql,\n        extra_facet_results,\n        extra_facets_timed_out,\n        extra_suggested_facets,\n        facet_instances,\n        extra_human_description_en,\n        extra_next_url,\n        extra_columns,\n        extra_all_columns,\n        extra_primary_keys,\n        run_display_columns_and_rows,\n        extra_display_columns,\n        extra_display_rows,\n        extra_render_cell,\n        extra_debug,\n        extra_request,\n        extra_query,\n        extra_column_types,\n        extra_set_column_type_ui,\n        extra_metadata,\n        extra_extras,\n        extra_database,\n        extra_table,\n        extra_database_color,\n        extra_actions,\n        extra_filters,\n        extra_renderers,\n        extra_custom_table_templates,\n        extra_sorted_facet_results,\n        extra_table_definition,\n        extra_view_definition,\n        extra_is_view,\n        extra_private,\n        extra_expandable_columns,\n        extra_form_hidden_args,\n    )\n\n    results = await registry.resolve_multi(\n        [\"extra_{}\".format(extra) for extra in extras]\n    )\n    data = {\n        \"ok\": True,\n        \"next\": next_value and str(next_value) or None,\n    }\n    data.update(\n        {\n            key.replace(\"extra_\", \"\"): value\n            for key, value in results.items()\n            if key.startswith(\"extra_\") and key.replace(\"extra_\", \"\") in extras\n        }\n    )\n    raw_sqlite_rows = rows[:page_size]\n    # Apply transform_value for columns with assigned types\n    ct_map = await datasette.get_column_types(database_name, table_name)\n    transformed_rows = []\n    for r in raw_sqlite_rows:\n        row_dict = dict(r)\n        for col_name, ct in ct_map.items():\n            if col_name in row_dict:\n                row_dict[col_name] = await ct.transform_value(\n                    row_dict[col_name], datasette\n                )\n        transformed_rows.append(row_dict)\n    data[\"rows\"] = transformed_rows\n\n    if context_for_html_hack:\n        data.update(extra_context_from_filters)\n        # filter_columns combine the columns we know are available\n        # in the table with any additional columns (such as rowid)\n        # which are available in the query\n        data[\"filter_columns\"] = list(columns) + [\n            table_column\n            for table_column in table_columns\n            if table_column not in columns\n        ]\n        url_labels_extra = {}\n        if data.get(\"expandable_columns\"):\n            url_labels_extra = {\"_labels\": \"on\"}\n        url_csv_args = {\"_size\": \"max\", **url_labels_extra}\n        url_csv = datasette.urls.path(\n            path_with_format(request=request, format=\"csv\", extra_qs=url_csv_args)\n        )\n        url_csv_path = url_csv.split(\"?\")[0]\n        data.update(\n            {\n                \"url_csv\": url_csv,\n                \"url_csv_path\": url_csv_path,\n                \"url_csv_hidden_args\": [\n                    (key, value)\n                    for key, value in urllib.parse.parse_qsl(request.query_string)\n                    if key not in (\"_labels\", \"_facet\", \"_size\")\n                ]\n                + [(\"_size\", \"max\")],\n            }\n        )\n        # if no sort specified AND table has a single primary key,\n        # set sort to that so arrow is displayed\n        if not sort and not sort_desc:\n            if 1 == len(pks):\n                sort = pks[0]\n            elif use_rowid:\n                sort = \"rowid\"\n        data[\"sort\"] = sort\n        data[\"sort_desc\"] = sort_desc\n\n    return data, rows[:page_size], columns, expanded_columns, sql, next_url\n\n\nasync def _next_value_and_url(\n    datasette,\n    db,\n    request,\n    table_name,\n    _next,\n    rows,\n    pks,\n    use_rowid,\n    sort,\n    sort_desc,\n    page_size,\n    is_view,\n):\n    next_value = None\n    next_url = None\n    if 0 < page_size < len(rows):\n        if is_view:\n            next_value = int(_next or 0) + page_size\n        else:\n            next_value = path_from_row_pks(rows[-2], pks, use_rowid)\n        # If there's a sort or sort_desc, add that value as a prefix\n        if (sort or sort_desc) and not is_view:\n            try:\n                prefix = rows[-2][sort or sort_desc]\n            except IndexError:\n                # sort/sort_desc column missing from SELECT - look up value by PK instead\n                prefix_where_clause = \" and \".join(\n                    \"[{}] = :pk{}\".format(pk, i) for i, pk in enumerate(pks)\n                )\n                prefix_lookup_sql = \"select [{}] from [{}] where {}\".format(\n                    sort or sort_desc, table_name, prefix_where_clause\n                )\n                prefix = (\n                    await db.execute(\n                        prefix_lookup_sql,\n                        {\n                            **{\n                                \"pk{}\".format(i): rows[-2][pk]\n                                for i, pk in enumerate(pks)\n                            }\n                        },\n                    )\n                ).single_value()\n            if isinstance(prefix, dict) and \"value\" in prefix:\n                prefix = prefix[\"value\"]\n            if prefix is None:\n                prefix = \"$null\"\n            else:\n                prefix = tilde_encode(str(prefix))\n            next_value = f\"{prefix},{next_value}\"\n            added_args = {\"_next\": next_value}\n            if sort:\n                added_args[\"_sort\"] = sort\n            else:\n                added_args[\"_sort_desc\"] = sort_desc\n        else:\n            added_args = {\"_next\": next_value}\n        next_url = datasette.absolute_url(\n            request, datasette.urls.path(path_with_replaced_args(request, added_args))\n        )\n    return next_value, next_url\n"
  },
  {
    "path": "demos/apache-proxy/000-default.conf",
    "content": "<Directory /app/html/>\n    Options Indexes FollowSymLinks\n    AllowOverride None\n    Require all granted\n</Directory>\n\n<VirtualHost *:80>\n    ServerName localhost\n    DocumentRoot /app/html\n    ProxyPreserveHost On\n    ProxyPass /prefix/ http://127.0.0.1:8001/\n    Header add X-Proxied-By \"Apache2 Debian\"\n</VirtualHost>\n"
  },
  {
    "path": "demos/apache-proxy/Dockerfile",
    "content": "FROM python:3.11.0-slim-bullseye\n\nRUN apt-get update && \\\n    apt-get install -y apache2 supervisor && \\\n    apt clean && \\\n    rm -rf /var/lib/apt && \\\n    rm -rf /var/lib/dpkg/info/*\n\n# Apache environment, copied from\n# https://github.com/ijklim/laravel-benfords-law-app/blob/e9bf385dcaddb62ea466a7b245ab6e4ef708c313/docker/os/Dockerfile\nENV APACHE_DOCUMENT_ROOT=/var/www/html/public\nENV APACHE_RUN_USER www-data\nENV APACHE_RUN_GROUP www-data\nENV APACHE_PID_FILE /var/run/apache2.pid\nENV APACHE_RUN_DIR /var/run/apache2\nENV APACHE_LOCK_DIR /var/lock/apache2\nENV APACHE_LOG_DIR /var/log\nRUN ln -sf /dev/stdout /var/log/apache2-access.log\nRUN ln -sf /dev/stderr /var/log/apache2-error.log\nRUN mkdir -p $APACHE_RUN_DIR $APACHE_LOCK_DIR\n\nRUN a2enmod proxy\nRUN a2enmod proxy_http\nRUN a2enmod headers\n\nARG DATASETTE_REF\n\nRUN pip install \\\n    https://github.com/simonw/datasette/archive/${DATASETTE_REF}.zip \\\n    datasette-redirect-to-https datasette-debug-asgi\n\nADD 000-default.conf /etc/apache2/sites-enabled/000-default.conf\n\nWORKDIR /app\nRUN mkdir -p /app/html\nRUN echo '<h1><a href=\"/prefix/\">Demo is at /prefix/</a></h1>' > /app/html/index.html\n\nADD https://latest.datasette.io/fixtures.db /app/fixtures.db\n\nEXPOSE 80\n\n# Dynamically build supervisord config since it includes $DATASETTE_REF:\nRUN echo \"[supervisord]\" >> /app/supervisord.conf\nRUN echo \"nodaemon=true\" >> /app/supervisord.conf\nRUN echo \"\" >> /app/supervisord.conf\nRUN echo \"[program:apache2]\" >> /app/supervisord.conf\nRUN echo \"command=apache2 -D FOREGROUND\" >> /app/supervisord.conf\nRUN echo \"stdout_logfile=/dev/stdout\" >> /app/supervisord.conf\nRUN echo \"stdout_logfile_maxbytes=0\" >> /app/supervisord.conf\nRUN echo \"\" >> /app/supervisord.conf\nRUN echo \"[program:datasette]\" >> /app/supervisord.conf\nRUN echo \"command=datasette /app/fixtures.db --setting base_url '/prefix/' --version-note '${DATASETTE_REF}' -h 0.0.0.0 -p 8001\" >> /app/supervisord.conf\nRUN echo \"stdout_logfile=/dev/stdout\" >> /app/supervisord.conf\nRUN echo \"stdout_logfile_maxbytes=0\" >> /app/supervisord.conf\n\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/app/supervisord.conf\"]\n"
  },
  {
    "path": "demos/apache-proxy/README.md",
    "content": "# Datasette running behind an Apache proxy\n\nSee also [Running Datasette behind a proxy](https://docs.datasette.io/en/latest/deploying.html#running-datasette-behind-a-proxy)\n\nThis live demo is running at https://datasette-apache-proxy-demo.fly.dev/prefix/\n\nTo build locally, passing in a Datasette commit hash (or `main` for the main branch):\n\n    docker build -t datasette-apache-proxy-demo . \\\n      --build-arg DATASETTE_REF=c617e1769ea27e045b0f2907ef49a9a1244e577d\n\nThen run it like this:\n\n    docker run -p 5000:80 datasette-apache-proxy-demo\n\nAnd visit `http://localhost:5000/` or `http://localhost:5000/prefix/`\n\n## Deployment to Fly\n\nTo deploy to [Fly](https://fly.io/) first create an application there by running:\n\n    flyctl apps create --name datasette-apache-proxy-demo\n\nYou will need a different name, since I have already taken that one.\n\nThen run this command to deploy:\n\n    flyctl deploy --build-arg DATASETTE_REF=main\n\nThis uses `fly.toml` in this directory, which hard-codes the `datasette-apache-proxy-demo` name - so you would need to edit that file to match your application name before running this.\n\n## Deployment to Cloud Run\n\nDeployments to Cloud Run currently result in intermittent 503 errors and I'm not sure why, see [issue #1522](https://github.com/simonw/datasette/issues/1522).\n\nYou can deploy like this:\n\n    DATASETTE_REF=main ./deploy-to-cloud-run.sh\n"
  },
  {
    "path": "demos/apache-proxy/deploy-to-cloud-run.sh",
    "content": "#!/bin/bash\n# https://til.simonwillison.net/cloudrun/using-build-args-with-cloud-run\n\nif [[ -z \"$DATASETTE_REF\" ]]; then\n    echo \"Must provide DATASETTE_REF environment variable\" 1>&2\n    exit 1\nfi\n\nNAME=\"datasette-apache-proxy-demo\"\nPROJECT=$(gcloud config get-value project)\nIMAGE=\"gcr.io/$PROJECT/$NAME\"\n\n# Need YAML so we can set --build-arg\necho \"\nsteps:\n- name: 'gcr.io/cloud-builders/docker'\n  args: ['build', '-t', '$IMAGE', '.', '--build-arg', 'DATASETTE_REF=$DATASETTE_REF']\n- name: 'gcr.io/cloud-builders/docker'\n  args: ['push', '$IMAGE']\n\" > /tmp/cloudbuild.yml\n\ngcloud builds submit --config /tmp/cloudbuild.yml\n\nrm /tmp/cloudbuild.yml\n\ngcloud run deploy $NAME \\\n  --allow-unauthenticated \\\n  --platform=managed \\\n  --image $IMAGE \\\n  --port 80\n"
  },
  {
    "path": "demos/apache-proxy/fly.toml",
    "content": "app = \"datasette-apache-proxy-demo\"\n\nkill_signal = \"SIGINT\"\nkill_timeout = 5\nprocesses = []\n\n[env]\n\n[experimental]\n  allowed_public_ports = []\n  auto_rollback = true\n\n[[services]]\n  http_checks = []\n  internal_port = 80\n  processes = [\"app\"]\n  protocol = \"tcp\"\n  script_checks = []\n\n  [services.concurrency]\n    hard_limit = 25\n    soft_limit = 20\n    type = \"connections\"\n\n  [[services.ports]]\n    handlers = [\"http\"]\n    port = 80\n\n  [[services.ports]]\n    handlers = [\"tls\", \"http\"]\n    port = 443\n\n  [[services.tcp_checks]]\n    grace_period = \"1s\"\n    interval = \"15s\"\n    restart_limit = 0\n    timeout = \"2s\"\n"
  },
  {
    "path": "demos/plugins/example_js_manager_plugins.py",
    "content": "from datasette import hookimpl\n\n# Test command:\n# datasette fixtures.db \\ --plugins-dir=demos/plugins/\n#                       \\ --static static:demos/plugins/static\n\n# Create a set with view names that qualify for this JS, since plugins won't do anything on other pages\n# Same pattern as in Nteract data explorer\n# https://github.com/hydrosquall/datasette-nteract-data-explorer/blob/main/datasette_nteract_data_explorer/__init__.py#L77\nPERMITTED_VIEWS = {\"table\", \"query\", \"database\"}\n\n\n@hookimpl\ndef extra_js_urls(view_name):\n    print(view_name)\n    if view_name in PERMITTED_VIEWS:\n        return [\n            {\n                \"url\": \"/static/table-example-plugins.js\",\n            }\n        ]\n"
  },
  {
    "path": "demos/plugins/static/table-example-plugins.js",
    "content": "/**\n * Example usage of Datasette JS Manager API\n */\n\ndocument.addEventListener(\"datasette_init\", function (evt) {\n  const { detail: manager } = evt;\n  // === Demo plugins: remove before merge===\n  addPlugins(manager);\n});\n\n/**\n * Examples for to test datasette JS api\n */\nconst addPlugins = (manager) => {\n\n  manager.registerPlugin(\"column-name-plugin\", {\n    version: 0.1,\n    makeColumnActions: (columnMeta) => {\n      const { column } = columnMeta;\n\n      return [\n        {\n          label: \"Copy name to clipboard\",\n          onClick: (evt) => copyToClipboard(column),\n        },\n        {\n          label: \"Log column metadata to console\",\n          onClick: (evt) => console.log(column),\n        },\n      ];\n    },\n  });\n\n  manager.registerPlugin(\"panel-plugin-graphs\", {\n    version: 0.1,\n    makeAboveTablePanelConfigs: () => {\n      return [\n        {\n          id: 'first-panel',\n          label: \"First\",\n          render: node => {\n            const description = document.createElement('p');\n            description.innerText = 'Hello world';\n            node.appendChild(description);\n          }\n        },\n        {\n          id: 'second-panel',\n          label: \"Second\",\n          render: node => {\n            const iframe = document.createElement('iframe');\n            iframe.src = \"https://observablehq.com/embed/@d3/sortable-bar-chart?cell=viewof+order&cell=chart\";\n            iframe.width = 800;\n            iframe.height = 635;\n            iframe.frameborder = '0';\n            node.appendChild(iframe);\n          }\n        },\n      ];\n    },\n  });\n\n  manager.registerPlugin(\"panel-plugin-maps\", {\n    version: 0.1,\n    makeAboveTablePanelConfigs: () => {\n      return [\n        {\n          // ID only has to be unique within a plugin, manager namespaces for you\n          id: 'first-map-panel',\n          label: \"Map plugin\",\n          // datasette-vega, leafleft can provide a \"render\" function\n          render: node => node.innerHTML = \"Here sits a map\",\n        },\n        {\n          id: 'second-panel',\n          label: \"Image plugin\",\n          render: node => {\n            const img = document.createElement('img');\n            img.src = 'https://datasette.io/static/datasette-logo.svg'\n            node.appendChild(img);\n          },\n        }\n      ];\n    },\n  });\n\n  // Future: dispatch message to some other part of the page with CustomEvent API\n  // Could use to drive filter/sort query builder actions without  page refresh.\n}\n\n\n\nasync function copyToClipboard(str) {\n  try {\n    await navigator.clipboard.writeText(str);\n  } catch (err) {\n    /** Rejected - text failed to copy to the clipboard. Browsers didn't give permission */\n    console.error('Failed to copy: ', err);\n  }\n}\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "_build\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nSPHINXPROJ    = Datasette\nSOURCEDIR     = .\nBUILDDIR      = _build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\nlivehtml:\n\tsphinx-autobuild -b html \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(0)\n"
  },
  {
    "path": "docs/_static/css/custom.css",
    "content": "a.external {\n    overflow-wrap: anywhere;\n}\nbody[data-theme=\"dark\"] .sidebar-logo-container {\n    background-color: white;\n    padding: 5px;\n    opacity: 0.6;\n}\n"
  },
  {
    "path": "docs/_static/js/custom.js",
    "content": "jQuery(function ($) {\n  // Show banner linking to /stable/ if this is a /latest/ page\n  if (!/\\/latest\\//.test(location.pathname)) {\n    return;\n  }\n  var stableUrl = location.pathname.replace(\"/latest/\", \"/stable/\");\n  // Check it's not a 404\n  fetch(stableUrl, { method: \"HEAD\" }).then((response) => {\n    if (response.status == 200) {\n      var warning = $(\n        `<div class=\"admonition warning\">\n           <p class=\"first admonition-title\">Note</p>\n           <p class=\"last\">\n             This documentation covers the <strong>development version</strong> of Datasette.</p>\n             <p>See <a href=\"${stableUrl}\">this page</a> for the current stable release.\n           </p>\n        </div>`\n      );\n      warning.find(\"a\").attr(\"href\", stableUrl);\n      $(\"article[role=main]\").prepend(warning);\n    }\n  });\n});\n"
  },
  {
    "path": "docs/_templates/base.html",
    "content": "{%- extends \"!base.html\" %}\n\n{% block site_meta %}\n{{ super() }}\n<script defer data-domain=\"docs.datasette.io\" src=\"https://plausible.io/js/plausible.js\"></script>\n{% endblock %}\n\n{% block scripts %}\n{{ super() }}\n<script>\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n  // Show banner linking to /stable/ if this is a /latest/ page\n  if (!/\\/latest\\//.test(location.pathname)) {\n    return;\n  }\n  var stableUrl = location.pathname.replace(\"/latest/\", \"/stable/\");\n  // Check it's not a 404\n  fetch(stableUrl, { method: \"HEAD\" }).then((response) => {\n    if (response.status === 200) {\n      var warning = document.createElement(\"div\");\n      warning.className = \"admonition warning\";\n      warning.innerHTML = `\n        <p class=\"first admonition-title\">Note</p>\n        <p class=\"last\">\n          This documentation covers the <strong>development version</strong> of Datasette.\n        </p>\n        <p>\n          See <a href=\"${stableUrl}\">this page</a> for the current stable release.\n        </p>\n      `;\n      var mainArticle = document.querySelector(\"article[role=main]\");\n      mainArticle.insertBefore(warning, mainArticle.firstChild);\n    }\n  });\n});\n</script>\n{% endblock %}\n"
  },
  {
    "path": "docs/_templates/sidebar/brand.html",
    "content": "<div class=\"sidebar-brand centered\">\n  {% block brand_content %}\n  <div class=\"sidebar-logo-container\">\n    <a href=\"https://datasette.io/\"><img class=\"sidebar-logo\" src=\"{{ logo_url }}\" alt=\"Datasette\"></a>\n  </div>\n  {%- set nav_version = version %}\n  {% if READTHEDOCS and current_version %}\n    {%- set nav_version = current_version %}\n  {% endif %}\n  {% if nav_version %}\n    <div class=\"version\">\n      {{ nav_version }}\n    </div>\n  {% endif %}\n  {% endblock brand_content %}\n</div>\n"
  },
  {
    "path": "docs/_templates/sidebar/navigation.html",
    "content": "<div class=\"sidebar-tree\">\n  <ul>\n    <li class=\"toctree-l1\"><a class=\"reference internal\" href=\"{{ pathto(master_doc) }}\">Contents</a></li>\n  </ul>\n  {{ toctree(\n    collapse=True,\n    titles_only=False,\n    maxdepth=3,\n    includehidden=True,\n) }}\n</div>"
  },
  {
    "path": "docs/authentication.rst",
    "content": ".. _authentication:\n\n================================\n Authentication and permissions\n================================\n\nDatasette doesn't require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries.\n\nDatasette can be configured to only allow authenticated users, or to control which databases, tables, and queries can be accessed by the public or by specific users. Datasette's plugin system can be used to add many different styles of authentication, such as user accounts, single sign-on or API keys.\n\n.. _authentication_actor:\n\nActors\n======\n\nThrough plugins, Datasette can support both authenticated users (with cookies) and authenticated API clients (via authentication tokens). The word \"actor\" is used to cover both of these cases.\n\nEvery request to Datasette has an associated actor value, available in the code as ``request.actor``. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API clients.\n\nThe actor dictionary can be any shape - the design of that data structure is left up to the plugins. Actors should always include a unique ``\"id\"`` string, as demonstrated by the \"root\" actor below.\n\nPlugins can use the :ref:`plugin_hook_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.\n\n.. _authentication_root:\n\nUsing the \"root\" actor\n----------------------\n\nDatasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.\n\nThe one exception is the \"root\" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.\n\nThe ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:\n\n* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)\n* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)\n* Debug permissions (``permissions-debug``, ``debug-menu``)\n* Any custom permissions defined by plugins\n\nIf you add explicit deny rules in ``datasette.yaml`` those can still block the\nroot actor from specific databases or tables.\n\nThe ``--root`` flag sets an internal ``root_enabled`` switch—without it, a signed-in user with ``{\"id\": \"root\"}`` is treated like any other actor.\n\nTo sign in as root, start Datasette using the ``--root`` command-line option, like this::\n\n    datasette --root\n\nDatasette will output a single-use-only login URL on startup::\n\n    http://127.0.0.1:8001/-/auth-token?token=786fc524e0199d70dc9a581d851f466244e114ca92f33aa3b42a139e9388daa7\n    INFO:     Started server process [25801]\n    INFO:     Waiting for application startup.\n    INFO:     Application startup complete.\n    INFO:     Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)\n\nClick on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm that you are authenticated as an actor that looks like this:\n\n.. code-block:: json\n\n    {\n        \"id\": \"root\"\n    }\n\n.. _authentication_permissions:\n\nPermissions\n===========\n\nDatasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.\n\nThe key question the permissions system answers is this:\n\n    Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?\n\n**Actors** are :ref:`described above <authentication_actor>`.\n\nAn **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.\n\nA **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource.\n\nDatasette's built-in view actions (``view-database``, ``view-table`` etc) are allowed by Datasette's default configuration: unless you :ref:`configure additional permission rules <authentication_permissions_config>` unauthenticated users will be allowed to access content.\n\nOther actions, including those introduced by plugins, will default to *deny*.\n\n.. _authentication_default_deny:\n\nDenying all permissions by default\n----------------------------------\n\nBy default, Datasette allows unauthenticated access to view databases, tables, and execute SQL queries.\n\nYou may want to run Datasette in a mode where **all** access is denied by default, and you explicitly grant permissions only to authenticated users, either using the :ref:`--root mechanism <authentication_root>` or through :ref:`configuration file rules <authentication_permissions_config>` or plugins.\n\nUse the ``--default-deny`` command-line option to run Datasette in this mode::\n\n    datasette --default-deny data.db --root\n\nWith ``--default-deny`` enabled:\n\n* Anonymous users are denied access to view the instance, databases, tables, and queries\n* Authenticated users are also denied access unless they're explicitly granted permissions\n* The root user (when using ``--root``) still has access to everything\n* You can grant permissions using :ref:`configuration file rules <authentication_permissions_config>` or plugins\n\nFor example, to allow only a specific user to access your instance::\n\n    datasette --default-deny data.db --config datasette.yaml\n\nWhere ``datasette.yaml`` contains:\n\n.. code-block:: yaml\n\n    allow:\n      id: alice\n\nThis configuration will deny access to everyone except the user with ``id`` of ``alice``.\n\n.. _authentication_permissions_explained:\n\nHow permissions are resolved\n----------------------------\n\nDatasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. \n\n``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.\n\nWhen a check runs Datasette gathers allow/deny rules from multiple sources and\ncompiles them into a SQL query. The resulting query describes all of the\nresources an actor may access for that action, together with the reasons those\nresources were allowed or denied. The combined sources are:\n\n* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.\n* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.\n* The \"root\" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.\n* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.\n\nDatasette evaluates the SQL to determine if the requested ``resource`` is\nincluded. Explicit deny rules returned by configuration or plugins will block\naccess even if other rules allowed it.\n\n.. _authentication_permissions_allow:\n\nDefining permissions with \"allow\" blocks\n----------------------------------------\n\nOne way to define permissions in Datasette is to use an ``\"allow\"`` block :ref:`in the datasette.yaml file <authentication_permissions_config>`. This is a JSON document describing which actors are allowed to perform an action against a specific resource.\n\nEach ``allow`` block is compiled into SQL and combined with any\n:ref:`plugin-provided rules <plugin_hook_permission_resources_sql>` to produce\nthe cascading allow/deny decisions that power :ref:`datasette_allowed`.\n\nThe most basic form of allow block is this (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%22id%22%3A+%22root%22%7D&allow=%7B%0D%0A++++++++%22id%22%3A+%22root%22%0D%0A++++%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%22id%22%3A+%22trevor%22%7D&allow=%7B%0D%0A++++++++%22id%22%3A+%22root%22%0D%0A++++%7D>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          id: root\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          id: root\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"id\": \"root\"\n          }\n        }\n.. [[[end]]]\n\nThis will match any actors with an ``\"id\"`` property of ``\"root\"`` - for example, an actor that looks like this:\n\n.. code-block:: json\n\n    {\n        \"id\": \"root\",\n        \"name\": \"Root User\"\n    }\n\nAn allow block can specify \"deny all\" using ``false`` (`demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22root%22%0D%0A%7D&allow=false>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow: false\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow: false\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": false\n        }\n.. [[[end]]]\n\nAn ``\"allow\"`` of ``true`` allows all access (`demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22root%22%0D%0A%7D&allow=true>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow: true\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow: true\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": true\n        }\n.. [[[end]]]\n\nAllow keys can provide a list of values. These will match any actor that has any of those values (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22pancakes%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%0D%0A%7D>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          id:\n          - simon\n          - cleopaws\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          id:\n          - simon\n          - cleopaws\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"id\": [\n              \"simon\",\n              \"cleopaws\"\n            ]\n          }\n        }\n.. [[[end]]]\n\nThis will match any actor with an ``\"id\"`` of either ``\"simon\"`` or ``\"cleopaws\"``.\n\nActors can have properties that feature a list of values. These will be matched against the list of values in an allow block. Consider the following actor:\n\n.. code-block:: json\n\n      {\n          \"id\": \"simon\",\n          \"roles\": [\"staff\", \"developer\"]\n      }\n\nThis allow block will provide access to any actor that has ``\"developer\"`` as one of their roles (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22simon%22%2C%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22staff%22%2C%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%2C%0D%0A++++%22roles%22%3A+%5B%22dog%22%5D%0D%0A%7D&allow=%7B%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          roles:\n          - developer\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          roles:\n          - developer\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"roles\": [\n              \"developer\"\n            ]\n          }\n        }\n.. [[[end]]]\n\nNote that \"roles\" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on.\n\nIf you want to provide access to any actor with a value for a specific key, use ``\"*\"``. For example, to match any logged-in user specify the following (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22simon%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%22*%22%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22bot%22%3A+%22readme-bot%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%22*%22%0D%0A%7D>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          id: \"*\"\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          id: \"*\"\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"id\": \"*\"\n          }\n        }\n.. [[[end]]]\n\nYou can specify that only unauthenticated actors (from anonymous HTTP requests) should be allowed access using the special ``\"unauthenticated\": true`` key in an allow block (`allow demo <https://latest.datasette.io/-/allow-debug?actor=null&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22hello%22%0D%0A%7D&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__):\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          unauthenticated: true\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          unauthenticated: true\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"unauthenticated\": true\n          }\n        }\n.. [[[end]]]\n\nAllow keys act as an \"or\" mechanism. An actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block. The following block will allow users with either a ``role`` of ``\"ops\"`` OR users who have an ``id`` of ``\"simon\"`` or ``\"cleopaws\"``:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        allow:\n          id:\n          - simon\n          - cleopaws\n          role: ops\n        \"\"\").strip(),\n        \"YAML\", \"JSON\"\n      )\n.. ]]]\n\n.. tab:: YAML\n\n    .. code-block:: yaml\n\n        allow:\n          id:\n          - simon\n          - cleopaws\n          role: ops\n\n.. tab:: JSON\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"id\": [\n              \"simon\",\n              \"cleopaws\"\n            ],\n            \"role\": \"ops\"\n          }\n        }\n.. [[[end]]]\n\n`Demo for cleopaws <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__, `demo for ops role <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22trevor%22%2C%0D%0A++++%22role%22%3A+%5B%0D%0A++++++++%22ops%22%2C%0D%0A++++++++%22staff%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__, `demo for an actor matching neither rule <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22percy%22%2C%0D%0A++++%22role%22%3A+%5B%0D%0A++++++++%22staff%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__.\n\n.. _AllowDebugView:\n\nThe /-/allow-debug tool\n-----------------------\n\nThe ``/-/allow-debug`` tool lets you try out different  ``\"action\"`` blocks against different ``\"actor\"`` JSON objects. You can try that out here: https://latest.datasette.io/-/allow-debug\n\n.. _authentication_permissions_config:\n\nAccess permissions in ``datasette.yaml``\n========================================\n\nThere are two ways to configure permissions using ``datasette.yaml`` (or ``datasette.json``).\n\nFor simple visibility permissions you can use ``\"allow\"`` blocks in the root, database, table and query sections.\n\nFor other permissions you can use a ``\"permissions\"`` block, described :ref:`in the next section <authentication_permissions_other>`.\n\nYou can limit who is allowed to view different parts of your Datasette instance using ``\"allow\"`` keys in your :ref:`configuration`.\n\nYou can control the following:\n\n* Access to the entire Datasette instance\n* Access to specific databases\n* Access to specific tables and views\n* Access to specific :ref:`canned_queries`\n\nIf a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within.\n\n.. _authentication_permissions_instance:\n\nAccess to an instance\n---------------------\n\nHere's how to restrict access to your entire Datasette instance to just the ``\"id\": \"root\"`` user:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, \"\"\"\n        title: My private Datasette instance\n        allow:\n          id: root\n      \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            title: My private Datasette instance\n            allow:\n              id: root\n  \n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"title\": \"My private Datasette instance\",\n          \"allow\": {\n            \"id\": \"root\"\n          }\n        }\n.. [[[end]]]\n\nTo deny access to all users, you can use ``\"allow\": false``:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        title: My entirely inaccessible instance\n        allow: false\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            title: My entirely inaccessible instance\n            allow: false\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"title\": \"My entirely inaccessible instance\",\n          \"allow\": false\n        }\n.. [[[end]]]\n\nOne reason to do this is if you are using a Datasette plugin - such as `datasette-permissions-sql <https://github.com/simonw/datasette-permissions-sql>`__ - to control permissions instead.\n\n.. _authentication_permissions_database:\n\nAccess to specific databases\n----------------------------\n\nTo limit access to a specific ``private.db`` database to just authenticated users, use the ``\"allow\"`` block like this:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          private:\n            allow:\n              id: \"*\"\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              private:\n                allow:\n                  id: \"*\"\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"private\": {\n              \"allow\": {\n                \"id\": \"*\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _authentication_permissions_table:\n\nAccess to specific tables and views\n-----------------------------------\n\nTo limit access to the ``users`` table in your ``bakery.db`` database:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          bakery:\n            tables:\n              users:\n                allow:\n                  id: '*'\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              bakery:\n                tables:\n                  users:\n                    allow:\n                      id: '*'\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"bakery\": {\n              \"tables\": {\n                \"users\": {\n                  \"allow\": {\n                    \"id\": \"*\"\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThis works for SQL views as well - you can list their names in the ``\"tables\"`` block above in the same way as regular tables.\n\n.. warning::\n    Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries, `like this <https://latest.datasette.io/fixtures?sql=select+*+from+facetable>`__ for example.\n\n    If you are restricting access to specific tables you should also use the ``\"allow_sql\"`` block to prevent users from bypassing the limit with their own SQL queries - see :ref:`authentication_permissions_execute_sql`.\n\n.. _authentication_permissions_query:\n\nAccess to specific canned queries\n---------------------------------\n\n:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.\n\nTo limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          dogs:\n            queries:\n              add_name:\n                sql: INSERT INTO names (name) VALUES (:name)\n                write: true\n                allow:\n                  id:\n                  - root\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              dogs:\n                queries:\n                  add_name:\n                    sql: INSERT INTO names (name) VALUES (:name)\n                    write: true\n                    allow:\n                      id:\n                      - root\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"dogs\": {\n              \"queries\": {\n                \"add_name\": {\n                  \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                  \"write\": true,\n                  \"allow\": {\n                    \"id\": [\n                      \"root\"\n                    ]\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _authentication_permissions_execute_sql:\n\nControlling the ability to execute arbitrary SQL\n------------------------------------------------\n\nDatasette defaults to allowing any site visitor to execute their own custom SQL queries, for example using the form on `the database page <https://latest.datasette.io/fixtures>`__ or by appending a ``?_where=`` parameter to the table page `like this <https://latest.datasette.io/fixtures/facetable?_where=_city_id=1>`__.\n\nAccess to this ability is controlled by the :ref:`actions_execute_sql` permission.\n\nThe 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.\n\nYou can alternatively use an ``\"allow_sql\"`` block to control who is allowed to execute arbitrary SQL queries.\n\nTo prevent any user from executing arbitrary SQL queries, use this:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        allow_sql: false\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            allow_sql: false\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"allow_sql\": false\n        }\n.. [[[end]]]\n\nTo enable just the :ref:`root user<authentication_root>` to execute SQL for all databases in your instance, use the following:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        allow_sql:\n          id: root\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            allow_sql:\n              id: root\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"allow_sql\": {\n            \"id\": \"root\"\n          }\n        }\n.. [[[end]]]\n\nTo limit this ability for just one specific database, use this:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          mydatabase:\n            allow_sql:\n              id: root\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              mydatabase:\n                allow_sql:\n                  id: root\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"allow_sql\": {\n                \"id\": \"root\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _authentication_permissions_other:\n\nOther permissions in ``datasette.yaml``\n=======================================\n\nFor all other permissions, you can use one or more ``\"permissions\"`` blocks in your ``datasette.yaml`` configuration file.\n\nTo grant access to the :ref:`permissions debug tool <PermissionsDebugView>` to all signed in users, you can grant ``permissions-debug`` to any actor with an ``id`` matching the wildcard ``*`` by adding this a the root of your configuration:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        permissions:\n          debug-menu:\n            id: '*'\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            permissions:\n              debug-menu:\n                id: '*'\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"permissions\": {\n            \"debug-menu\": {\n              \"id\": \"*\"\n            }\n          }\n        }\n.. [[[end]]]\n\nTo grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` database:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          docs:\n            permissions:\n              create-table:\n                id: editor\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              docs:\n                permissions:\n                  create-table:\n                    id: editor\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"docs\": {\n              \"permissions\": {\n                \"create-table\": {\n                  \"id\": \"editor\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nOther table-scoped write permissions, including ``set-column-type``, can be configured in the same place.\n\nAnd for ``insert-row`` against the ``reports`` table in that ``docs`` database:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        databases:\n          docs:\n            tables:\n              reports:\n                permissions:\n                  insert-row:\n                    id: editor\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            databases:\n              docs:\n                tables:\n                  reports:\n                    permissions:\n                      insert-row:\n                        id: editor\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"docs\": {\n              \"tables\": {\n                \"reports\": {\n                  \"permissions\": {\n                    \"insert-row\": {\n                      \"id\": \"editor\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThe :ref:`permissions debug tool <PermissionsDebugView>` can be useful for helping test permissions that you have configured in this way.\n\n.. _CreateTokenView:\n\nAPI Tokens\n==========\n\nDatasette includes a default mechanism for generating API tokens that can be used to authenticate requests.\n\nAuthenticated users can create new API tokens using a form on the ``/-/create-token`` page.\n\nTokens created in this way can be further restricted to only allow access to specific actions, or to limit those actions to specific databases, tables or queries.\n\nCreated tokens can then be passed in the ``Authorization: Bearer $token`` header of HTTP requests to Datasette.\n\nA token created by a user will include that user's ``\"id\"`` in the token payload, so any permissions granted to that user based on their ID can be made available to the token as well.\n\nWhen one of these a token accompanies a request, the actor for that request will have the following shape:\n\n.. code-block:: json\n\n    {\n        \"id\": \"user_id\",\n        \"token\": \"dstok\",\n        \"token_expires\": 1667717426\n    }\n\nThe ``\"id\"`` field duplicates the ID of the actor who first created the token.\n\nThe ``\"token\"`` field identifies that this actor was authenticated using a Datasette signed token (``dstok``).\n\nThe ``\"token_expires\"`` field, if present, indicates that the token will expire after that integer timestamp.\n\nThe ``/-/create-token`` page cannot be accessed by actors that are authenticated with a ``\"token\": \"some-value\"`` property. This is to prevent API tokens from being used to create more tokens.\n\nDatasette plugins that implement their own form of API token authentication should follow this convention.\n\nYou can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting.\n\n.. _authentication_cli_create_token:\n\ndatasette create-token\n----------------------\n\nYou can also create tokens on the command line using the ``datasette create-token`` command.\n\nThis command takes one required argument - the ID of the actor to be associated with the created token.\n\nYou can specify a ``-e/--expires-after`` option in seconds. If omitted, the token will never expire.\n\nThe command will sign the token using the ``DATASETTE_SECRET`` environment variable, if available. You can also pass the secret using the ``--secret`` option.\n\nThis means you can run the command locally to create tokens for use with a deployed Datasette instance, provided you know that instance's secret.\n\nTo create a token for the ``root`` actor that will expire in one hour::\n\n    datasette create-token root --expires-after 3600\n\nTo create a token that never expires using a specific secret::\n\n    datasette create-token root --secret my-secret-goes-here\n\n.. _authentication_cli_create_token_restrict:\n\nRestricting the actions that a token can perform\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nTokens created using ``datasette create-token ACTOR_ID`` will inherit all of the permissions of the actor that they are associated with.\n\nYou can pass additional options to create tokens that are restricted to a subset of that actor's permissions.\n\nTo restrict the token to just specific permissions against all available databases, use the ``--all`` option::\n\n    datasette create-token root --all insert-row --all update-row\n\nThis option can be passed as many times as you like. In the above example the token will only be allowed to insert and update rows.\n\nYou can also restrict permissions such that they can only be used within specific databases::\n\n    datasette create-token root --database mydatabase insert-row\n\nThe resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database.\n\nFinally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::\n\n    datasette create-token root --resource mydatabase mytable insert-row\n\nThese options have short versions: ``-a`` for ``--all``, ``-d`` for ``--database`` and ``-r`` for ``--resource``.\n\nYou can add ``--debug`` to see a JSON representation of the token that has been created. Here's a full example::\n\n    datasette create-token root \\\n        --secret mysecret \\\n        --all view-instance \\\n        --all view-table \\\n        --database docs view-query \\\n        --resource docs documents insert-row \\\n        --resource docs documents update-row \\\n        --debug\n\nThis example outputs the following::\n\n    dstok_.eJxFizEKgDAMRe_y5w4qYrFXERGxDkVsMI0uxbubdjFL8l_ez1jhwEQCA6Fjjxp90qtkuHawzdjYrh8MFobLxZ_wBH0_gtnAF-hpS5VfmF8D_lnd97lHqUJgLd6sls4H1qwlhA.nH_7RecYHj5qSzvjhMU95iy0Xlc\n\n    Decoded:\n\n    {\n      \"a\": \"root\",\n      \"token\": \"dstok\",\n      \"t\": 1670907246,\n      \"_r\": {\n        \"a\": [\n          \"vi\",\n          \"vt\"\n        ],\n        \"d\": {\n          \"docs\": [\n            \"vq\"\n          ]\n        },\n        \"r\": {\n          \"docs\": {\n            \"documents\": [\n              \"ir\",\n              \"ur\"\n            ]\n          }\n        }\n      }\n    }\n\nRestrictions act as an allowlist layered on top of the actor's existing\npermissions. They can only remove access the actor would otherwise have—they\ncannot grant new access. If the underlying actor is denied by ``allow`` rules in\n``datasette.yaml`` or by a plugin, a token that lists that resource in its\n``\"_r\"`` section will still be denied.\n\nTo create tokens with restrictions in Python code, use the :ref:`TokenRestrictions <TokenRestrictions>` builder and pass it to :ref:`datasette.create_token() <datasette_create_token>`.\n\n.. _permissions_plugins:\n\nChecking permissions in plugins\n===============================\n\nDatasette plugins can check if an actor has permission to perform an action using :ref:`datasette_allowed`—for example::\n\n    from datasette.resources import TableResource\n\n    can_edit = await datasette.allowed(\n        action=\"update-row\",\n        resource=TableResource(database=\"fixtures\", table=\"facetable\"),\n        actor=request.actor,\n    )\n\nUse :ref:`datasette_ensure_permission` when you need to enforce a permission and\nraise a ``Forbidden`` error automatically.\n\nPlugins that define new operations should return :class:`~datasette.permissions.Action`\nobjects from :ref:`plugin_register_actions` and can supply additional allow/deny\nrules by returning :class:`~datasette.permissions.PermissionSQL` objects from the\n:ref:`plugin_hook_permission_resources_sql` hook. Those rules are merged with\nconfiguration ``allow`` blocks and actor restrictions to determine the final\nresult for each check.\n\n.. _authentication_actor_matches_allow:\n\nactor_matches_allow()\n=====================\n\nPlugins that wish to implement this same ``\"allow\"`` block permissions scheme can take advantage of the ``datasette.utils.actor_matches_allow(actor, allow)`` function:\n\n.. code-block:: python\n\n    from datasette.utils import actor_matches_allow\n\n    actor_matches_allow({\"id\": \"root\"}, {\"id\": \"*\"})\n    # returns True\n\nThe currently authenticated actor is made available to plugins as ``request.actor``.\n\n.. _PermissionsDebugView:\n\nPermissions debug tools\n=======================\n\nThe debug tool at ``/-/permissions`` is available to any actor with the ``permissions-debug`` permission. By default this is just the :ref:`authenticated root user <authentication_root>` but you can open it up to all users by starting Datasette like this::\n\n    datasette -s permissions.permissions-debug true data.db\n\nThe page shows the permission checks that have been carried out by the Datasette instance.\n\nIt also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.\n\nThis is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.\n\n.. _AllowedResourcesView:\n\nAllowed resources view\n----------------------\n\nThe ``/-/allowed`` endpoint displays resources that the current actor can access for a specified ``action``.\n\nThis endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.\n\nPass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.\n\nThis endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision.\n\n.. _PermissionRulesView:\n\nPermission rules view\n---------------------\n\nThe ``/-/rules`` endpoint displays all permission rules (both allow and deny) for each candidate resource for the requested action.\n\nThis endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.\n\nPass ``?action=`` as a query parameter to specify which action to check.\n\nThis endpoint requires the ``permissions-debug`` permission.\n\n.. _PermissionCheckView:\n\nPermission check view\n---------------------\n\nThe ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.\n\nThis endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.\n\nPass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.\n\n.. _authentication_ds_actor:\n\nThe ds_actor cookie\n===================\n\nDatasette includes a default authentication plugin which looks for a signed ``ds_actor`` cookie containing a JSON actor dictionary. This is how the :ref:`root actor <authentication_root>` mechanism works.\n\nAuthentication plugins can set signed ``ds_actor`` cookies themselves like so:\n\n.. code-block:: python\n\n    response = Response.redirect(\"/\")\n    datasette.set_actor_cookie(response, {\"id\": \"cleopaws\"})\n\nThe shape of data encoded in the cookie is as follows:\n\n.. code-block:: json\n\n    {\n      \"a\": {\n        \"id\": \"cleopaws\"\n      }\n    }\n\nTo implement logout in a plugin, use the ``delete_actor_cookie()`` method:\n\n.. code-block:: python\n\n    response = Response.redirect(\"/\")\n    datasette.delete_actor_cookie(response)\n\n.. _authentication_ds_actor_expiry:\n\nIncluding an expiry time\n------------------------\n\n``ds_actor`` cookies can optionally include a signed expiry timestamp, after which the cookies will no longer be valid. Authentication plugins may chose to use this mechanism to limit the lifetime of the cookie. For example, if a plugin implements single-sign-on against another source it may decide to set short-lived cookies so that if the user is removed from the SSO system their existing Datasette cookies will stop working shortly afterwards.\n\nTo include an expiry pass ``expire_after=`` to ``datasette.set_actor_cookie()`` with a number of seconds. For example, to expire in 24 hours:\n\n.. code-block:: python\n\n    response = Response.redirect(\"/\")\n    datasette.set_actor_cookie(\n        response, {\"id\": \"cleopaws\"}, expire_after=60 * 60 * 24\n    )\n\nThe resulting cookie will encode data that looks something like this:\n\n.. code-block:: json\n\n    {\n      \"a\": {\n        \"id\": \"cleopaws\"\n      },\n      \"e\": \"1jjSji\"\n    }\n\n.. _LogoutView:\n\nThe /-/logout page\n------------------\n\nThe page at ``/-/logout`` provides the ability to log out of a ``ds_actor`` cookie authentication session.\n\n.. _actions:\n\nBuilt-in actions\n================\n\nThis section lists all of the permission checks that are carried out by Datasette core, along with the ``resource`` if it was passed.\n\n.. _actions_view_instance:\n\nview-instance\n-------------\n\nTop level permission - Actor is allowed to view any pages within this instance, starting at https://latest.datasette.io/\n\n.. _actions_view_database:\n\nview-database\n-------------\n\nActor is allowed to view a database page, e.g. https://latest.datasette.io/fixtures\n\n``resource`` - ``datasette.permissions.DatabaseResource(database)``\n    ``database`` is the name of the database (string)\n\n.. _actions_view_database_download:\n\nview-database-download\n----------------------\n\nActor is allowed to download a database, e.g. https://latest.datasette.io/fixtures.db\n\n``resource`` - ``datasette.resources.DatabaseResource(database)``\n    ``database`` is the name of the database (string)\n\n.. _actions_view_table:\n\nview-table\n----------\n\nActor is allowed to view a table (or view) page, e.g. https://latest.datasette.io/fixtures/complex_foreign_keys\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_view_query:\n\nview-query\n----------\n\nActor is allowed to view (and execute) a :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.\n\n``resource`` - ``datasette.resources.QueryResource(database, query)``\n    ``database`` is the name of the database (string)\n    \n    ``query`` is the name of the canned query (string)\n\n.. _actions_insert_row:\n\ninsert-row\n----------\n\nActor is allowed to insert rows into a table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_delete_row:\n\ndelete-row\n----------\n\nActor is allowed to delete rows from a table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_update_row:\n\nupdate-row\n----------\n\nActor is allowed to update rows in a table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_create_table:\n\ncreate-table\n------------\n\nActor is allowed to create a database table.\n\n``resource`` - ``datasette.resources.DatabaseResource(database)``\n    ``database`` is the name of the database (string)\n\n.. _actions_alter_table:\n\nalter-table\n-----------\n\nActor is allowed to alter a database table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_set_column_type:\n\nset-column-type\n---------------\n\nActor is allowed to set assigned :ref:`column types <table_configuration_column_types>` for columns in a table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_drop_table:\n\ndrop-table\n----------\n\nActor is allowed to drop a database table.\n\n``resource`` - ``datasette.resources.TableResource(database, table)``\n    ``database`` is the name of the database (string)\n\n    ``table`` is the name of the table (string)\n\n.. _actions_execute_sql:\n\nexecute-sql\n-----------\n\nActor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100\n\n``resource`` - ``datasette.resources.DatabaseResource(database)``\n    ``database`` is the name of the database (string)\n\nSee also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.\n\n.. _actions_permissions_debug:\n\npermissions-debug\n-----------------\n\nActor is allowed to view the ``/-/permissions`` debug tools.\n\n.. _actions_debug_menu:\n\ndebug-menu\n----------\n\nControls if the various debug pages are displayed in the navigation menu.\n"
  },
  {
    "path": "docs/auto-build.sh",
    "content": "sphinx-autobuild . _build/html\n"
  },
  {
    "path": "docs/binary_data.rst",
    "content": ".. _binary:\n\n=============\n Binary data\n=============\n\nSQLite tables can contain binary data in ``BLOB`` columns.\n\nDatasette includes special handling for these binary values. The Datasette interface detects binary values and provides a link to download their content, for example on https://latest.datasette.io/fixtures/binary_data\n\n.. image:: https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/binary-data.png\n   :width: 311px\n   :alt: Screenshot showing download links next to binary data in the table view\n\nBinary data is represented in ``.json`` exports using Base64 encoding.\n\nhttps://latest.datasette.io/fixtures/binary_data.json?_shape=array\n\n.. code-block:: json\n\n    [\n        {\n            \"rowid\": 1,\n            \"data\": {\n                \"$base64\": true,\n                \"encoded\": \"FRwCx60F/g==\"\n            }\n        },\n        {\n            \"rowid\": 2,\n            \"data\": {\n                \"$base64\": true,\n                \"encoded\": \"FRwDx60F/g==\"\n            }\n        },\n        {\n            \"rowid\": 3,\n            \"data\": null\n        }\n    ]\n\n.. _binary_linking:\n\nLinking to binary downloads\n---------------------------\n\nThe ``.blob`` output format is used to return binary data. It requires a ``_blob_column=`` query string argument specifying which BLOB column should be downloaded, for example:\n\nhttps://latest.datasette.io/fixtures/binary_data/1.blob?_blob_column=data\n\nThis output format can also be used to return binary data from an arbitrary SQL query. Since such queries do not specify an exact row, an additional ``?_blob_hash=`` parameter can be used to specify the SHA-256 hash of the value that is being linked to.\n\nConsider the query ``select data from binary_data`` - `demonstrated here <https://latest.datasette.io/fixtures?sql=select+data+from+binary_data>`__.\n\nThat page links to the binary value downloads. Those links look like this:\n\nhttps://latest.datasette.io/fixtures.blob?sql=select+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d\n\nThese ``.blob`` links are also returned in the ``.csv`` exports Datasette provides for binary tables and queries, since the CSV format does not have a mechanism for representing binary data.\n\nBinary plugins\n--------------\n\nSeveral Datasette plugins are available that change the way Datasette treats binary data.\n\n- `datasette-render-binary <https://github.com/simonw/datasette-render-binary>`__ modifies Datasette's default interface to show an automatic guess at what type of binary data is being stored, along with a visual representation of the binary value that displays ASCII strings directly in the interface.\n- `datasette-render-images <https://github.com/simonw/datasette-render-images>`__ detects common image formats and renders them as images directly in the Datasette interface.\n- `datasette-media <https://github.com/simonw/datasette-media>`__ allows Datasette interfaces to be configured to serve binary files from configured SQL queries, and includes the ability to resize images directly before serving them.\n"
  },
  {
    "path": "docs/changelog.rst",
    "content": ".. _changelog:\n\n=========\nChangelog\n=========\n\n.. _v1_0_a26:\n\n1.0a26 (2026-03-18)\n-------------------\n\nNew ``column_types`` system\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nTable columns can now have custom column types assigned to them, using the new ``column_types`` table configuration option or at runtime using a new UI and ``POST /<database>/<table>/-/set-column-type`` JSON API.\n\nBuilt-in column types include ``url``, ``email``, and ``json``, and plugins can register additional types using the new :ref:`register_column_types() <plugin_register_column_types>` plugin hook. (:issue:`2664`, :issue:`2671`)\n\nColumn types can customize HTML rendering, validate values written through the insert, update, and upsert APIs, and transform values returned by the JSON API. They can optionally restrict themselves to specific SQLite column types using ``sqlite_types``. This feature also introduces a new :ref:`set-column-type <actions_set_column_type>` permission for assigning column types to a table. (:issue:`2672`)\n\nThe :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives a ``column_type`` argument containing the assigned type instance, and a column type's own ``render_cell()`` method takes priority over the plugin hook chain.\n\nThe `datasette-files <https://github.com/datasette/datasette-files>`__ plugin will be the first to use this new feature.\n\nUI for selecting columns and their order\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nTable and view pages now include a dialog for selecting and re-ordering visible columns. (:issue:`2661`)\n\nOther changes\n~~~~~~~~~~~~~\n\n- Fixed ``allowed_resources(\"view-query\", actor)`` so actor-specific canned queries are returned correctly. Any plugin that defines a ``resources_sql()`` method on a ``Resource`` subclass needs to update to the new signature, see :ref:`the resources_sql() method<plugin_resources_sql>` documentation for details.\n- Column actions can now be accessed in mobile view via a new \"Column actions\" button. Previously they were not available on mobile because table headers are not displayed there. (:issue:`2669`, :issue:`2670`)\n- Row pages now render foreign key values as links to the referenced row. (:issue:`1592`)\n- The ``startup()`` plugin hook now fires after metadata and internal schema tables have been populated, so plugins can reliably inspect that state during startup. (:issue:`2666`)\n\n.. _v1_0_a25:\n\n1.0a25 (2026-02-25)\n-------------------\n\n``write_wrapper()`` plugin hook for intercepting write operations\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nA new :ref:`write_wrapper() <plugin_hook_write_wrapper>` plugin hook allows plugins to intercept and wrap database write operations. (`#2636 <https://github.com/simonw/datasette/pull/2636>`__)\n\nPlugins implement the hook as a generator-based context manager:\n\n.. code-block:: python\n\n    @hookimpl\n    def write_wrapper(datasette, database, request):\n        def wrapper(conn):\n            # Setup code runs before the write\n            yield\n            # Cleanup code runs after the write\n\n        return wrapper\n\n``register_token_handler()`` plugin hook for custom API token backends\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nA new :ref:`register_token_handler() <plugin_hook_register_token_handler>` plugin hook allows plugins to provide custom token backends for API authentication. (`#2650 <https://github.com/simonw/datasette/pull/2650>`__)\n\nThis includes a **backwards incompatible change**: the ``datasette.create_token()`` internal  method is now an ``async`` method. Consult the :ref:`upgrade guide <upgrade_guide_v1_a25>` for details on how to update your code.\n\n``render_cell()`` now receives a ``pks`` parameter\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives a ``pks`` parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. (`#2641 <https://github.com/simonw/datasette/pull/2641>`__)\n\nOther changes\n~~~~~~~~~~~~~\n\n- Facets defined in metadata now preserve their configured order, instead of being sorted by result count. Request-based facets added via the ``_facet`` parameter are still sorted by result count and appear after metadata-defined facets. (:issue:`2647`)\n- Fixed ``--reload`` incorrectly interpreting the ``serve`` command as a file argument. Thanks, `Daniel Bates <https://github.com/danielalanbates>`__. (`#2646 <https://github.com/simonw/datasette/pull/2646>`__)\n\n.. _v1_0_a24:\n\n1.0a24 (2026-01-29)\n-------------------\n\n``request.form()`` method for POST data and file uploads\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette now includes a ``request.form()`` method for parsing form submissions, including handling file uploads. (`#2626 <https://github.com/simonw/datasette/pull/2626>`__)\n\nThis supports both ``application/x-www-form-urlencoded`` and ``multipart/form-data`` content types, and uses a new streaming multipart parser that processes uploads without buffering entire request bodies in memory.\n\n.. code-block:: python\n\n    # Parse form fields (files are discarded by default)\n    form = await request.form()\n    username = form[\"username\"]\n\n    # Parse form fields AND file uploads\n    form = await request.form(files=True)\n    uploaded = form[\"avatar\"]\n    content = await uploaded.read()\n\nThe returned :ref:`FormData <internals_formdata>` object provides dictionary-style access with support for multiple values per key via ``form.getlist(\"key\")``. Uploaded files are represented as :ref:`UploadedFile <internals_uploadedfile>` objects with ``filename``, ``content_type``, ``size`` properties and async ``read()`` and ``seek()`` methods.\n\nFiles smaller than 1MB are held in memory; larger files automatically spill to temporary files on disk. Configurable limits control maximum file size, request size, field counts and more.\n\nSeveral internal views (permissions debug, messages debug, create token) now use ``request.form()`` instead of ``request.post_vars()``.\n\n``request.post_vars()`` remains available for backwards compatibility but is no longer the recommended API for handling POST data.\n\n``render_cell`` and ``foreign_key_tables`` extras for the JSON API\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe table JSON API now supports ``?_extra=render_cell``, which returns the rendered HTML for each cell as produced by the :ref:`render_cell plugin hook <plugin_hook_render_cell>`. Only columns whose rendered output differs from the default are included. (:issue:`2619`)\n\nThe row JSON API also gains ``?_extra=render_cell`` and ``?_extra=foreign_key_tables`` extras, bringing it closer to parity with the table API.\n\nThe row JSON API now returns ``\"ok\": true`` in its response, for consistency with the table API.\n\n``uv run pytest`` with a ``dev=`` dependency group\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe recommended development environment for Datasette now uses `uv <https://github.com/astral-sh/uv>`__. You can now set up a development environment and run the test suite with just ``uv run pytest`` — no manual virtualenv or ``pip install`` step required. (:issue:`2611`)\n\nOther changes\n~~~~~~~~~~~~~\n\n- Plugins that raise ``datasette.utils.StartupError()`` during startup now display a clean error message instead of a full traceback. (:issue:`2624`)\n- Schema refreshes are now throttled to at most once per second, providing a small performance increase. (:issue:`2629`)\n- Minor performance improvement to ``remove_infinites`` — rows without infinity values now skip the list/dict reconstruction step. (:issue:`2629`)\n- Filter inputs and the search input no longer trigger unwanted zoom on iOS Safari. Thanks, `Daniel Olasubomi Sobowale <https://github.com/bowale-os>`__. (:issue:`2346`)\n- ``table_names()`` and ``get_all_foreign_keys()`` now return results in deterministic sorted order. (:issue:`2628`)\n- Switched linting to `ruff <https://github.com/astral-sh/ruff>`__ and fixed all lint errors. (:issue:`2630`)\n\n.. _v1_0_a23:\n\n1.0a23 (2025-12-02)\n-------------------\n\n- Fix for bug where a stale database entry in ``internal.db`` could cause a 500 error on the homepage. (:issue:`2605`)\n- Cosmetic improvement to ``/-/actions`` page. (:issue:`2599`)\n\n.. _v1_0_a22:\n\n1.0a22 (2025-11-13)\n-------------------\n\n- ``datasette serve --default-deny`` option for running Datasette configured to  :ref:`deny all permissions by default <authentication_default_deny>`. (:issue:`2592`)\n- ``datasette.is_client()`` method for detecting if code is :ref:`executing inside a datasette.client request <internals_datasette_is_client>`. (:issue:`2594`)\n- ``datasette.pm`` property can now be used to :ref:`register and unregister plugins in tests <testing_plugins_register_in_test>`. (:issue:`2595`)\n\n.. _v1_0_a21:\n\n1.0a21 (2025-11-05)\n-------------------\n\n- Fixes an **open redirect** security issue: Datasette instances would redirect to ``example.com/foo/bar`` if you accessed the path ``//example.com/foo/bar``. Thanks to `James Jefferies <https://github.com/jamesjefferies>`__ for the fix. (:issue:`2429`)\n- Fixed ``datasette publish cloudrun`` to work with changes to the underlying Cloud Run architecture. (:issue:`2511`)\n- New ``datasette --get /path --headers`` option for inspecting the headers returned by a path. (:issue:`2578`)\n- New ``datasette.client.get(..., skip_permission_checks=True)`` parameter to bypass permission checks when making requests using the internal client. (:issue:`2583`)\n\n.. _v0_65_2:\n\n0.65.2 (2025-11-05)\n-------------------\n\n- Fixes an **open redirect** security issue: Datasette instances would redirect to ``example.com/foo/bar`` if you accessed the path ``//example.com/foo/bar``. Thanks to `James Jefferies <https://github.com/jamesjefferies>`__ for the fix. (:issue:`2429`)\n- Upgraded for compatibility with Python 3.14.\n- Fixed ``datasette publish cloudrun`` to work with changes to the underlying Cloud Run architecture. (:issue:`2511`)\n- Minor upgrades to fix warnings, including ``pkg_resources`` deprecation.\n\n.. _v1_0_a20:\n\n1.0a20 (2025-11-03)\n-------------------\n\nThis alpha introduces a major breaking change prior to the 1.0 release of Datasette concerning how Datasette's permission system works.\n\nPermission system redesign\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nPreviously the permission system worked using ``datasette.permission_allowed()`` checks which consulted all available plugins in turn to determine whether a given actor was allowed to perform a given action on a given resource.\n\nThis approach could become prohibitively expensive for large lists of items - for example to determine the list of tables that a user could view in a large Datasette instance each plugin implementation of that hook would be fired for every table.\n\nThe new design uses SQL queries against Datasette's internal :ref:`catalog tables <internals_internal>` to derive the list of resources for which an actor has permission for a given action. This turns an N x M problem (N resources, M plugins) into a single SQL query.\n\nPlugins can use the new :ref:`plugin_hook_permission_resources_sql` hook to return SQL fragments which will be used as part of that query.\n\nPlugins that use any of the following features will need to be updated to work with this and following alphas (and Datasette 1.0 stable itself):\n\n- Checking permissions with ``datasette.permission_allowed()`` - this method has been replaced with :ref:`datasette.allowed() <datasette_allowed>`.\n- Implementing the ``permission_allowed()`` plugin hook - this hook has been removed in favor of :ref:`permission_resources_sql() <plugin_hook_permission_resources_sql>`.\n- Using ``register_permissions()`` to register permissions - this hook has been removed in favor of :ref:`register_actions() <plugin_register_actions>`.\n\nConsult the :ref:`v1.0a20 upgrade guide <upgrade_guide_v1_a20>` for further details on how to upgrade affected plugins.\n\nPlugins can now make use of two new internal methods to help resolve permission checks:\n\n- :ref:`datasette.allowed_resources() <datasette_allowed_resources>` returns a ``PaginatedResources`` object with a ``.resources`` list of ``Resource`` instances that an actor is allowed to access for a given action (and a ``.next`` token for pagination).\n- :ref:`datasette.allowed_resources_sql() <datasette_allowed_resources_sql>` returns the SQL and parameters that can be executed against the internal catalog tables to determine which resources an actor is allowed to access for a given action. This can be combined with further SQL to perform advanced custom filtering.\n\nRelated changes:\n\n- The way ``datasette --root`` works has changed. Running Datasette with this flag now causes the root actor to pass *all* permission checks. (:issue:`2521`)\n\n- Permission debugging improvements:\n\n  - The ``/-/allowed`` endpoint shows resources the user is allowed to interact with for different actions.\n  - ``/-/rules`` shows the raw allow/deny rules that apply to different permission checks.\n  - ``/-/actions`` lists every available action.\n  - ``/-/check`` can be used to try out different permission checks for the current actor.\n\nOther changes\n~~~~~~~~~~~~~\n\n- The internal ``catalog_views`` table now tracks SQLite views alongside tables in the introspection database. (:issue:`2495`)\n- Hitting the ``/`` brings up a search interface for navigating to tables that the current user can view. A new ``/-/tables`` endpoint supports this functionality. (:issue:`2523`)\n- Datasette attempts to detect some configuration errors on startup.\n- Datasette now supports Python 3.14 and no longer tests against Python 3.9.\n\n.. _v1_0_a19:\n\n1.0a19 (2025-04-21)\n-------------------\n\n- Tiny cosmetic bug fix for mobile display of table rows. (:issue:`2479`)\n\n.. _v1_0_a18:\n\n1.0a18 (2025-04-16)\n-------------------\n\n- Fix for incorrect foreign key references in the internal database schema. (:issue:`2466`)\n- The ``prepare_connection()`` hook no longer runs for the internal database. (:issue:`2468`)\n- Fixed bug where ``link:`` HTTP headers used invalid syntax. (:issue:`2470`)\n- No longer tested against Python 3.8. Now tests against Python 3.13.\n- FTS tables are now hidden by default if they correspond to a content table. (:issue:`2477`)\n- Fixed bug with foreign key links to rows in databases with filenames containing a special character. Thanks, `Jack Stratton <https://github.com/phroa>`__. (`#2476 <https://github.com/simonw/datasette/pull/2476>`__)\n\n.. _v1_0_a17:\n\n1.0a17 (2025-02-06)\n-------------------\n\n- ``DATASETTE_SSL_KEYFILE`` and ``DATASETTE_SSL_CERTFILE`` environment variables as alternatives to ``--ssl-keyfile`` and ``--ssl-certfile``. Thanks, Alex Garcia. (:issue:`2422`)\n- ``SQLITE_EXTENSIONS`` environment variable has been renamed to ``DATASETTE_LOAD_EXTENSION``. (:issue:`2424`)\n- ``datasette serve`` environment variables are now :ref:`documented here <cli_datasette_serve_env>`.\n- The :ref:`plugin_hook_register_magic_parameters` plugin hook can now register async functions. (:issue:`2441`)\n- Datasette is now tested against Python 3.13.\n- Breadcrumbs on database and table pages now include a consistent self-link for resetting query string parameters. (:issue:`2454`)\n- Fixed issue where Datasette could crash on ``metadata.json`` with nested values. (:issue:`2455`)\n- New internal methods ``datasette.set_actor_cookie()`` and ``datasette.delete_actor_cookie()``, :ref:`described here <authentication_ds_actor>`. (:issue:`1690`)\n- ``/-/permissions`` page now shows a list of all permissions registered by plugins. (:issue:`1943`)\n- If a table has a single unique text column Datasette now detects that as the foreign key label for that table. (:issue:`2458`)\n- The ``/-/permissions`` page now includes options for filtering or exclude permission checks recorded against the current user. (:issue:`2460`)\n- Fixed a bug where replacing a database with a new one with the same name did not pick up the new database correctly. (:issue:`2465`)\n\n.. _v0_65_1:\n\n0.65.1 (2024-11-28)\n-------------------\n\n- Fixed bug with upgraded HTTPX 0.28.0 dependency. (:issue:`2443`)\n\n.. _v0_65:\n\n0.65 (2024-10-07)\n-----------------\n\n- Upgrade for compatibility with Python 3.13 (by vendoring Pint dependency). (:issue:`2434`)\n- Dropped support for Python 3.8.\n\n.. _v1_0_a16:\n\n1.0a16 (2024-09-05)\n-------------------\n\nThis release focuses on performance, in particular against large tables, and introduces some minor breaking changes for CSS styling in Datasette plugins.\n\n- Removed the unit conversions feature and its dependency, Pint. This means Datasette is now compatible with the upcoming Python 3.13. (:issue:`2400`, :issue:`2320`)\n- The ``datasette --pdb`` option now uses the `ipdb <https://github.com/gotcha/ipdb>`__ debugger if it is installed. You can install it using ``datasette install ipdb``. Thanks, `Tiago Ilieve <https://github.com/myhro>`__. (`#2342 <https://github.com/simonw/datasette/pull/2342>`__)\n- Fixed a confusing error that occurred if ``metadata.json`` contained nested objects. (:issue:`2403`)\n- Fixed a bug with ``?_trace=1`` where it returned a blank page if the response was larger than 256KB. (:issue:`2404`)\n- Tracing mechanism now also displays SQL queries that returned errors or ran out of time. `datasette-pretty-traces 0.5 <https://github.com/simonw/datasette-pretty-traces/releases/tag/0.5>`__ includes support for displaying this new type of trace. (:issue:`2405`)\n- Fixed a text spacing with table descriptions on the homepage. (:issue:`2399`)\n- Performance improvements for large tables:\n    - Suggested facets now only consider the first 1000 rows. (:issue:`2406`)\n    - Improved performance of date facet suggestion against large tables. (:issue:`2407`)\n    - Row counts stop at 10,000 rows when listing tables. (:issue:`2398`)\n    - On table page the count stops at 10,000 rows too, with a \"count all\" button to execute the full count. (:issue:`2408`)\n- New ``.dicts()`` internal method on :ref:`database_results` that returns a list of dictionaries representing the results from a SQL query: (:issue:`2414`)\n\n  .. code-block:: bash\n\n        rows = (await db.execute(\"select * from t\")).dicts()\n\n- Default Datasette core CSS that styles inputs and buttons now requires a class of ``\"core\"`` on the element or a containing element, for example ``<form class=\"core\">``. (:issue:`2415`)\n- Similarly, default table styles now only apply to ``<table class=\"rows-and-columns\">``. (:issue:`2420`)\n\n.. _v1_0_a15:\n\n1.0a15 (2024-08-15)\n-------------------\n\n- Datasette now defaults to hiding SQLite \"shadow\" tables, as seen in extensions such as SQLite FTS and `sqlite-vec <https://github.com/asg017/sqlite-vec>`__. Virtual tables that it makes sense to display, such as FTS core tables, are no longer hidden. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:issue:`2296`)\n- Fixed bug where running Datasette with one or more ``-s/--setting`` options could over-ride settings that were present in ``datasette.yml``. (:issue:`2389`)\n- The Datasette homepage is now duplicated at ``/-/``, using the default ``index.html`` template. This ensures that the information on that page is still accessible even if the Datasette homepage has been customized using a custom ``index.html`` template, for example on sites like `datasette.io <https://datasette.io/>`__. (:issue:`2393`)\n- Failed CSRF checks now display a more user-friendly error page. (:issue:`2390`)\n- Fixed a bug where the ``json1`` extension was not correctly detected on the ``/-/versions`` page. Thanks, `Seb Bacon <https://github.com/sebbacon>`__. (:issue:`2326`)\n- Fixed a bug where the Datasette write API did not correctly accept ``Content-Type: application/json; charset=utf-8``. (:issue:`2384`)\n- Fixed a bug where Datasette would fail to start if ``metadata.yml`` contained a ``queries`` block. (`#2386 <https://github.com/simonw/datasette/pull/2386>`__)\n\n.. _v1_0_a14:\n\n1.0a14 (2024-08-05)\n-------------------\n\nThis alpha introduces significant changes to Datasette's :ref:`metadata` system, some of which represent breaking changes in advance of the full 1.0 release. The new :ref:`upgrade_guide` document provides detailed coverage of those breaking changes and how they affect plugin authors and Datasette API consumers.\n\n- The ``/databasename?sql=`` interface and JSON API for executing arbitrary SQL queries can now be found at ``/databasename/-/query?sql=``. Requests with a ``?sql=`` parameter to the old endpoints will be redirected. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:issue:`2360`)\n- Metadata about tables, databases, instances and columns is now stored in :ref:`internals_internal`. Thanks, Alex Garcia. (:issue:`2341`)\n- Database write connections now execute using the ``IMMEDIATE`` isolation level for SQLite. This should help avoid a rare ``SQLITE_BUSY`` error that could occur when a transaction upgraded to a write mid-flight. (:issue:`2358`)\n- Fix for a bug where canned queries with named parameters could fail against SQLite 3.46. (:issue:`2353`)\n- Datasette now serves ``E-Tag`` headers for static files. Thanks, `Agustin Bacigalup <https://github.com/redraw>`__. (`#2306 <https://github.com/simonw/datasette/pull/2306>`__)\n- Dropdown menus now use a ``z-index`` that should avoid them being hidden by plugins. (:issue:`2311`)\n- Incorrect table and row names are no longer reflected back on the resulting 404 page. (:issue:`2359`)\n- Improved documentation for async usage of the :ref:`plugin_hook_track_event` hook. (:issue:`2319`)\n- Fixed some HTTPX deprecation warnings. (:issue:`2307`)\n- Datasette now serves a ``<html lang=\"en\">`` attribute. Thanks, `Charles Nepote <https://github.com/CharlesNepote>`__. (:issue:`2348`)\n- Datasette's automated tests now run against the maximum and minimum supported versions of SQLite: 3.25 (from September 2018) and 3.46 (from May 2024). Thanks, Alex Garcia. (`#2352 <https://github.com/simonw/datasette/pull/2352>`__)\n- Fixed an issue where clicking twice on the URL output by ``datasette --root`` produced a confusing error. (:issue:`2375`)\n\n.. _v0_64_8:\n\n0.64.8 (2024-06-21)\n-------------------\n\n- Security improvement: 404 pages used to reflect content from the URL path, which could be used to display misleading information to Datasette users. 404 errors no longer display additional information from the URL. (:issue:`2359`)\n- Backported a better fix for correctly extracting named parameters from canned query SQL against SQLite 3.46.0. (:issue:`2353`)\n\n.. _v0_64_7:\n\n0.64.7 (2024-06-12)\n-------------------\n\n- Fixed a bug where canned queries with named parameters threw an error when run against SQLite 3.46.0. (:issue:`2353`)\n\n.. _v1_0_a13:\n\n1.0a13 (2024-03-12)\n-------------------\n\nEach of the key concepts in Datasette now has an :ref:`actions menu <plugin_actions>`, which plugins can use to add additional functionality targeting that entity.\n\n- Plugin hook: :ref:`view_actions() <plugin_hook_view_actions>` for actions that can be applied to a SQL view. (:issue:`2297`)\n- Plugin hook: :ref:`homepage_actions() <plugin_hook_homepage_actions>` for actions that apply to the instance homepage. (:issue:`2298`)\n- Plugin hook: :ref:`row_actions() <plugin_hook_row_actions>` for actions that apply to the row page. (:issue:`2299`)\n- Action menu items for all of the ``*_actions()`` plugin hooks can now return an optional ``\"description\"`` key, which will be displayed in the menu below the action label. (:issue:`2294`)\n- :ref:`Plugin hooks <plugin_hooks>` documentation page is now organized with additional headings. (:issue:`2300`)\n- Improved the display of action buttons on pages that also display metadata. (:issue:`2286`)\n- The header and footer of the page now uses a subtle gradient effect, and options in the navigation menu are better visually defined. (:issue:`2302`)\n- Table names that start with an underscore now default to hidden. (:issue:`2104`)\n- ``pragma_table_list`` has been added to the allow-list of SQLite pragma functions supported by Datasette. ``select * from pragma_table_list()`` is no longer blocked. (`#2104 <https://github.com/simonw/datasette/issues/2104#issuecomment-1982352475>`__)\n\n.. _v1_0_a12:\n\n1.0a12 (2024-02-29)\n-------------------\n\n- New :ref:`query_actions() <plugin_hook_query_actions>` plugin hook, similar to :ref:`table_actions() <plugin_hook_table_actions>` and :ref:`database_actions() <plugin_hook_database_actions>`. Can be used to add a menu of actions to the canned query or arbitrary SQL query page. (:issue:`2283`)\n- New design for the button that opens the query, table and database actions menu. (:issue:`2281`)\n- \"does not contain\" table filter for finding rows that do not contain a string. (:issue:`2287`)\n- Fixed a bug in the :ref:`javascript_plugins_makeColumnActions` JavaScript plugin mechanism where the column action menu was not fully reset in between each interaction. (:issue:`2289`)\n\n.. _v1_0_a11:\n\n1.0a11 (2024-02-19)\n-------------------\n\n- The ``\"replace\": true`` argument to the ``/db/table/-/insert`` API now requires the actor to have the ``update-row`` permission. (:issue:`2279`)\n- Fixed some UI bugs in the interactive permissions debugging tool. (:issue:`2278`)\n- The column action menu now aligns better with the cog icon, and positions itself taking into account the width of the browser window. (:issue:`2263`)\n\n.. _v1_0_a10:\n\n1.0a10 (2024-02-17)\n-------------------\n\nThe only changes in this alpha correspond to the way Datasette handles database transactions. (:issue:`2277`)\n\n- The :ref:`database.execute_write_fn() <database_execute_write_fn>` method has a new ``transaction=True`` parameter. This defaults to ``True`` which means all functions executed using this method are now automatically wrapped in a transaction - previously the functions needed to roll transaction handling on their own, and many did not.\n- Pass ``transaction=False`` to ``execute_write_fn()`` if you want to manually handle transactions in your function.\n- Several internal Datasette features, including parts of the :ref:`JSON write API <json_api_write>`, had been failing to wrap their operations in a transaction. This has been fixed by the new ``transaction=True`` default.\n\n.. _v1_0_a9:\n\n1.0a9 (2024-02-16)\n------------------\n\nThis alpha release adds basic alter table support to the Datasette Write API and fixes a permissions bug relating to the ``/upsert`` API endpoint.\n\nAlter table support for create, insert, upsert and update\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`JSON write API <json_api_write>` can now be used to apply simple alter table schema changes, provided the acting actor has the new :ref:`actions_alter_table` permission. (:issue:`2101`)\n\nThe only alter operation supported so far is adding new columns to an existing table.\n\n* The :ref:`/db/-/create <TableCreateView>` API now adds new columns during large operations to create a table based on incoming example ``\"rows\"``, in the case where one of the later rows includes columns that were not present in the earlier batches. This requires the ``create-table`` but not the ``alter-table`` permission.\n* When ``/db/-/create`` is called with rows in a situation where the table may have been already created, an ``\"alter\": true`` key can be included to indicate that any missing columns from the new rows should be added to the table. This requires the ``alter-table`` permission.\n* :ref:`/db/table/-/insert <TableInsertView>` and :ref:`/db/table/-/upsert <TableUpsertView>` and :ref:`/db/table/row-pks/-/update <RowUpdateView>` all now also accept ``\"alter\": true``, depending on the ``alter-table`` permission.\n\nOperations that alter a table now fire the new :ref:`alter-table event <events>`.\n\nPermissions fix for the upsert API\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`/database/table/-/upsert API <TableUpsertView>` had a minor permissions bug, only affecting Datasette instances that had configured the ``insert-row`` and ``update-row`` permissions to apply to a specific table rather than the database or instance as a whole. Full details in issue :issue:`2262`.\n\nTo avoid similar mistakes in the future the ``datasette.permission_allowed()`` method now specifies ``default=`` as a keyword-only argument.\n\nPermission checks now consider opinions from every plugin\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe ``datasette.permission_allowed()`` method previously consulted every plugin that implemented the ``permission_allowed()`` plugin hook and obeyed the opinion of the last plugin to return a value. (:issue:`2275`)\n\nDatasette now consults every plugin and checks to see if any of them returned ``False`` (the veto rule), and if none of them did, it then checks to see if any of them returned ``True``.\n\nThis is explained at length in the new documentation covering :ref:`authentication_permissions_explained`.\n\nOther changes\n~~~~~~~~~~~~~\n\n- The new :ref:`DATASETTE_TRACE_PLUGINS=1 environment variable <writing_plugins_tracing>` turns on detailed trace output for every executed plugin hook, useful for debugging and understanding how the plugin system works at a low level. (:issue:`2274`)\n- Datasette on Python 3.9 or above marks its non-cryptographic uses of the MD5 hash function as ``usedforsecurity=False``, for compatibility with FIPS systems. (:issue:`2270`)\n- SQL relating to :ref:`internals_internal` now executes inside a transaction, avoiding a potential database locked error. (:issue:`2273`)\n- The ``/-/threads`` debug page now identifies the database in the name associated with each dedicated write thread. (:issue:`2265`)\n- The ``/db/-/create`` API now fires a ``insert-rows`` event if rows were inserted after the table was created. (:issue:`2260`)\n\n.. _v1_0_a8:\n\n1.0a8 (2024-02-07)\n------------------\n\nThis alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, introduces a new system for JavaScript plugins and adds several new plugin hooks.\n\nSee `Datasette 1.0a8: JavaScript plugins, new plugin hooks and plugin configuration in datasette.yaml <https://simonwillison.net/2024/Feb/7/datasette-1a8/>`__ for an annotated version of these release notes.\n\nConfiguration\n~~~~~~~~~~~~~\n\n- Plugin configuration now lives in the :ref:`datasette.yaml configuration file <configuration>`, passed to Datasette using the ``-c/--config`` option. Thanks, Alex Garcia. (:issue:`2093`)\n\n  .. code-block:: bash\n\n        datasette -c datasette.yaml\n\n  Where ``datasette.yaml`` contains configuration that looks like this:\n\n  .. code-block:: yaml\n\n        plugins:\n          datasette-cluster-map:\n            latitude_column: xlat\n            longitude_column: xlon\n\n  Previously plugins were configured in ``metadata.yaml``, which was confusing as plugin settings were unrelated to database and table metadata.\n- The ``-s/--setting`` option can now be used to set plugin configuration as well. See :ref:`configuration_cli` for details. (:issue:`2252`)\n\n  The above YAML configuration example using ``-s/--setting`` looks like this:\n\n  .. code-block:: bash\n\n        datasette mydatabase.db \\\n          -s plugins.datasette-cluster-map.latitude_column xlat \\\n          -s plugins.datasette-cluster-map.longitude_column xlon\n\n- The new ``/-/config`` page shows the current instance configuration, after redacting keys that could contain sensitive data such as API keys or passwords. (:issue:`2254`)\n\n- Existing Datasette installations may already have configuration set in ``metadata.yaml`` that should be migrated to ``datasette.yaml``. To avoid breaking these installations, Datasette will silently treat table configuration, plugin configuration and allow blocks in metadata as if they had been specified in configuration instead. (:issue:`2247`) (:issue:`2248`) (:issue:`2249`)\n\nNote that the ``datasette publish`` command has not yet been updated to accept a ``datasette.yaml`` configuration file. This will be addressed in :issue:`2195` but for the moment you can include those settings in ``metadata.yaml`` instead.\n\nJavaScript plugins\n~~~~~~~~~~~~~~~~~~\n\nDatasette now includes a :ref:`JavaScript plugins mechanism <javascript_plugins>`, allowing JavaScript to customize Datasette in a way that can collaborate with other plugins.\n\nThis provides two initial hooks, with more to come in the future:\n\n- :ref:`makeAboveTablePanelConfigs() <javascript_plugins_makeAboveTablePanelConfigs>` can add additional panels to the top of the table page.\n- :ref:`makeColumnActions() <javascript_plugins_makeColumnActions>` can add additional actions to the column menu.\n\nThanks `Cameron Yick <https://github.com/hydrosquall>`__ for contributing this feature. (`#2052 <https://github.com/simonw/datasette/pull/2052>`__)\n\nPlugin hooks\n~~~~~~~~~~~~\n\n- New :ref:`plugin_hook_jinja2_environment_from_request` plugin hook, which can be used to customize the current Jinja environment based on the incoming request. This can be used to modify the template lookup path based on the incoming request hostname, among other things. (:issue:`2225`)\n- New :ref:`family of template slot plugin hooks <plugin_hook_slots>`: ``top_homepage``, ``top_database``, ``top_table``, ``top_row``, ``top_query``, ``top_canned_query``. Plugins can use these to provide additional HTML to be injected at the top of the corresponding pages. (:issue:`1191`)\n- New :ref:`track_event() mechanism <plugin_event_tracking>` for plugins to emit and receive events when certain events occur within Datasette. (:issue:`2240`)\n    - Plugins can register additional event classes using :ref:`plugin_hook_register_events`.\n    - They can then trigger those events with the :ref:`datasette.track_event(event) <datasette_track_event>` internal method.\n    - Plugins can subscribe to notifications of events using the :ref:`plugin_hook_track_event` plugin hook.\n    - Datasette core now emits ``login``, ``logout``, ``create-token``, ``create-table``, ``drop-table``, ``insert-rows``, ``upsert-rows``, ``update-row``, ``delete-row`` events, :ref:`documented here <events>`.\n- New internal function for plugin authors: :ref:`database_execute_isolated_fn`, for creating a new SQLite connection, executing code and then closing that connection, all while preventing other code from writing to that particular database. This connection will not have the :ref:`prepare_connection() <plugin_hook_prepare_connection>` plugin hook executed against it, allowing plugins to perform actions that might otherwise be blocked by existing connection configuration. (:issue:`2218`)\n\nDocumentation\n~~~~~~~~~~~~~\n\n- Documentation describing :ref:`how to write tests that use signed actor cookies <testing_datasette_client>` using ``datasette.client.actor_cookie()``. (:issue:`1830`)\n- Documentation on how to :ref:`register a plugin for the duration of a test <testing_plugins_register_in_test>`. (:issue:`2234`)\n- The :ref:`configuration documentation <configuration>` now shows examples of both YAML and JSON for each setting.\n\nMinor fixes\n~~~~~~~~~~~\n\n- Datasette no longer attempts to run SQL queries in parallel when rendering a table page, as this was leading to some rare crashing bugs. (:issue:`2189`)\n- Fixed warning: ``DeprecationWarning: pkg_resources is deprecated as an API`` (:issue:`2057`)\n- Fixed bug where ``?_extra=columns`` parameter returned an incorrectly shaped response. (:issue:`2230`)\n\n.. _v0_64_6:\n\n0.64.6 (2023-12-22)\n-------------------\n\n- Fixed a bug where CSV export with expanded labels could fail if a foreign key reference did not correctly resolve. (:issue:`2214`)\n\n.. _v0_64_5:\n\n0.64.5 (2023-10-08)\n-------------------\n\n- Dropped dependency on ``click-default-group-wheel``, which could cause a dependency conflict. (:issue:`2197`)\n\n.. _v1_0_a7:\n\n1.0a7 (2023-09-21)\n------------------\n\n- Fix for a crashing bug caused by viewing the table page for a named in-memory database. (:issue:`2189`)\n\n.. _v0_64_4:\n\n0.64.4 (2023-09-21)\n-------------------\n\n- Fix for a crashing bug caused by viewing the table page for a named in-memory database. (:issue:`2189`)\n\n.. _v1_0_a6:\n\n1.0a6 (2023-09-07)\n------------------\n\n- New plugin hook: :ref:`plugin_hook_actors_from_ids` and an internal method to accompany it, :ref:`datasette_actors_from_ids`. This mechanism is intended to be used by plugins that may need to display the actor who was responsible for something managed by that plugin: they can now resolve the recorded IDs of actors into the full actor objects. (:issue:`2181`)\n- ``DATASETTE_LOAD_PLUGINS`` environment variable for :ref:`controlling which plugins <plugins_datasette_load_plugins>` are loaded by Datasette. (:issue:`2164`)\n- Datasette now checks if the user has permission to view a table linked to by a foreign key before turning that foreign key into a clickable link. (:issue:`2178`)\n- The ``execute-sql`` permission now implies that the actor can also view the database and instance. (:issue:`2169`)\n- Documentation describing a pattern for building plugins that themselves :ref:`define further hooks <writing_plugins_extra_hooks>` for other plugins. (:issue:`1765`)\n- Datasette is now tested against the Python 3.12 preview. (`#2175 <https://github.com/simonw/datasette/pull/2175>`__)\n\n.. _v1_0_a5:\n\n1.0a5 (2023-08-29)\n------------------\n\n- When restrictions are applied to :ref:`API tokens <CreateTokenView>`, those restrictions now behave slightly differently: applying the ``view-table`` restriction will imply the ability to ``view-database`` for the database containing that table, and both ``view-table`` and ``view-database`` will imply ``view-instance``. Previously you needed to create a token with restrictions that explicitly listed ``view-instance`` and ``view-database`` and ``view-table`` in order to view a table without getting a permission denied error. (:issue:`2102`)\n- New ``datasette.yaml`` (or ``.json``) configuration file, which can be specified using ``datasette -c path-to-file``. The goal here to consolidate settings, plugin configuration, permissions, canned queries, and other Datasette configuration into a single single file, separate from ``metadata.yaml``. The legacy ``settings.json`` config file used for :ref:`config_dir` has been removed, and ``datasette.yaml`` has a ``\"settings\"`` section where the same settings key/value pairs can be included. In the next future alpha release, more configuration such as plugins/permissions/canned queries will be moved to the ``datasette.yaml`` file. See :issue:`2093` for more details. Thanks, Alex Garcia.\n- The ``-s/--setting`` option can now take dotted paths to nested settings. These will then be used to set or over-ride the same options as are present in the new configuration file. (:issue:`2156`)\n- New ``--actor '{\"id\": \"json-goes-here\"}'`` option for use with ``datasette --get`` to treat the simulated request as being made by a specific actor, see :ref:`cli_datasette_get`. (:issue:`2153`)\n- The Datasette ``_internal`` database has had some changes. It no longer shows up in the ``datasette.databases`` list by default, and is now instead available to plugins using the ``datasette.get_internal_database()``. Plugins are invited to use this as a private database to store configuration and settings and secrets that should not be made visible through the default Datasette interface. Users can pass the new  ``--internal internal.db`` option to persist that internal database to disk. Thanks, Alex Garcia. (:issue:`2157`).\n\n.. _v1_0_a4:\n\n1.0a4 (2023-08-21)\n------------------\n\nThis alpha fixes a security issue with the ``/-/api`` API explorer. On authenticated Datasette instances (instances protected using plugins such as `datasette-auth-passwords <https://datasette.io/plugins/datasette-auth-passwords>`__) the API explorer interface could reveal the names of databases and tables within the protected instance. The data stored in those tables was not revealed.\n\nFor more information and workarounds, read `the security advisory <https://github.com/simonw/datasette/security/advisories/GHSA-7ch3-7pp7-7cpq>`__. The issue has been present in every previous alpha version of Datasette 1.0: versions 1.0a0, 1.0a1, 1.0a2 and 1.0a3.\n\nAlso in this alpha:\n\n- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)\n- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)\n- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)\n- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)\n\n.. _v1_0_a3:\n\n1.0a3 (2023-08-09)\n------------------\n\nThis alpha release previews the updated design for Datasette's default JSON API. (:issue:`782`)\n\nThe new :ref:`default JSON representation <json_api_default>` for both table pages (``/dbname/table.json``) and arbitrary SQL queries (``/dbname.json?sql=...``) is now shaped like this:\n\n.. code-block:: json\n\n    {\n      \"ok\": true,\n      \"rows\": [\n        {\n          \"id\": 3,\n          \"name\": \"Detroit\"\n        },\n        {\n          \"id\": 2,\n          \"name\": \"Los Angeles\"\n        },\n        {\n          \"id\": 4,\n          \"name\": \"Memnonia\"\n        },\n        {\n          \"id\": 1,\n          \"name\": \"San Francisco\"\n        }\n      ],\n      \"truncated\": false\n    }\n\nTables will include an additional ``\"next\"`` key for pagination, which can be passed to ``?_next=`` to fetch the next page of results.\n\nThe various ``?_shape=`` options continue to work as before - see :ref:`json_api_shapes` for details.\n\nA new ``?_extra=`` mechanism is available for tables, but has not yet been stabilized or documented. Details on that are available in :issue:`262`.\n\nSmaller changes\n~~~~~~~~~~~~~~~\n\n- Datasette documentation now shows YAML examples for :ref:`metadata` by default, with a tab interface for switching to JSON. (:issue:`1153`)\n- :ref:`plugin_register_output_renderer` plugins now have access to ``error`` and ``truncated`` arguments, allowing them to display error messages and take into account truncated results. (:issue:`2130`)\n- ``render_cell()`` plugin hook now also supports an optional ``request`` argument. (:issue:`2007`)\n- New ``Justfile`` to support development workflows for Datasette using `Just <https://github.com/casey/just>`__.\n- ``datasette.render_template()`` can now accepts a ``datasette.views.Context`` subclass as an alternative to a dictionary. (:issue:`2127`)\n- ``datasette install -e path`` option for editable installations, useful while developing plugins. (:issue:`2106`)\n- When started with the ``--cors`` option Datasette now serves an ``Access-Control-Max-Age: 3600`` header, ensuring CORS OPTIONS requests are repeated no more than once an hour. (:issue:`2079`)\n- Fixed a bug where the ``_internal`` database could display ``None`` instead of ``null`` for in-memory databases. (:issue:`1970`)\n\n.. _v0_64_2:\n\n0.64.2 (2023-03-08)\n-------------------\n\n- Fixed a bug with ``datasette publish cloudrun`` where deploys all used the same Docker image tag. This was mostly inconsequential as the service is deployed as soon as the image has been pushed to the registry, but could result in the incorrect image being deployed if two different deploys for two separate services ran at exactly the same time. (:issue:`2036`)\n\n.. _v0_64_1:\n\n0.64.1 (2023-01-11)\n-------------------\n\n- Documentation now links to a current source of information for installing Python 3. (:issue:`1987`)\n- Incorrectly calling the Datasette constructor using ``Datasette(\"path/to/data.db\")`` instead of ``Datasette([\"path/to/data.db\"])`` now returns a useful error message. (:issue:`1985`)\n\n.. _v0_64:\n\n0.64 (2023-01-09)\n-----------------\n\n- Datasette now **strongly recommends against allowing arbitrary SQL queries if you are using SpatiaLite**. SpatiaLite includes SQL functions that could cause the Datasette server to crash. See :ref:`spatialite` for more details.\n- New :ref:`setting_default_allow_sql` setting, providing an easier way to disable all arbitrary SQL execution by end users: ``datasette --setting default_allow_sql off``. See also :ref:`authentication_permissions_execute_sql`. (:issue:`1409`)\n- `Building a location to time zone API with SpatiaLite <https://datasette.io/tutorials/spatialite>`__ is a new Datasette tutorial showing how to safely use SpatiaLite to create a location to time zone API.\n- New documentation about :ref:`how to debug problems loading SQLite extensions <installation_extensions>`. The error message shown when an extension cannot be loaded has also been improved. (:issue:`1979`)\n- Fixed an accessibility issue: the ``<select>`` elements in the table filter form now show an outline when they are currently focused. (:issue:`1771`)\n\n.. _v0_63_3:\n\n0.63.3 (2022-12-17)\n-------------------\n\n- Fixed a bug where ``datasette --root``, when running in Docker, would only output the URL to sign in root when the server shut down, not when it started up. (:issue:`1958`)\n- You no longer need to ensure ``await datasette.invoke_startup()`` has been called in order for Datasette to start correctly serving requests - this is now handled automatically the first time the server receives a request. This fixes a bug experienced when Datasette is served directly by an ASGI application server such as Uvicorn or Gunicorn. It also fixes a bug with the `datasette-gunicorn <https://datasette.io/plugins/datasette-gunicorn>`__ plugin. (:issue:`1955`)\n\n.. _v1_0_a2:\n\n1.0a2 (2022-12-14)\n------------------\n\nThe third Datasette 1.0 alpha release adds upsert support to the JSON API, plus the ability to specify finely grained permissions when creating an API token.\n\nSee `Datasette 1.0a2: Upserts and finely grained permissions <https://simonwillison.net/2022/Dec/15/datasette-1a2/>`__ for an extended, annotated version of these release notes.\n\n- New ``/db/table/-/upsert`` API, :ref:`documented here <TableUpsertView>`. upsert is an update-or-insert: existing rows will have specified keys updated, but if no row matches the incoming primary key a brand new row will be inserted instead. (:issue:`1878`)\n- New ``register_permissions()`` plugin hook. Plugins can now register named permissions, which will then be listed in various interfaces that show available permissions. (:issue:`1940`)\n- The ``/db/-/create`` API for :ref:`creating a table <TableCreateView>` now accepts ``\"ignore\": true`` and ``\"replace\": true`` options when called with the ``\"rows\"`` property that creates a new table based on an example set of rows. This means the API can be called multiple times with different rows, setting rules for what should happen if a primary key collides with an existing row. (:issue:`1927`)\n- Arbitrary permissions can now be configured at the instance, database and resource (table, SQL view or canned query) level in Datasette's :ref:`metadata` JSON and YAML files. The new ``\"permissions\"`` key can be used to specify which actors should have which permissions. See :ref:`authentication_permissions_other` for details. (:issue:`1636`)\n- The ``/-/create-token`` page can now be used to create API tokens which are restricted to just a subset of actions, including against specific databases or resources. See :ref:`CreateTokenView` for details. (:issue:`1947`)\n- Likewise, the ``datasette create-token`` CLI command can now create tokens with :ref:`a subset of permissions <authentication_cli_create_token_restrict>`. (:issue:`1855`)\n- New :ref:`datasette.create_token() API method <datasette_create_token>` for programmatically creating signed API tokens. (:issue:`1951`)\n- ``/db/-/create`` API now requires actor to have ``insert-row`` permission in order to use the ``\"row\"`` or ``\"rows\"`` properties. (:issue:`1937`)\n\n.. _v1_0_a1:\n\n1.0a1 (2022-12-01)\n------------------\n\n- Write APIs now serve correct CORS headers if Datasette is started in ``--cors`` mode. See the full list of :ref:`CORS headers <json_api>` in the documentation. (:issue:`1922`)\n- Fixed a bug where the ``_memory`` database could be written to even though writes were not persisted. (:issue:`1917`)\n- The https://latest.datasette.io/ demo instance now includes an ``ephemeral`` database which can be used to test Datasette's write APIs, using the new `datasette-ephemeral-tables <https://datasette.io/plugins/datasette-ephemeral-tables>`_ plugin to drop any created tables after five minutes. This database is only available if you sign in as the root user using the link on the homepage. (:issue:`1915`)\n- Fixed a bug where hitting the write endpoints with a ``GET`` request returned a 500 error. It now returns a 405 (method not allowed) error instead. (:issue:`1916`)\n- The list of endpoints in the API explorer now lists mutable databases first. (:issue:`1918`)\n- The ``\"ignore\": true`` and ``\"replace\": true`` options for the insert API are :ref:`now documented <TableInsertView>`. (:issue:`1924`)\n\n.. _v1_0_a0:\n\n1.0a0 (2022-11-29)\n------------------\n\nThis first alpha release of Datasette 1.0 introduces a brand new collection of APIs for writing to the database (:issue:`1850`), as well as a new API token mechanism baked into Datasette core. Previously, API tokens have only been supported by installing additional plugins.\n\nThis is very much a preview: expect many more backwards incompatible API changes prior to the full 1.0 release.\n\nFeedback enthusiastically welcomed, either through `issue comments <https://github.com/simonw/datasette/issues/1850>`__ or via the `Datasette Discord <https://datasette.io/discord>`__ community.\n\nSigned API tokens\n~~~~~~~~~~~~~~~~~\n\n- New ``/-/create-token`` page allowing authenticated users to create signed API tokens that can act on their behalf, see :ref:`CreateTokenView`. (:issue:`1852`)\n- New ``datasette create-token`` command for creating tokens from the command line: :ref:`authentication_cli_create_token`.\n- New :ref:`setting_allow_signed_tokens` setting which can be used to turn off signed token support. (:issue:`1856`)\n- New :ref:`setting_max_signed_tokens_ttl` setting for restricting the maximum allowed duration of a signed token. (:issue:`1858`)\n\nWrite API\n~~~~~~~~~\n\n- New API explorer at ``/-/api`` for trying out the API. (:issue:`1871`)\n- ``/db/-/create`` API for :ref:`TableCreateView`. (:issue:`1882`)\n- ``/db/table/-/insert`` API for :ref:`TableInsertView`. (:issue:`1851`)\n- ``/db/table/-/drop`` API for :ref:`TableDropView`. (:issue:`1874`)\n- ``/db/table/pk/-/update`` API for :ref:`RowUpdateView`. (:issue:`1863`)\n- ``/db/table/pk/-/delete`` API for :ref:`RowDeleteView`. (:issue:`1864`)\n\n.. _v0_63_2:\n\n0.63.2 (2022-11-18)\n-------------------\n\n- Fixed a bug in ``datasette publish heroku`` where deployments failed due to an older version of Python being requested. (:issue:`1905`)\n- New ``datasette publish heroku --generate-dir <dir>`` option for generating a Heroku deployment directory without deploying it.\n\n.. _v0_63_1:\n\n0.63.1 (2022-11-10)\n-------------------\n\n- Fixed a bug where Datasette's table filter form would not redirect correctly when run behind a proxy using the :ref:`base_url <setting_base_url>` setting. (:issue:`1883`)\n- SQL query is now shown wrapped in a ``<textarea>`` if a query exceeds a time limit. (:issue:`1876`)\n- Fixed an intermittent \"Too many open files\" error while running the test suite. (:issue:`1843`)\n- New :ref:`database_close` internal method.\n\n.. _v0_63:\n\n0.63 (2022-10-27)\n-----------------\n\nSee `Datasette 0.63: The annotated release notes <https://simonwillison.net/2022/Oct/27/datasette-0-63/>`__ for more background on the changes in this release.\n\nFeatures\n~~~~~~~~\n\n- Now tested against Python 3.11. Docker containers used by ``datasette publish`` and ``datasette package`` both now use that version of Python. (:issue:`1853`)\n- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 <https://github.com/simonw/datasette/pull/1789>`__)\n- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`)\n- The :ref:`setting_truncate_cells_html` setting now also affects long URLs in columns. (:issue:`1805`)\n- The non-JavaScript SQL editor textarea now increases height to fit the SQL query. (:issue:`1786`)\n- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 <https://github.com/simonw/datasette/pull/1794>`__)\n- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`)\n- SQL queries can now include leading SQL comments, using ``/* ... */`` or ``-- ...`` syntax. Thanks,  Charles Nepote. (:issue:`1860`)\n- SQL query is now re-displayed when terminated with a time limit error. (:issue:`1819`)\n- The :ref:`inspect data <performance_inspect>` mechanism is now used to speed up server startup - thanks, Forest Gregg. (:issue:`1834`)\n- In :ref:`config_dir` databases with filenames ending in ``.sqlite`` or ``.sqlite3`` are now automatically added to the Datasette instance. (:issue:`1646`)\n- Breadcrumb navigation display now respects the current user's permissions. (:issue:`1831`)\n\nPlugin hooks and internals\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n- The :ref:`plugin_hook_prepare_jinja2_environment` plugin hook now accepts an optional ``datasette`` argument. Hook implementations can also now return an ``async`` function which will be awaited automatically. (:issue:`1809`)\n- ``Database(is_mutable=)`` now defaults to ``True``. (:issue:`1808`)\n- The :ref:`datasette.check_visibility() <datasette_check_visibility>` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`)\n- Datasette no longer enforces upper bounds on its dependencies. (:issue:`1800`)\n\nDocumentation\n~~~~~~~~~~~~~\n\n- New tutorial: `Cleaning data with sqlite-utils and Datasette <https://datasette.io/tutorials/clean-data>`__.\n- Screenshots in the documentation are now maintained using `shot-scraper <https://shot-scraper.datasette.io/>`__, as described in `Automating screenshots for the Datasette documentation using shot-scraper <https://simonwillison.net/2022/Oct/14/automating-screenshots/>`__. (:issue:`1844`)\n- More detailed command descriptions on the :ref:`CLI reference <cli_reference>` page. (:issue:`1787`)\n- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 <https://github.com/simonw/datasette/pull/1825>`__)\n\n.. _v0_62:\n\n0.62 (2022-08-14)\n-----------------\n\nDatasette can now run entirely in your browser using WebAssembly. Try out `Datasette Lite <https://lite.datasette.io/>`__, take a look `at the code <https://github.com/simonw/datasette-lite>`__ or read more about it in `Datasette Lite: a server-side Python web application running in a browser <https://simonwillison.net/2022/May/4/datasette-lite/>`__.\n\nDatasette now has a `Discord community <https://datasette.io/discord>`__ for questions and discussions about Datasette and its ecosystem of projects.\n\nFeatures\n~~~~~~~~\n\n- Datasette is now compatible with `Pyodide <https://pyodide.org/>`__.  This is the enabling technology behind `Datasette Lite <https://lite.datasette.io/>`__. (:issue:`1733`)\n- Database file downloads now implement conditional GET using ETags. (:issue:`1739`)\n- HTML for facet results and suggested results has been extracted out into new templates ``_facet_results.html`` and ``_suggested_facets.html``. Thanks, M. Nasimul Haque. (`#1759 <https://github.com/simonw/datasette/pull/1759>`__)\n- Datasette now runs some SQL queries in parallel. This has limited impact on performance, see `this research issue <https://github.com/simonw/datasette/issues/1727>`__ for details.\n- New ``--nolock`` option for ignoring file locks when opening read-only databases. (:issue:`1744`)\n- Spaces in the database names in URLs are now encoded as ``+`` rather than ``~20``. (:issue:`1701`)\n- ``<Binary: 2427344 bytes>`` is now displayed as ``<Binary: 2,427,344 bytes>`` and is accompanied by tooltip showing \"2.3MB\". (:issue:`1712`)\n- 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`)\n- Canned writable queries against immutable databases now show a warning message. (:issue:`1728`)\n- ``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. (`#1717 <https://github.com/simonw/datasette/pull/1717>`__)\n- ``datasette publish cloudrun`` has new ``--min-instances`` and ``--max-instances`` options. (:issue:`1779`)\n\nPlugin hooks\n~~~~~~~~~~~~\n\n- New plugin hook: :ref:`handle_exception() <plugin_hook_handle_exception>`, for custom handling of exceptions caught by Datasette. (:issue:`1770`)\n- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook is now also passed a ``row`` argument, representing the ``sqlite3.Row`` object that is being rendered. (:issue:`1300`)\n- The :ref:`configuration directory <config_dir>` is now stored in ``datasette.config_dir``, making it available to plugins. Thanks, Chris Amico. (`#1766 <https://github.com/simonw/datasette/pull/1766>`__)\n\nBug fixes\n~~~~~~~~~\n\n- Don't show the facet option in the cog menu if faceting is not allowed. (:issue:`1683`)\n- ``?_sort`` and ``?_sort_desc`` now work if the column that is being sorted has been excluded from the query using ``?_col=`` or ``?_nocol=``. (:issue:`1773`)\n- Fixed bug where ``?_sort_desc`` was duplicated in the URL every time the Apply button was clicked. (:issue:`1738`)\n\nDocumentation\n~~~~~~~~~~~~~\n\n- Examples in the documentation now include a copy-to-clipboard button. (:issue:`1748`)\n- Documentation now uses the `Furo <https://github.com/pradyunsg/furo>`__ Sphinx theme. (:issue:`1746`)\n- Code examples in the documentation are now all formatted using Black. (:issue:`1718`)\n- ``Request.fake()`` method is now documented, see :ref:`internals_request`.\n- New documentation for plugin authors: :ref:`testing_plugins_register_in_test`. (:issue:`903`)\n\n.. _v0_61_1:\n\n0.61.1 (2022-03-23)\n-------------------\n\n- Fixed a bug where databases with a different route from their name (as used by the `datasette-hashed-urls plugin <https://datasette.io/plugins/datasette-hashed-urls>`__) returned errors when executing custom SQL queries. (:issue:`1682`)\n\n.. _v0_61:\n\n0.61 (2022-03-23)\n-----------------\n\nIn preparation for Datasette 1.0, this release includes two potentially backwards-incompatible changes. Hashed URL mode has been moved to a separate plugin, and the way Datasette generates URLs to databases and tables with special characters in their name such as ``/`` and ``.`` has changed.\n\nDatasette also now requires Python 3.7 or higher.\n\n- URLs within Datasette now use a different encoding scheme for tables or databases that include \"special\" characters outside of the range of ``a-zA-Z0-9_-``. This scheme is explained here: :ref:`internals_tilde_encoding`. (:issue:`1657`)\n- Removed hashed URL mode from Datasette. The new ``datasette-hashed-urls`` plugin can be used to achieve the same result, see :ref:`performance_hashed_urls` for details. (:issue:`1661`)\n- Databases can now have a custom path within the Datasette instance that is independent of the database name, using the ``db.route`` property. (:issue:`1668`)\n- Datasette is now covered by a `Code of Conduct <https://github.com/simonw/datasette/blob/main/CODE_OF_CONDUCT.md>`__. (:issue:`1654`)\n- Python 3.6 is no longer supported. (:issue:`1577`)\n- Tests now run against Python 3.11-dev. (:issue:`1621`)\n- New ``datasette.ensure_permissions(actor, permissions)`` internal method for checking multiple permissions at once. (:issue:`1675`)\n- New :ref:`datasette.check_visibility(actor, action, resource=None) <datasette_check_visibility>` internal method for checking if a user can see a resource that would otherwise be invisible to unauthenticated users. (:issue:`1678`)\n- Table and row HTML pages now include a ``<link rel=\"alternate\" type=\"application/json+datasette\" href=\"...\">`` element and return a ``Link: URL; rel=\"alternate\"; type=\"application/json+datasette\"`` HTTP header pointing to the JSON version of those pages. (:issue:`1533`)\n- ``Access-Control-Expose-Headers: Link`` is now added to the CORS headers, allowing remote JavaScript to access that header.\n- Canned queries are now shown at the top of the database page, directly below the SQL editor. Previously they were shown at the bottom, below the list of tables. (:issue:`1612`)\n- Datasette now has a default favicon. (:issue:`1603`)\n- ``sqlite_stat`` tables are now hidden by default. (:issue:`1587`)\n- SpatiaLite tables ``data_licenses``, ``KNN`` and ``KNN2`` are now hidden by default. (:issue:`1601`)\n- SQL query tracing mechanism now works for queries executed in ``asyncio`` sub-tasks, such as those created by ``asyncio.gather()``. (:issue:`1576`)\n- :ref:`internals_tracer` mechanism is now documented.\n- Common Datasette symbols can now be imported directly from the top-level ``datasette`` package, see :ref:`internals_shortcuts`. Those symbols are ``Response``, ``Forbidden``, ``NotFound``, ``hookimpl``, ``actor_matches_allow``. (:issue:`957`)\n- ``/-/versions`` page now returns additional details for libraries used by SpatiaLite. (:issue:`1607`)\n- Documentation now links to the `Datasette Tutorials <https://datasette.io/tutorials>`__.\n- Datasette will now also look for SpatiaLite in ``/opt/homebrew`` - thanks, Dan Peterson. (`#1649 <https://github.com/simonw/datasette/pull/1649>`__)\n- Fixed bug where :ref:`custom pages <custom_pages>` did not work on Windows. Thanks, Robert Christie. (:issue:`1545`)\n- Fixed error caused when a table had a column named ``n``. (:issue:`1228`)\n\n.. _v0_60_2:\n\n0.60.2 (2022-02-07)\n-------------------\n\n- Fixed a bug where Datasette would open the same file twice with two different database names if you ran ``datasette file.db file.db``. (:issue:`1632`)\n\n.. _v0_60_1:\n\n0.60.1 (2022-01-20)\n-------------------\n\n- Fixed a bug where installation on Python 3.6 stopped working due to a change to an underlying dependency. This release can now be installed on Python 3.6, but is the last release of Datasette that will support anything less than Python 3.7. (:issue:`1609`)\n\n.. _v0_60:\n\n0.60 (2022-01-13)\n-----------------\n\nPlugins and internals\n~~~~~~~~~~~~~~~~~~~~~\n\n- New plugin hook: :ref:`plugin_hook_filters_from_request`, which runs on the table page and can be used to support new custom query string parameters that modify the SQL query. (:issue:`473`)\n- Added two additional methods for writing to the database: :ref:`database_execute_write_script` and :ref:`database_execute_write_many`. (:issue:`1570`)\n- The :ref:`db.execute_write() <database_execute_write>` internal method now defaults to blocking until the write operation has completed. Previously it defaulted to queuing the write and then continuing to run code while the write was in the queue. (:issue:`1579`)\n- Database write connections now execute the :ref:`plugin_hook_prepare_connection` plugin hook. (:issue:`1564`)\n- The ``Datasette()`` constructor no longer requires the ``files=`` argument, and is now documented at :ref:`internals_datasette`. (:issue:`1563`)\n- The tracing feature now traces write queries, not just read queries. (:issue:`1568`)\n- The query string variables exposed by ``request.args`` will now include blank strings for arguments such as ``foo`` in ``?foo=&bar=1`` rather than ignoring those parameters entirely. (:issue:`1551`)\n\nFaceting\n~~~~~~~~\n\n- The number of unique values in a facet is now always displayed. Previously it was only displayed if the user specified ``?_facet_size=max``. (:issue:`1556`)\n- Facets of type ``date`` or ``array`` can now be configured in ``metadata.json``, see :ref:`facets_metadata`. Thanks, David Larlet. (:issue:`1552`)\n- New ``?_nosuggest=1`` parameter for table views, which disables facet suggestion. (:issue:`1557`)\n- Fixed bug where ``?_facet_array=tags&_facet=tags`` would only display one of the two selected facets. (:issue:`625`)\n\nOther small fixes\n~~~~~~~~~~~~~~~~~\n\n- Made several performance improvements to the database schema introspection code that runs when Datasette first starts up. (:issue:`1555`)\n- Label columns detected for foreign keys are now case-insensitive, so ``Name`` or ``TITLE`` will be detected in the same way as ``name`` or ``title``. (:issue:`1544`)\n- Upgraded Pluggy dependency to 1.0. (:issue:`1575`)\n- Now using `Plausible analytics <https://plausible.io/>`__ for the Datasette documentation.\n- ``explain query plan`` is now allowed with varying amounts of whitespace in the query. (:issue:`1588`)\n- New :ref:`cli_reference` page showing the output of ``--help`` for each of the ``datasette`` sub-commands. This lead to several small improvements to the help copy. (:issue:`1594`)\n- Fixed bug where writable canned queries could not be used with custom templates.  (:issue:`1547`)\n- Improved fix for a bug where columns with a underscore prefix could result in unnecessary hidden form fields. (:issue:`1527`)\n\n.. _v0_59_4:\n\n0.59.4 (2021-11-29)\n-------------------\n\n- Fixed bug where columns with a leading underscore could not be removed from the interactive filters list. (:issue:`1527`)\n- Fixed bug where columns with a leading underscore were not correctly linked to by the \"Links from other tables\" interface on the row page. (:issue:`1525`)\n- Upgraded dependencies ``aiofiles``, ``black`` and ``janus``.\n\n.. _v0_59_3:\n\n0.59.3 (2021-11-20)\n-------------------\n\n- Fixed numerous bugs when running Datasette :ref:`behind a proxy <deploying_proxy>` with a prefix URL path using the :ref:`setting_base_url` setting. A live demo of this mode is now available at `datasette-apache-proxy-demo.datasette.io/prefix/ <https://datasette-apache-proxy-demo.datasette.io/prefix/>`__. (:issue:`1519`, :issue:`838`)\n- ``?column__arraycontains=`` and ``?column__arraynotcontains=`` table parameters now also work against SQL views. (:issue:`448`)\n- ``?_facet_array=column`` no longer returns incorrect counts if columns contain the same value more than once.\n\n.. _v0_59_2:\n\n0.59.2 (2021-11-13)\n-------------------\n\n- Column names with a leading underscore now work correctly when used as a facet. (:issue:`1506`)\n- Applying ``?_nocol=`` to a column no longer removes that column from the filtering interface. (:issue:`1503`)\n- Official Datasette Docker container now uses Debian Bullseye as the base image. (:issue:`1497`)\n- Datasette is four years old today! Here's the `original release announcement <https://simonwillison.net/2017/Nov/13/datasette/>`__ from 2017.\n\n.. _v0_59_1:\n\n0.59.1 (2021-10-24)\n-------------------\n\n- Fix compatibility with Python 3.10. (:issue:`1482`)\n- Documentation on how to use :ref:`sql_parameters` with integer and floating point values. (:issue:`1496`)\n\n.. _v0_59:\n\n0.59 (2021-10-14)\n-----------------\n\n- Columns can now have associated metadata descriptions in ``metadata.json``, see :ref:`metadata_column_descriptions`. (:issue:`942`)\n- New :ref:`register_commands() <plugin_hook_register_commands>` plugin hook allows plugins to register additional Datasette CLI commands, e.g. ``datasette mycommand file.db``. (:issue:`1449`)\n- Adding ``?_facet_size=max`` to a table page now shows the number of unique values in each facet. (:issue:`1423`)\n- Upgraded dependency `httpx 0.20 <https://github.com/encode/httpx/releases/tag/0.20.0>`__ - the undocumented ``allow_redirects=`` parameter to :ref:`internals_datasette_client` is now ``follow_redirects=``, and defaults to ``False`` where it previously defaulted to ``True``. (:issue:`1488`)\n- The ``--cors`` option now causes Datasette to return the ``Access-Control-Allow-Headers: Authorization`` header, in addition to ``Access-Control-Allow-Origin: *``. (`#1467 <https://github.com/simonw/datasette/pull/1467>`__)\n- Code that figures out which named parameters a SQL query takes in order to display form fields for them is no longer confused by strings that contain colon characters. (:issue:`1421`)\n- Renamed ``--help-config`` option to ``--help-settings``. (:issue:`1431`)\n- ``datasette.databases`` property is now a documented API. (:issue:`1443`)\n- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class=\"not-footer\">`` element, to help with advanced CSS customization. (:issue:`1446`)\n- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`)\n- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`)\n- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`)\n- New ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`)\n- If `Rich <https://github.com/willmcgugan/rich>`__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`)\n- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__, is now a documented API. (:issue:`1405`)\n- Fixed bug where ``?_next=x&_sort=rowid`` could throw an error. (:issue:`1470`)\n- Column cog menu no longer shows the option to facet by a column that is already selected by the default facets in metadata. (:issue:`1469`)\n\n.. _v0_58_1:\n\n0.58.1 (2021-07-16)\n-------------------\n\n- Fix for an intermittent race condition caused by the ``refresh_schemas()`` internal function. (:issue:`1231`)\n\n.. _v0_58:\n\n0.58 (2021-07-14)\n-----------------\n\n- New ``datasette --uds /tmp/datasette.sock`` option for binding Datasette to a Unix domain socket, see :ref:`proxy documentation <deploying_proxy>` (:issue:`1388`)\n- ``\"searchmode\": \"raw\"`` table metadata option for defaulting a table to executing SQLite full-text search syntax without first escaping it, see :ref:`full_text_search_advanced_queries`. (:issue:`1389`)\n- New plugin hook: ``get_metadata()``, for returning custom metadata for an instance, database or table. Thanks, Brandon Roberts! (:issue:`1384`)\n- New plugin hook: :ref:`plugin_hook_skip_csrf`, for opting out of CSRF protection based on the incoming request. (:issue:`1377`)\n- The :ref:`menu_links() <plugin_hook_menu_links>`, :ref:`table_actions() <plugin_hook_table_actions>` and :ref:`database_actions() <plugin_hook_database_actions>` plugin hooks all gained a new optional ``request`` argument providing access to the current request. (:issue:`1371`)\n- Major performance improvement for Datasette faceting. (:issue:`1394`)\n- Improved documentation for :ref:`deploying_proxy` to recommend using ``ProxyPreservehost On`` with Apache. (:issue:`1387`)\n- ``POST`` requests to endpoints that do not support that HTTP verb now return a 405 error.\n- ``db.path`` can now be provided as a ``pathlib.Path`` object, useful when writing unit tests for plugins. Thanks, Chris Amico. (:issue:`1365`)\n\n.. _v0_57_1:\n\n0.57.1 (2021-06-08)\n-------------------\n\n- Fixed visual display glitch with global navigation menu. (:issue:`1367`)\n- No longer truncates the list of table columns displayed on the ``/database`` page. (:issue:`1364`)\n\n.. _v0_57:\n\n0.57 (2021-06-05)\n-----------------\n\n.. warning::\n    This release fixes a `reflected cross-site scripting <https://owasp.org/www-community/attacks/xss/#reflected-xss-attacks>`__ security hole with the ``?_trace=1`` feature. You should upgrade to this version, or to Datasette 0.56.1, as soon as possible. (:issue:`1360`)\n\nIn addition to the security fix, this release includes ``?_col=`` and ``?_nocol=`` options for controlling which columns are displayed for a table, ``?_facet_size=`` for increasing the number of facet results returned, re-display of your SQL query should an error occur and numerous bug fixes.\n\nNew features\n~~~~~~~~~~~~\n\n- If an error occurs while executing a user-provided SQL query, that query is now re-displayed in an editable form along with the error message. (:issue:`619`)\n-  New ``?_col=`` and ``?_nocol=`` parameters to show and hide columns in a table, plus an interface for hiding and showing columns in the column cog menu. (:issue:`615`)\n- A new ``?_facet_size=`` parameter for customizing the number of facet results returned on a table or view page. (:issue:`1332`)\n- ``?_facet_size=max`` sets that to the maximum, which defaults to 1,000 and is controlled by the the :ref:`setting_max_returned_rows` setting. If facet results are truncated the … at the bottom of the facet list now links to this parameter. (:issue:`1337`)\n- ``?_nofacet=1`` option to disable all facet calculations on a page, used as a performance optimization for CSV exports and ``?_shape=array/object``. (:issue:`1349`, :issue:`263`)\n- ``?_nocount=1`` option to disable full query result counts. (:issue:`1353`)\n- ``?_trace=1`` debugging option is now controlled by the new :ref:`setting_trace_debug` setting, which is turned off by default. (:issue:`1359`)\n\nBug fixes and other improvements\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n- :ref:`custom_pages` now work correctly when combined with the :ref:`setting_base_url` setting. (:issue:`1238`)\n- Fixed intermittent error displaying the index page when the user did not have permission to access one of the tables. Thanks, Guy Freeman. (:issue:`1305`)\n- Columns with the name \"Link\" are no longer incorrectly displayed in bold. (:issue:`1308`)\n- Fixed error caused by tables with a single quote in their names. (:issue:`1257`)\n- Updated dependencies: ``pytest-asyncio``, ``Black``, ``jinja2``, ``aiofiles``, ``click``, and ``itsdangerous``.\n- The official Datasette Docker image now supports ``apt-get install``. (:issue:`1320`)\n- The Heroku runtime used by ``datasette publish heroku`` is now ``python-3.8.10``.\n\n.. _v0_56_1:\n\n0.56.1 (2021-06-05)\n-------------------\n\n.. warning::\n    This release fixes a `reflected cross-site scripting <https://owasp.org/www-community/attacks/xss/#reflected-xss-attacks>`__ security hole with the ``?_trace=1`` feature. You should upgrade to this version, or to Datasette 0.57, as soon as possible. (:issue:`1360`)\n\n.. _v0_56:\n\n0.56 (2021-03-28)\n-----------------\n\nDocumentation improvements, bug fixes and support for SpatiaLite 5.\n\n- The SQL editor can now be resized by dragging a handle. (:issue:`1236`)\n- Fixed a bug with JSON faceting and the ``__arraycontains`` filter caused by tables with spaces in their names. (:issue:`1239`)\n- Upgraded ``httpx`` dependency. (:issue:`1005`)\n- JSON faceting is now suggested even if a column contains blank strings. (:issue:`1246`)\n- New :ref:`datasette.add_memory_database() <datasette_add_memory_database>` method. (:issue:`1247`)\n- The :ref:`Response.asgi_send() <internals_response_asgi_send>` method is now documented. (:issue:`1266`)\n- The official Datasette Docker image now bundles SpatiaLite version 5. (:issue:`1278`)\n- Fixed a ``no such table: pragma_database_list`` bug when running Datasette against SQLite versions prior to SQLite 3.16.0. (:issue:`1276`)\n- HTML lists displayed in table cells are now styled correctly. Thanks, Bob Whitelock. (:issue:`1141`, `#1252 <https://github.com/simonw/datasette/pull/1252>`__)\n- Configuration directory mode now correctly serves immutable databases that are listed in ``inspect-data.json``. Thanks Campbell Allen and Frankie Robertson. (`#1031 <https://github.com/simonw/datasette/pull/1031>`__, `#1229 <https://github.com/simonw/datasette/pull/1229>`__)\n\n.. _v0_55:\n\n0.55 (2021-02-18)\n-----------------\n\nSupport for cross-database SQL queries and built-in support for serving via HTTPS.\n\n- The new ``--crossdb`` command-line option causes Datasette to attach up to ten database files to the same ``/_memory`` database connection. This enables cross-database SQL queries, including the ability to use joins and unions to combine data from tables that exist in different database files. See :ref:`cross_database_queries` for details. (:issue:`283`)\n- ``--ssl-keyfile`` and ``--ssl-certfile`` options can be used to specify a TLS certificate, allowing Datasette to serve traffic over ``https://`` without needing to run it behind a separate proxy. (:issue:`1221`)\n- The ``/:memory:`` page has been renamed (and redirected) to ``/_memory`` for consistency with the new ``/_internal`` database introduced in Datasette 0.54. (:issue:`1205`)\n- Added plugin testing documentation on :ref:`testing_plugins_pdb`. (:issue:`1207`)\n- The `official Datasette Docker image <https://hub.docker.com/r/datasetteproject/datasette>`__ now uses Python 3.7.10, applying `the latest security fix <https://www.python.org/downloads/release/python-3710/>`__ for that Python version. (:issue:`1235`)\n\n.. _v0_54_1:\n\n0.54.1 (2021-02-02)\n-------------------\n\n- Fixed a bug where ``?_search=`` and ``?_sort=`` parameters were incorrectly duplicated when the filter form on the table page was re-submitted. (:issue:`1214`)\n\n.. _v0_54:\n\n0.54 (2021-01-25)\n-----------------\n\nThe two big new features in this release are the ``_internal`` SQLite in-memory database storing details of all connected databases and tables, and support for JavaScript modules in plugins and additional scripts.\n\nFor additional commentary on this release, see `Datasette 0.54, the annotated release notes <https://simonwillison.net/2021/Jan/25/datasette/>`__.\n\nThe _internal database\n~~~~~~~~~~~~~~~~~~~~~~\n\nAs part of ongoing work to help Datasette handle much larger numbers of connected databases and tables (see `Datasette Library <https://github.com/simonw/datasette/issues/417>`__) Datasette now maintains an in-memory SQLite database with details of all of the attached databases, tables, columns, indexes and foreign keys. (:issue:`1150`)\n\nThis will support future improvements such as a searchable, paginated homepage of all available tables.\n\nYou can explore an example of this database by `signing in as root <https://latest.datasette.io/login-as-root>`__ to the ``latest.datasette.io`` demo instance and then navigating to `latest.datasette.io/_internal <https://latest.datasette.io/_internal>`__.\n\nPlugins can use these tables to introspect attached data in an efficient way. Plugin authors should note that this is not yet considered a stable interface, so any plugins that use this may need to make changes prior to Datasette 1.0 if the ``_internal`` table schemas change.\n\nNamed in-memory database support\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nAs part of the work building the ``_internal`` database, Datasette now supports named in-memory databases that can be shared across multiple connections. This allows plugins to create in-memory databases which will persist data for the lifetime of the Datasette server process. (:issue:`1151`)\n\nThe new ``memory_name=`` parameter to the :ref:`internals_database` can be used to create named, shared in-memory databases.\n\nJavaScript modules\n~~~~~~~~~~~~~~~~~~\n\n`JavaScript modules <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules>`__ were introduced in ECMAScript 2015 and provide native browser support for the ``import`` and ``export`` keywords.\n\nTo use modules, JavaScript needs to be included in ``<script>`` tags with a ``type=\"module\"`` attribute.\n\nDatasette now has the ability to output ``<script type=\"module\">`` in places where you may wish to take advantage of modules. The ``extra_js_urls`` option described in :ref:`configuration_reference_css_js` can now be used with modules, and module support is also available for the :ref:`extra_body_script() <plugin_hook_extra_body_script>` plugin hook. (:issue:`1186`, :issue:`1187`)\n\n`datasette-leaflet-freedraw <https://datasette.io/plugins/datasette-leaflet-freedraw>`__ is the first example of a Datasette plugin that takes advantage of the new support for JavaScript modules. See `Drawing shapes on a map to query a SpatiaLite database <https://simonwillison.net/2021/Jan/24/drawing-shapes-spatialite/>`__ for more on this plugin.\n\nCode formatting with Black and Prettier\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette adopted `Black <https://github.com/psf/black>`__ for opinionated Python code formatting in June 2019. Datasette now also embraces `Prettier <https://prettier.io/>`__ for JavaScript formatting, which like Black is enforced by tests in continuous integration. Instructions for using these two tools can be found in the new section on :ref:`contributing_formatting` in the contributors documentation. (:issue:`1167`)\n\nOther changes\n~~~~~~~~~~~~~\n\n- Datasette can now open multiple database files with the same name, e.g. if you run ``datasette path/to/one.db path/to/other/one.db``. (:issue:`509`)\n- ``datasette publish cloudrun`` now sets ``force_https_urls`` for every deployment, fixing some incorrect ``http://`` links. (:issue:`1178`)\n- Fixed a bug in the example nginx configuration in :ref:`deploying_proxy`. (:issue:`1091`)\n- The :ref:`Datasette Ecosystem <ecosystem>` documentation page has been reduced in size in favour of the ``datasette.io`` `tools <https://datasette.io/tools>`__ and `plugins <https://datasette.io/plugins>`__ directories. (:issue:`1182`)\n- The request object now provides a ``request.full_path`` property, which returns the path including any query string. (:issue:`1184`)\n- Better error message for disallowed ``PRAGMA`` clauses in SQL queries. (:issue:`1185`)\n- ``datasette publish heroku`` now deploys using ``python-3.8.7``.\n- New plugin testing documentation on :ref:`testing_plugins_pytest_httpx`. (:issue:`1198`)\n- All ``?_*`` query string parameters passed to the table page are now persisted in hidden form fields, so parameters such as ``?_size=10`` will be correctly passed to the next page when query filters are changed. (:issue:`1194`)\n- Fixed a bug loading a database file called ``test-database (1).sqlite``. (:issue:`1181`)\n\n\n.. _v0_53:\n\n0.53 (2020-12-10)\n-----------------\n\nDatasette has an official project website now, at https://datasette.io/. This release mainly updates the documentation to reflect the new site.\n\n- New ``?column__arraynotcontains=`` table filter. (:issue:`1132`)\n- ``datasette serve`` has a new ``--create`` option, which will create blank database files if they do not already exist rather than exiting with an error. (:issue:`1135`)\n-  New ``?_header=off`` option for CSV export which omits the CSV header row, :ref:`documented here <csv_export_url_parameters>`. (:issue:`1133`)\n- \"Powered by Datasette\" link in the footer now links to https://datasette.io/. (:issue:`1138`)\n- Project news no longer lives in the README - it can now be found at https://datasette.io/news. (:issue:`1137`)\n\n.. _v0_52_5:\n\n0.52.5 (2020-12-09)\n-------------------\n\n- Fix for error caused by combining the ``_searchmode=raw`` and ``?_search_COLUMN`` parameters. (:issue:`1134`)\n\n.. _v0_52_4:\n\n0.52.4 (2020-12-05)\n-------------------\n\n- Show `pysqlite3 <https://github.com/coleifer/pysqlite3>`__ version on ``/-/versions``, if installed. (:issue:`1125`)\n- Errors output by Datasette (e.g. for invalid SQL queries) now go to ``stderr``, not ``stdout``. (:issue:`1131`)\n- Fix for a startup error on windows caused by unnecessary ``from os import EX_CANTCREAT`` - thanks, Abdussamet Koçak.  (:issue:`1094`)\n\n.. _v0_52_3:\n\n0.52.3 (2020-12-03)\n-------------------\n\n- Fixed bug where static assets would 404 for Datasette installed on ARM Amazon Linux. (:issue:`1124`)\n\n.. _v0_52_2:\n\n0.52.2 (2020-12-02)\n-------------------\n\n- Generated columns from SQLite 3.31.0 or higher are now correctly displayed. (:issue:`1116`)\n- Error message if you attempt to open a SpatiaLite database now suggests using ``--load-extension=spatialite`` if it detects that the extension is available in a common location. (:issue:`1115`)\n- ``OPTIONS`` requests against the ``/database`` page no longer raise a 500 error. (:issue:`1100`)\n- Databases larger than 32MB that are published to Cloud Run can now be downloaded. (:issue:`749`)\n- Fix for misaligned cog icon on table and database pages. Thanks, Abdussamet Koçak. (:issue:`1121`)\n\n.. _v0_52_1:\n\n0.52.1 (2020-11-29)\n-------------------\n\n- Documentation on :ref:`testing_plugins` now recommends using :ref:`internals_datasette_client`. (:issue:`1102`)\n- Fix bug where compound foreign keys produced broken links. (:issue:`1098`)\n- ``datasette --load-module=spatialite`` now also checks for ``/usr/local/lib/mod_spatialite.so``. Thanks, Dan Peterson. (:issue:`1114`)\n\n.. _v0_52:\n\n0.52 (2020-11-28)\n-----------------\n\nThis release includes a number of changes relating to an internal rebranding effort: Datasette's **configuration** mechanism (things like ``datasette --config default_page_size:10``) has been renamed to **settings**.\n\n- New ``--setting default_page_size 10`` option as a replacement for ``--config default_page_size:10`` (note the lack of a colon). The ``--config`` option is deprecated but will continue working until Datasette 1.0. (:issue:`992`)\n- The ``/-/config`` introspection page is now ``/-/settings``, and the previous page redirects to the new one. (:issue:`1103`)\n- The ``config.json`` file in :ref:`config_dir` is now called ``settings.json``. (:issue:`1104`)\n- The undocumented ``datasette.config()`` internal method has been replaced by a documented :ref:`datasette_setting` method. (:issue:`1107`)\n\nAlso in this release:\n\n- New plugin hook: :ref:`plugin_hook_database_actions`, which adds menu items to a new cog menu shown at the top of the database page. (:issue:`1077`)\n- ``datasette publish cloudrun`` has a new ``--apt-get-install`` option that can be used to install additional Ubuntu packages as part of the deployment. This is useful for deploying the new `datasette-ripgrep plugin <https://github.com/simonw/datasette-ripgrep>`__. (:issue:`1110`)\n- Swept the documentation to remove words that minimize involved difficulty. (:issue:`1089`)\n\nAnd some bug fixes:\n\n- Foreign keys linking to rows with blank label columns now display as a hyphen, allowing those links to be clicked. (:issue:`1086`)\n- Fixed bug where row pages could sometimes 500 if the underlying queries exceeded a time limit. (:issue:`1088`)\n- Fixed a bug where the table action menu could appear partially obscured by the edge of the page. (:issue:`1084`)\n\n.. _v0_51_1:\n\n0.51.1 (2020-10-31)\n-------------------\n\n- Improvements to the new :ref:`binary` documentation page.\n\n.. _v0_51:\n\n0.51 (2020-10-31)\n-----------------\n\nA new visual design, plugin hooks for adding navigation options, better handling of binary data, URL building utility methods and better support for running Datasette behind a proxy.\n\nNew visual design\n~~~~~~~~~~~~~~~~~\n\nDatasette is no longer white and grey with blue and purple links! `Natalie Downe <https://twitter.com/natbat>`__ has been working on a visual refresh, the first iteration of which is included in this release. (`#1056 <https://github.com/simonw/datasette/pull/1056>`__)\n\n.. image:: datasette-0.51.png\n   :width: 740px\n   :alt: Screenshot showing Datasette's new visual look\n\nPlugins can now add links within Datasette\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nA number of existing Datasette plugins add new pages to the Datasette interface, providig tools for things like `uploading CSVs <https://github.com/simonw/datasette-upload-csvs>`__, `editing table schemas <https://github.com/simonw/datasette-edit-schema>`__ or `configuring full-text search <https://github.com/simonw/datasette-configure-fts>`__.\n\nPlugins like this can now link to themselves from other parts of Datasette interface. The :ref:`plugin_hook_menu_links` hook (:issue:`1064`) lets plugins add links to Datasette's new top-right application menu, and the :ref:`plugin_hook_table_actions` hook (:issue:`1066`) adds links to a new \"table actions\" menu on the table page.\n\nThe demo at `latest.datasette.io <https://latest.datasette.io/>`__ now includes some example plugins. To see the new table actions menu first `sign into that demo as root <https://latest.datasette.io/login-as-root>`__ and then visit the `facetable <https://latest.datasette.io/fixtures/facetable>`__ table to see the new cog icon menu at the top of the page.\n\nBinary data\n~~~~~~~~~~~\n\nSQLite tables can contain binary data in ``BLOB`` columns. Datasette now provides links for users to download this data directly from Datasette, and uses those links to make binary data available from CSV exports. See :ref:`binary` for more details. (:issue:`1036` and :issue:`1034`).\n\nURL building\n~~~~~~~~~~~~\n\nThe new :ref:`internals_datasette_urls` family of methods can be used to generate URLs to key pages within the Datasette interface, both within custom templates and Datasette plugins. See :ref:`writing_plugins_building_urls` for more details. (:issue:`904`)\n\nRunning Datasette behind a proxy\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`setting_base_url` configuration option is designed to help run Datasette on a specific path behind a proxy - for example if you want to run an instance of Datasette at ``/my-datasette/`` within your existing site's URL hierarchy, proxied behind nginx or Apache.\n\nSupport for this configuration option has been greatly improved (:issue:`1023`), and guidelines for using it are now available in a new documentation section on :ref:`deploying_proxy`. (:issue:`1027`)\n\nSmaller changes\n~~~~~~~~~~~~~~~\n\n- Wide tables shown within Datasette now scroll horizontally (:issue:`998`). This is achieved using a new ``<div class=\"table-wrapper\">`` element which may impact the implementation of some plugins (for example `this change to datasette-cluster-map <https://github.com/simonw/datasette-cluster-map/commit/fcb4abbe7df9071c5ab57defd39147de7145b34e>`__).\n- New :ref:`actions_debug_menu` permission. (:issue:`1068`)\n- Removed ``--debug`` option, which didn't do anything. (:issue:`814`)\n- ``Link:`` HTTP header pagination. (:issue:`1014`)\n- ``x`` button for clearing filters. (:issue:`1016`)\n- Edit SQL button on canned queries, (:issue:`1019`)\n- ``--load-extension=spatialite`` shortcut. (:issue:`1028`)\n- scale-in animation for column action menu. (:issue:`1039`)\n- Option to pass a list of templates to ``.render_template()`` is now documented. (:issue:`1045`)\n- New ``datasette.urls.static_plugins()`` method. (:issue:`1033`)\n- ``datasette -o`` option now opens the most relevant page. (:issue:`976`)\n- ``datasette --cors`` option now enables access to ``/database.db`` downloads. (:issue:`1057`)\n- Database file downloads now implement cascading permissions, so you can download a database if you have ``view-database-download`` permission even if you do not have permission to access the Datasette instance. (:issue:`1058`)\n- New documentation on :ref:`writing_plugins_designing_urls`. (:issue:`1053`)\n\n.. _v0_50_2:\n\n0.50.2 (2020-10-09)\n-------------------\n\n- Fixed another bug introduced in 0.50 where column header links on the table page were broken. (:issue:`1011`)\n\n.. _v0_50_1:\n\n0.50.1 (2020-10-09)\n-------------------\n\n- Fixed a bug introduced in 0.50 where the export as JSON/CSV links on the table, row and query pages were broken. (:issue:`1010`)\n\n.. _v0_50:\n\n0.50 (2020-10-09)\n-----------------\n\nThe key new feature in this release is the **column actions** menu on the table page (:issue:`891`). This can be used to sort a column in ascending or descending order, facet data by that column or filter the table to just rows that have a value for that column.\n\nPlugin authors can use the new :ref:`internals_datasette_client` object to make internal HTTP requests from their plugins, allowing them to make use of Datasette's JSON API. (:issue:`943`)\n\nNew :ref:`deploying` documentation with guides for deploying Datasette on a Linux server :ref:`using systemd <deploying_systemd>` or to hosting providers :ref:`that support buildpacks <deploying_buildpacks>`. (:issue:`514`, :issue:`997`)\n\nOther improvements in this release:\n\n- :ref:`publish_cloud_run` documentation now covers Google Cloud SDK options. Thanks, Geoffrey Hing. (`#995 <https://github.com/simonw/datasette/pull/995>`__)\n- New ``datasette -o`` option which opens your browser as soon as Datasette starts up. (:issue:`970`)\n- Datasette now sets ``sqlite3.enable_callback_tracebacks(True)`` so that errors in custom SQL functions will display tracebacks. (:issue:`891`)\n- Fixed two rendering bugs with column headers in portrait mobile view. (:issue:`978`, :issue:`980`)\n- New ``db.table_column_details(table)`` introspection method for retrieving full details of the columns in a specific table, see :ref:`internals_database_introspection`.\n- Fixed a routing bug with custom page wildcard templates. (:issue:`996`)\n- ``datasette publish heroku`` now deploys using Python 3.8.6.\n- New ``datasette publish heroku --tar=`` option. (:issue:`969`)\n- ``OPTIONS`` requests against HTML pages no longer return a 500 error. (:issue:`1001`)\n- Datasette now supports Python 3.9.\n\nSee also `Datasette 0.50: The annotated release notes <https://simonwillison.net/2020/Oct/9/datasette-0-50/>`__.\n\n.. _v0_49_1:\n\n0.49.1 (2020-09-15)\n-------------------\n\n- Fixed a bug with writable canned queries that use magic parameters but accept no non-magic arguments. (:issue:`967`)\n\n.. _v0_49:\n\n0.49 (2020-09-14)\n-----------------\n\nSee also `Datasette 0.49: The annotated release notes <https://simonwillison.net/2020/Sep/15/datasette-0-49/>`__.\n\n- Writable canned queries now expose a JSON API, see :ref:`canned_queries_json_api`. (:issue:`880`)\n- New mechanism for defining page templates with custom path parameters - a template file called ``pages/about/{slug}.html`` will be used to render any requests to ``/about/something``. See :ref:`custom_pages_parameters`. (:issue:`944`)\n- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)\n- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)\n- New ``datasette --pdb`` option. (:issue:`962`)\n- ``datasette --get`` exit code now reflects the internal HTTP status code. (:issue:`947`)\n- New ``raise_404()`` template function for returning 404 errors. (:issue:`964`)\n- ``datasette publish heroku`` now deploys using Python 3.8.5\n- Upgraded `CodeMirror <https://codemirror.net/>`__ to 5.57.0. (:issue:`948`)\n- Upgraded code style to Black 20.8b1. (:issue:`958`)\n- Fixed bug where selected facets were not correctly persisted in hidden form fields on the table page. (:issue:`963`)\n- Renamed the default error template from ``500.html`` to ``error.html``.\n- Custom error pages are now documented, see :ref:`custom_pages_errors`. (:issue:`965`)\n\n.. _v0_48:\n\n0.48 (2020-08-16)\n-----------------\n\n- Datasette documentation now lives at `docs.datasette.io <https://docs.datasette.io/>`__.\n- ``db.is_mutable`` property is now documented and tested, see :ref:`internals_database_introspection`.\n- The ``extra_template_vars``, ``extra_css_urls``, ``extra_js_urls`` and ``extra_body_script`` plugin hooks now all accept the same arguments. See :ref:`plugin_hook_extra_template_vars` for details. (:issue:`939`)\n- Those hooks now accept a new ``columns`` argument detailing the table columns that will be rendered on that page. (:issue:`938`)\n- Fixed bug where plugins calling ``db.execute_write_fn()`` could hang Datasette if the connection failed. (:issue:`935`)\n- Fixed bug with the ``?_nl=on`` output option and binary data. (:issue:`914`)\n\n.. _v0_47_3:\n\n0.47.3 (2020-08-15)\n-------------------\n\n- The ``datasette --get`` command-line mechanism now ensures any plugins using the ``startup()`` hook are correctly executed. (:issue:`934`)\n\n.. _v0_47_2:\n\n0.47.2 (2020-08-12)\n-------------------\n\n- Fixed an issue with the Docker image `published to Docker Hub <https://hub.docker.com/r/datasetteproject/datasette>`__. (:issue:`931`)\n\n.. _v0_47_1:\n\n0.47.1 (2020-08-11)\n-------------------\n\n- Fixed a bug where the ``sdist`` distribution of Datasette was not correctly including the template files. (:issue:`930`)\n\n.. _v0_47:\n\n0.47 (2020-08-11)\n-----------------\n\n- Datasette now has `a GitHub discussions forum <https://github.com/simonw/datasette/discussions>`__ for conversations about the project that go beyond just bug reports and issues.\n- Datasette can now be installed on macOS using Homebrew! Run ``brew install simonw/datasette/datasette``. See :ref:`installation_homebrew`. (:issue:`335`)\n- Two new commands: ``datasette install name-of-plugin`` and ``datasette uninstall name-of-plugin``. These are equivalent to ``pip install`` and ``pip uninstall`` but automatically run in the same virtual environment as Datasette, so users don't have to figure out where that virtual environment is - useful for installations created using Homebrew or ``pipx``. See :ref:`plugins_installing`. (:issue:`925`)\n- A new command-line option, ``datasette --get``, accepts a path to a URL within the Datasette instance. It will run that request through Datasette (without starting a web server) and print out the response. See :ref:`cli_datasette_get` for an example. (:issue:`926`)\n\n.. _v0_46:\n\n0.46 (2020-08-09)\n-----------------\n\n.. warning::\n    This release contains a security fix related to authenticated writable canned queries. If you are using this feature you should upgrade as soon as possible.\n\n- **Security fix:** CSRF tokens were incorrectly included in read-only canned query forms, which could allow them to be leaked to a sophisticated attacker. See `issue 918 <https://github.com/simonw/datasette/issues/918>`__ for details.\n- Datasette now supports GraphQL via the new `datasette-graphql <https://github.com/simonw/datasette-graphql>`__ plugin - see `GraphQL in Datasette with the new datasette-graphql plugin <https://simonwillison.net/2020/Aug/7/datasette-graphql/>`__.\n- Principle git branch has been renamed from ``master`` to ``main``. (:issue:`849`)\n- New debugging tool: ``/-/allow-debug tool`` (`demo here <https://latest.datasette.io/-/allow-debug>`__) helps test allow blocks against actors, as described in :ref:`authentication_permissions_allow`. (:issue:`908`)\n- New logo for the documentation, and a new project tagline: \"An open source multi-tool for exploring and publishing data\".\n- Whitespace in column values is now respected on display, using ``white-space: pre-wrap``. (:issue:`896`)\n- New ``await request.post_body()`` method for accessing the raw POST body, see :ref:`internals_request`. (:issue:`897`)\n- Database file downloads now include a ``content-length`` HTTP header, enabling download progress bars. (:issue:`905`)\n- File downloads now also correctly set the suggested file name using a ``content-disposition`` HTTP header. (:issue:`909`)\n- ``tests`` are now excluded from the Datasette package properly - thanks, abeyerpath. (:issue:`456`)\n- The Datasette package published to PyPI now includes ``sdist`` as well as ``bdist_wheel``.\n- Better titles for canned query pages. (:issue:`887`)\n- Now only loads Python files from a directory passed using the ``--plugins-dir`` option - thanks, Amjith Ramanujam. (`#890 <https://github.com/simonw/datasette/pull/890>`__)\n- New documentation section on :ref:`publish_vercel`.\n\n.. _v0_45:\n\n0.45 (2020-07-01)\n-----------------\n\nSee also `Datasette 0.45: The annotated release notes <https://simonwillison.net/2020/Jul/1/datasette-045/>`__.\n\nMagic parameters for canned queries, a log out feature, improved plugin documentation and four new plugin hooks.\n\nMagic parameters for canned queries\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nCanned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::\n\n    insert into logs\n      (user_id, timestamp)\n    values\n      (:_actor_id, :_now_datetime_utc)\n\nThis inserts the currently authenticated actor ID and the current datetime. (:issue:`842`)\n\nLog out\n~~~~~~~\n\nThe :ref:`ds_actor cookie <authentication_ds_actor>` can be used by plugins (or by Datasette's :ref:`--root mechanism<authentication_root>`) to authenticate users. The new ``/-/logout`` page provides a way to clear that cookie.\n\nA \"Log out\" button now shows in the global navigation provided the user is authenticated using the ``ds_actor`` cookie. (:issue:`840`)\n\nBetter plugin documentation\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe plugin documentation has been re-arranged into four sections, including a brand new section on testing plugins. (:issue:`687`)\n\n- :ref:`plugins` introduces Datasette's plugin system and describes how to install and configure plugins.\n- :ref:`writing_plugins` describes how to author plugins, from  one-off single file plugins to packaged plugins that can be published to PyPI. It also describes how to start a plugin using the new `datasette-plugin <https://github.com/simonw/datasette-plugin>`__ cookiecutter template.\n- :ref:`plugin_hooks` is a full list of detailed documentation for every Datasette plugin hook.\n- :ref:`testing_plugins` describes how to write tests for Datasette plugins, using `pytest <https://docs.pytest.org/>`__ and `HTTPX <https://www.python-httpx.org/>`__.\n\nNew plugin hooks\n~~~~~~~~~~~~~~~~\n\n- :ref:`plugin_hook_register_magic_parameters` can be used to define new types of magic canned query parameters.\n- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)\n- :ref:`plugin_hook_canned_queries` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)\n- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)\n\nSmaller changes\n~~~~~~~~~~~~~~~\n\n- Cascading view permissions - so if a user has ``view-table`` they can view the table page even if they do not have ``view-database`` or ``view-instance``. (:issue:`832`)\n- CSRF protection no longer applies to ``Authentication: Bearer token`` requests or requests without cookies. (:issue:`835`)\n- ``datasette.add_message()`` now works inside plugins. (:issue:`864`)\n- Workaround for \"Too many open files\" error in test runs. (:issue:`846`)\n- Respect existing ``scope[\"actor\"]`` if already set by ASGI middleware. (:issue:`854`)\n- New process for shipping :ref:`contributing_alpha_beta`. (:issue:`807`)\n- ``{{ csrftoken() }}`` now works when plugins render a template using ``datasette.render_template(..., request=request)``. (:issue:`863`)\n- Datasette now creates a single :ref:`internals_request` and uses it throughout the lifetime of the current HTTP request. (:issue:`870`)\n\n.. _v0_44:\n\n0.44 (2020-06-11)\n-----------------\n\nSee also `Datasette 0.44: The annotated release notes <https://simonwillison.net/2020/Jun/12/annotated-release-notes/>`__.\n\nAuthentication and permissions, writable canned queries, flash messages, new plugin hooks and more.\n\nAuthentication\n~~~~~~~~~~~~~~\n\nPrior to this release the Datasette ecosystem has treated authentication as exclusively the realm of plugins, most notably through `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__.\n\n0.44 introduces :ref:`authentication` as core Datasette concepts (:issue:`699`). This enables different plugins to share responsibility for authenticating requests - you might have one plugin that handles user accounts and another one that allows automated access via API keys, for example.\n\nYou'll need to install plugins if you want full user accounts, but default Datasette can now authenticate a single root user with the new ``--root`` command-line option, which outputs a one-time use URL to :ref:`authenticate as a root actor <authentication_root>` (:issue:`784`)::\n\n    datasette fixtures.db --root\n\n::\n\n    http://127.0.0.1:8001/-/auth-token?token=5b632f8cd44b868df625f5a6e2185d88eea5b22237fd3cc8773f107cc4fd6477\n    INFO:     Started server process [14973]\n    INFO:     Waiting for application startup.\n    INFO:     Application startup complete.\n    INFO:     Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)\n\nPlugins can implement new ways of authenticating users using the new :ref:`plugin_hook_actor_from_request` hook.\n\nPermissions\n~~~~~~~~~~~\n\nDatasette also now has a built-in concept of :ref:`authentication_permissions`. The permissions system answers the following question:\n\n    Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?\n\nYou can use the new ``\"allow\"`` block syntax in ``metadata.json`` (or ``metadata.yaml``) to set required permissions at the instance, database, table or canned query level. For example, to restrict access to the ``fixtures.db`` database to the ``\"root\"`` user:\n\n.. code-block:: json\n\n    {\n        \"databases\": {\n            \"fixtures\": {\n                \"allow\": {\n                    \"id\" \"root\"\n                }\n            }\n        }\n    }\n\nSee :ref:`authentication_permissions_allow` for more details.\n\nPlugins can implement their own custom permission checks using the new ``plugin_hook_permission_allowed()`` plugin hook.\n\nA new debug page at ``/-/permissions`` shows recent permission checks, to help administrators and plugin authors understand exactly what checks are being performed. This tool defaults to only being available to the root user, but can be exposed to other users by plugins that respond to the ``permissions-debug`` permission. (:issue:`788`)\n\nWritable canned queries\n~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.\n\nCanned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``\"write\": true`` property (:issue:`800`):\n\n.. code-block:: json\n\n    {\n        \"databases\": {\n            \"dogs\": {\n                \"queries\": {\n                    \"add_name\": {\n                        \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                        \"write\": true\n                    }\n                }\n            }\n        }\n    }\n\nSee :ref:`canned_queries_writable` for more details.\n\nFlash messages\n~~~~~~~~~~~~~~\n\nWritable canned queries needed a mechanism to let the user know that the query has been successfully executed. The new flash messaging system (:issue:`790`) allows messages to persist in signed cookies which are then displayed to the user on the next page that they visit. Plugins can use this mechanism to display their own messages, see :ref:`datasette_add_message` for details.\n\nYou can try out the new messages using the ``/-/messages`` debug tool, for example at https://latest.datasette.io/-/messages\n\nSigned values and secrets\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nBoth flash messages and user authentication needed a way to sign values and set signed cookies. Two new methods are now available for plugins to take advantage of this mechanism: :ref:`datasette_sign` and :ref:`datasette_unsign`.\n\nDatasette will generate a secret automatically when it starts up, but to avoid resetting the secret (and hence invalidating any cookies) every time the server restarts you should set your own secret. You can pass a secret to Datasette using the new ``--secret`` option or with a ``DATASETTE_SECRET`` environment variable. See :ref:`setting_secret` for more details.\n\nYou can also set a secret when you deploy Datasette using ``datasette publish`` or ``datasette package`` - see :ref:`setting_publish_secrets`.\n\nPlugins can now sign values and verify their signatures using the :ref:`datasette.sign() <datasette_sign>` and :ref:`datasette.unsign() <datasette_unsign>` methods.\n\nCSRF protection\n~~~~~~~~~~~~~~~\n\nSince writable canned queries are built using POST forms, Datasette now ships with :ref:`internals_csrf` (:issue:`798`). This applies automatically to any POST request, which means plugins need to include a ``csrftoken`` in any POST forms that they render. They can do that like so:\n\n.. code-block:: html\n\n    <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n\nCookie methods\n~~~~~~~~~~~~~~\n\nPlugins can now use the new :ref:`response.set_cookie() <internals_response_set_cookie>` method to set cookies.\n\nA new ``request.cookies`` method on the :ref:internals_request` can be used to read incoming cookies.\n\nregister_routes() plugin hooks\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nPlugins can now register new views and routes via the :ref:`plugin_register_routes` plugin hook (:issue:`819`). View functions can be defined that accept any of the current ``datasette`` object, the current ``request``, or the ASGI ``scope``, ``send`` and ``receive`` objects.\n\nSmaller changes\n~~~~~~~~~~~~~~~\n\n- New internals documentation for :ref:`internals_request` and :ref:`internals_response`. (:issue:`706`)\n- ``request.url`` now respects the ``force_https_urls`` config setting. closes (:issue:`781`)\n- ``request.args.getlist()`` returns ``[]`` if missing. Removed ``request.raw_args`` entirely. (:issue:`774`)\n- New :ref:`datasette.get_database() <datasette_get_database>` method.\n- Added ``_`` prefix to many private, undocumented methods of the Datasette class. (:issue:`576`)\n- Removed the ``db.get_outbound_foreign_keys()`` method which duplicated the behaviour of ``db.foreign_keys_for_table()``.\n- New ``await datasette.permission_allowed()`` method.\n- ``/-/actor`` debugging endpoint for viewing the currently authenticated actor.\n- New ``request.cookies`` property.\n- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1\n- ``request.post_vars()`` method no longer discards empty values.\n- New \"params\" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)\n- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.\n- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)\n- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)\n- New ``request.actor`` property.\n- Fixed broken CSS on nested 404 pages. (:issue:`777`)\n- New ``request.url_vars`` property. (:issue:`822`)\n- Fixed a bug with the ``python tests/fixtures.py`` command for outputting Datasette's testing fixtures database and plugins. (:issue:`804`)\n- ``datasette publish heroku`` now deploys using Python 3.8.3.\n- Added a warning that the :ref:`plugin_register_facet_classes` hook is unstable and may change in the future. (:issue:`830`)\n- The ``{\"$env\": \"ENVIRONMENT_VARIBALE\"}`` mechanism (see :ref:`plugins_configuration_secret`) now works with variables inside nested lists. (:issue:`837`)\n\nThe road to Datasette 1.0\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nI've assembled a `milestone for Datasette 1.0 <https://github.com/simonw/datasette/milestone/7>`__. The focus of the 1.0 release will be the following:\n\n- Signify confidence in the quality/stability of Datasette\n- Give plugin authors confidence that their plugins will work for the whole 1.x release cycle\n- Provide the same confidence to developers building against Datasette JSON APIs\n\nIf you have thoughts about what you would like to see for Datasette 1.0 you can join `the conversation on issue #519 <https://github.com/simonw/datasette/issues/519>`__.\n\n.. _v0_43:\n\n0.43 (2020-05-28)\n-----------------\n\nThe main focus of this release is a major upgrade to the :ref:`plugin_register_output_renderer` plugin hook, which allows plugins to provide new output formats for Datasette such as `datasette-atom <https://github.com/simonw/datasette-atom>`__ and `datasette-ics <https://github.com/simonw/datasette-ics>`__.\n\n* Redesign of :ref:`plugin_register_output_renderer` to provide more context to the render callback and support an optional ``\"can_render\"`` callback that controls if a suggested link to the output format is provided. (:issue:`581`, :issue:`770`)\n* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)\n* The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)\n* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)\n* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`canned_queries_options`. (:issue:`706`)\n* Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)\n* Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)\n\n.. _v0_42:\n\n0.42 (2020-05-08)\n-----------------\n\nA small release which provides improved internal methods for use in plugins, along with documentation. See :issue:`685`.\n\n* Added documentation for ``db.execute()``, see :ref:`database_execute`.\n* Renamed ``db.execute_against_connection_in_thread()`` to ``db.execute_fn()`` and made it a documented method, see :ref:`database_execute_fn`.\n* New ``results.first()`` and ``results.single_value()`` methods, plus documentation for the ``Results`` class - see :ref:`database_results`.\n\n.. _v0_41:\n\n0.41 (2020-05-06)\n-----------------\n\nYou can now create :ref:`custom pages <custom_pages>` within your Datasette instance using a custom template file. For example, adding a template file called ``templates/pages/about.html`` will result in a new page being served at ``/about`` on your instance. See the :ref:`custom pages documentation <custom_pages>` for full details, including how to return custom HTTP headers, redirects and status codes. (:issue:`648`)\n\n:ref:`config_dir` (:issue:`731`) allows you to define a custom Datasette instance as a directory. So instead of running the following::\n\n    datasette one.db two.db \\\n      --metadata=metadata.json \\\n      --template-dir=templates/ \\\n      --plugins-dir=plugins \\\n      --static css:css\n\nYou can instead arrange your files in a single directory called ``my-project`` and run this::\n\n    datasette my-project/\n\nAlso in this release:\n\n* New ``NOT LIKE`` table filter: ``?colname__notlike=expression``. (:issue:`750`)\n* Datasette now has a *pattern portfolio* at ``/-/patterns`` - e.g. https://latest.datasette.io/-/patterns. This is a page that shows every Datasette user interface component in one place, to aid core development and people building custom CSS themes. (:issue:`151`)\n* SQLite `PRAGMA functions <https://www.sqlite.org/pragma.html#pragfunc>`__ such as ``pragma_table_info(tablename)`` are now allowed in Datasette SQL queries. (:issue:`761`)\n* Datasette pages now consistently return a ``content-type`` of ``text/html; charset=utf-8\"``. (:issue:`752`)\n* Datasette now handles an ASGI ``raw_path`` value of ``None``, which should allow compatibility with the `Mangum <https://github.com/erm/mangum>`__ adapter for running ASGI apps on AWS Lambda. Thanks, Colin Dellow. (`#719 <https://github.com/simonw/datasette/pull/719>`__)\n* Installation documentation now covers how to :ref:`installation_pipx`. (:issue:`756`)\n* Improved the documentation for :ref:`full_text_search`. (:issue:`748`)\n\n.. _v0_40:\n\n0.40 (2020-04-21)\n-----------------\n\n* Datasette :ref:`metadata` can now be provided as a YAML file as an optional alternative to JSON. (:issue:`713`)\n* Removed support for ``datasette publish now``, which used the the now-retired Zeit Now v1 hosting platform. A new plugin, `datasette-publish-now <https://github.com/simonw/datasette-publish-now>`__, can be installed to publish data to Zeit (`now Vercel <https://vercel.com/blog/zeit-is-now-vercel>`__) Now v2. (:issue:`710`)\n* Fixed a bug where the ``extra_template_vars(request, view_name)`` plugin hook was not receiving the correct ``view_name``. (:issue:`716`)\n* Variables added to the template context by the ``extra_template_vars()`` plugin hook are now shown in the ``?_context=1`` debugging mode (see :ref:`setting_template_debug`). (:issue:`693`)\n* Fixed a bug where the \"templates considered\" HTML comment was no longer being displayed. (:issue:`689`)\n* Fixed a ``datasette publish`` bug where ``--plugin-secret`` would over-ride plugin configuration in the provided ``metadata.json`` file. (:issue:`724`)\n* Added a new CSS class for customizing the canned query page. (:issue:`727`)\n\n.. _v0_39:\n\n0.39 (2020-03-24)\n-----------------\n\n* New :ref:`setting_base_url` configuration setting for serving up the correct links while running Datasette under a different URL prefix. (:issue:`394`)\n* New metadata settings ``\"sort\"`` and ``\"sort_desc\"`` for setting the default sort order for a table. See :ref:`table_configuration_sort`. (:issue:`702`)\n* Sort direction arrow now displays by default on the primary key. This means you only have to click once (not twice) to sort in reverse order. (:issue:`677`)\n* New ``await Request(scope, receive).post_vars()`` method for accessing POST form variables. (:issue:`700`)\n* :ref:`plugin_hooks` documentation now links to example uses of each plugin. (:issue:`709`)\n\n.. _v0_38:\n\n0.38 (2020-03-08)\n-----------------\n\n* The `Docker build <https://hub.docker.com/r/datasetteproject/datasette>`__ of Datasette now uses SQLite 3.31.1, upgraded from 3.26. (:issue:`695`)\n* ``datasette publish cloudrun`` now accepts an optional ``--memory=2Gi`` flag for setting the Cloud Run allocated memory to a value other than the default (256Mi). (:issue:`694`)\n* Fixed bug where templates that shipped with plugins were sometimes not being correctly loaded. (:issue:`697`)\n\n.. _v0_37_1:\n\n0.37.1 (2020-03-02)\n-------------------\n\n* Don't attempt to count table rows to display on the index page for databases > 100MB. (:issue:`688`)\n* Print exceptions if they occur in the write thread rather than silently swallowing them.\n* Handle the possibility of ``scope[\"path\"]`` being a string rather than bytes\n* Better documentation for the :ref:`plugin_hook_extra_template_vars` plugin hook.\n\n.. _v0_37:\n\n0.37 (2020-02-25)\n-----------------\n\n* Plugins now have a supported mechanism for writing to a database, using the new ``.execute_write()`` and ``.execute_write_fn()`` methods. :ref:`Documentation <database_execute_write>`. (:issue:`682`)\n* Immutable databases that have had their rows counted using the ``inspect`` command now use the calculated count more effectively - thanks, Kevin Keogh. (`#666 <https://github.com/simonw/datasette/pull/666>`__)\n* ``--reload`` no longer restarts the server if a database file is modified, unless that database was opened immutable mode with ``-i``. (:issue:`494`)\n* New ``?_searchmode=raw`` option turns off escaping for FTS queries in ``?_search=`` allowing full use of SQLite's `FTS5 query syntax <https://www.sqlite.org/fts5.html#full_text_query_syntax>`__. (:issue:`676`)\n\n.. _v0_36:\n\n0.36 (2020-02-21)\n-----------------\n\n* The ``datasette`` object passed to plugins now has API documentation: :ref:`internals_datasette`. (:issue:`576`)\n* New methods on ``datasette``: ``.add_database()`` and ``.remove_database()`` - :ref:`documentation <datasette_add_database>`. (:issue:`671`)\n* ``prepare_connection()`` plugin hook now takes optional ``datasette`` and ``database`` arguments - :ref:`plugin_hook_prepare_connection`. (:issue:`678`)\n* Added three new plugins and one new conversion tool to the :ref:`ecosystem`.\n\n.. _v0_35:\n\n0.35 (2020-02-04)\n-----------------\n\n* Added five new plugins and one new conversion tool to the :ref:`ecosystem`.\n* The ``Datasette`` class has a new ``render_template()`` method which can be used by plugins to render templates using Datasette's pre-configured `Jinja <https://jinja.palletsprojects.com/>`__ templating library.\n* You can now execute SQL queries that start with a ``-- comment`` - thanks, Jay Graves (`#653 <https://github.com/simonw/datasette/pull/653>`__)\n\n.. _v0_34:\n\n0.34 (2020-01-29)\n-----------------\n\n* ``_search=`` queries are now correctly escaped using a new ``escape_fts()`` custom SQL function. This means you can now run searches for strings like ``park.`` without seeing errors. (:issue:`651`)\n* `Google Cloud Run <https://cloud.google.com/run/>`__ is no longer in beta, so ``datasette publish cloudrun`` has been updated to work even if the user has not installed the ``gcloud`` beta components package. Thanks, Katie McLaughlin (`#660 <https://github.com/simonw/datasette/pull/660>`__)\n* ``datasette package`` now accepts a ``--port`` option for specifying which port the resulting Docker container should listen on. (:issue:`661`)\n\n.. _v0_33:\n\n0.33 (2019-12-22)\n-----------------\n\n* ``rowid`` is now included in dropdown menus for filtering tables (:issue:`636`)\n* Columns are now only suggested for faceting if they have at least one value with more than one record (:issue:`638`)\n* Queries with no results now display \"0 results\" (:issue:`637`)\n* Improved documentation for the ``--static`` option (:issue:`641`)\n* asyncio task information is now included on the ``/-/threads`` debug page\n* Bumped Uvicorn dependency 0.11\n* You can now use ``--port 0`` to listen on an available port\n* New :ref:`setting_template_debug` setting for debugging templates, e.g. https://latest.datasette.io/fixtures/roadside_attractions?_context=1 (:issue:`654`)\n\n.. _v0_32:\n\n0.32 (2019-11-14)\n-----------------\n\nDatasette now renders templates using `Jinja async mode <https://jinja.palletsprojects.com/en/2.10.x/api/#async-support>`__. This means plugins can provide custom template functions that perform asynchronous actions, for example the new `datasette-template-sql <https://github.com/simonw/datasette-template-sql>`__ plugin which allows custom templates to directly execute SQL queries and render their results. (:issue:`628`)\n\n.. _v0_31_2:\n\n0.31.2 (2019-11-13)\n-------------------\n\n- Fixed a bug where ``datasette publish heroku`` applications failed to start (:issue:`633`)\n- Fix for ``datasette publish`` with just ``--source_url`` - thanks, Stanley Zheng (:issue:`572`)\n- Deployments to Heroku now use Python 3.8.0 (:issue:`632`)\n\n.. _v0_31_1:\n\n0.31.1 (2019-11-12)\n-------------------\n\n- Deployments created using ``datasette publish``  now use ``python:3.8`` base Docker image (`#629 <https://github.com/simonw/datasette/pull/629>`__)\n\n.. _v0_31:\n\n0.31 (2019-11-11)\n-----------------\n\nThis version adds compatibility with Python 3.8 and breaks compatibility with Python 3.5.\n\nIf you are still running Python 3.5 you should stick with ``0.30.2``, which you can install like this::\n\n    pip install datasette==0.30.2\n\n- Format SQL button now works with read-only SQL queries - thanks, Tobias Kunze (`#602 <https://github.com/simonw/datasette/pull/602>`__)\n- New ``?column__notin=x,y,z`` filter for table views (:issue:`614`)\n- Table view now uses ``select col1, col2, col3`` instead of ``select *``\n- Database filenames can now contain spaces - thanks, Tobias Kunze (`#590 <https://github.com/simonw/datasette/pull/590>`__)\n- Removed obsolete ``?_group_count=col`` feature (:issue:`504`)\n- Improved user interface and documentation for ``datasette publish cloudrun`` (:issue:`608`)\n- Tables with indexes now show the ``CREATE INDEX`` statements on the table page (:issue:`618`)\n- Current version of `uvicorn <https://www.uvicorn.org/>`__ is now shown on ``/-/versions``\n- Python 3.8 is now supported! (:issue:`622`)\n- Python 3.5 is no longer supported.\n\n.. _v0_30_2:\n\n0.30.2 (2019-11-02)\n-------------------\n\n- ``/-/plugins`` page now uses distribution name e.g. ``datasette-cluster-map`` instead of the name of the underlying Python package (``datasette_cluster_map``) (:issue:`606`)\n- Array faceting is now only suggested for columns that contain arrays of strings (:issue:`562`)\n- Better documentation for the ``--host`` argument (:issue:`574`)\n- Don't show ``None`` with a broken link for the label on a nullable foreign key (:issue:`406`)\n\n.. _v0_30_1:\n\n0.30.1 (2019-10-30)\n-------------------\n\n- Fixed bug where ``?_where=`` parameter was not persisted in hidden form fields (:issue:`604`)\n- Fixed bug with .JSON representation of row pages - thanks, Chris Shaw (:issue:`603`)\n\n.. _v0_30:\n\n\n0.30 (2019-10-18)\n-----------------\n\n- Added ``/-/threads`` debugging page\n- Allow ``EXPLAIN WITH...`` (:issue:`583`)\n- Button to format SQL - thanks, Tobias Kunze (:issue:`136`)\n- Sort databases on homepage by argument order - thanks, Tobias Kunze (:issue:`585`)\n- Display metadata footer on custom SQL queries - thanks, Tobias Kunze (`#589 <https://github.com/simonw/datasette/pull/589>`__)\n- Use ``--platform=managed`` for ``publish cloudrun`` (:issue:`587`)\n- Fixed bug returning non-ASCII characters in CSV (:issue:`584`)\n- Fix for ``/foo`` v.s. ``/foo-bar`` bug (:issue:`601`)\n\n.. _v0_29_3:\n\n0.29.3 (2019-09-02)\n-------------------\n\n- Fixed implementation of CodeMirror on database page (:issue:`560`)\n- Documentation typo fixes - thanks, Min ho Kim (`#561 <https://github.com/simonw/datasette/pull/561>`__)\n- Mechanism for detecting if a table has FTS enabled now works if the table name used alternative escaping mechanisms (:issue:`570`) - for compatibility with `a recent change to sqlite-utils <https://github.com/simonw/sqlite-utils/pull/57>`__.\n\n.. _v0_29_2:\n\n0.29.2 (2019-07-13)\n-------------------\n\n- 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`)\n- Fixed bug where the navigation breadcrumbs were not displayed correctly on the page for a custom query. (:issue:`558`)\n- Fixed bug where custom query names containing unicode characters caused errors.\n\n.. _v0_29_1:\n\n0.29.1 (2019-07-11)\n-------------------\n\n- Fixed bug with static mounts using relative paths which could lead to traversal exploits (:issue:`555`) - thanks Abdussamet Kocak!\n- Datasette can now be run as a module: ``python -m datasette`` (:issue:`556`) - thanks, Abdussamet Kocak!\n\n.. _v0_29:\n\n0.29 (2019-07-07)\n-----------------\n\nASGI, new plugin hooks, facet by date and much, much more...\n\nASGI\n~~~~\n\n`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>`__.\n\nI 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/>`__.\n\nThe most exciting consequence of this change is that Datasette plugins can now take advantage of the ASGI standard.\n\nNew plugin hook: asgi_wrapper\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`plugin_asgi_wrapper` plugin hook allows plugins to entirely wrap the Datasette ASGI application in their own ASGI middleware. (:issue:`520`)\n\nTwo new plugins take advantage of this hook:\n\n* `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ adds a authentication layer: users will have to sign in using their GitHub account before they can view data or interact with Datasette. You can also use it to restrict access to specific GitHub users, or to members of specified GitHub `organizations <https://help.github.com/en/articles/about-organizations>`__ or `teams <https://help.github.com/en/articles/organizing-members-into-teams>`__.\n\n* `datasette-cors <https://github.com/simonw/datasette-cors>`__ allows you to configure `CORS headers <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`__ for your Datasette instance. You can use this to enable JavaScript running on a whitelisted set of domains to make ``fetch()`` calls to the JSON API provided by your Datasette instance.\n\nNew plugin hook: extra_template_vars\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`plugin_hook_extra_template_vars` plugin hook allows plugins to inject their own additional variables into the Datasette template context. This can be used in conjunction with custom templates to customize the Datasette interface. `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ uses this hook to add custom HTML to the new top navigation bar (which is designed to be modified by plugins, see :issue:`540`).\n\nSecret plugin configuration options\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nPlugins like `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ need a safe way to set secret configuration options. Since the default mechanism for configuring plugins exposes those settings in ``/-/metadata`` a new mechanism was needed. :ref:`plugins_configuration_secret` describes how plugins can now specify that their settings should be read from a file or an environment variable::\n\n    {\n        \"plugins\": {\n            \"datasette-auth-github\": {\n                \"client_secret\": {\n                    \"$env\": \"GITHUB_CLIENT_SECRET\"\n                }\n            }\n        }\n    }\n\nThese plugin secrets can be set directly using ``datasette publish``. See :ref:`publish_custom_metadata_and_plugins` for details. (:issue:`538` and :issue:`543`)\n\nFacet by date\n~~~~~~~~~~~~~\n\nIf a column contains datetime values, Datasette can now facet that column by date. (:issue:`481`)\n\n.. _v0_29_medium_changes:\n\nEasier custom templates for table rows\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nIf you want to customize the display of individual table rows, you can do so using a ``_table.html`` template include that looks something like this::\n\n    {% for row in display_rows %}\n        <div>\n            <h2>{{ row[\"title\"] }}</h2>\n            <p>{{ row[\"description\"] }}<lp>\n            <p>Category: {{ row.display(\"category_id\") }}</p>\n        </div>\n    {% endfor %}\n\nThis is a **backwards incompatible change**. If you previously had a custom template called ``_rows_and_columns.html`` you need to rename it to ``_table.html``.\n\nSee :ref:`customization_custom_templates` for full details.\n\n?_through= for joins through many-to-many tables\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe new ``?_through={json}`` argument to the Table view allows records to be filtered based on a many-to-many relationship. See :ref:`json_api_table_arguments` for full documentation - here's `an example <https://latest.datasette.io/fixtures/roadside_attractions?_through={%22table%22:%22roadside_attraction_characteristics%22,%22column%22:%22characteristic_id%22,%22value%22:%221%22}>`__. (:issue:`355`)\n\nThis feature was added to help support `facet by many-to-many <https://github.com/simonw/datasette/issues/551>`__, which isn't quite ready yet but will be coming in the next Datasette release.\n\nSmall changes\n~~~~~~~~~~~~~\n\n* Databases published using ``datasette publish`` now open in :ref:`performance_immutable_mode`. (:issue:`469`)\n* ``?col__date=`` now works for columns containing spaces\n* Automatic label detection (for deciding which column to show when linking to a foreign key) has been improved. (:issue:`485`)\n* Fixed bug where pagination broke when combined with an expanded foreign key. (:issue:`489`)\n* Contributors can now run ``pip install -e .[docs]`` to get all of the dependencies needed to build the documentation, including ``cd docs && make livehtml`` support.\n* Datasette's dependencies are now all specified using the ``~=`` match operator. (:issue:`532`)\n* ``white-space: pre-wrap`` now used for table creation SQL. (:issue:`505`)\n\n\n`Full list of commits <https://github.com/simonw/datasette/compare/0.28...0.29>`__ between 0.28 and 0.29.\n\n.. _v0_28:\n\n0.28 (2019-05-19)\n-----------------\n\nA `salmagundi <https://adamj.eu/tech/2019/01/18/a-salmagundi-of-django-alpha-announcements/>`__ of new features!\n\n.. _v0_28_databases_that_change:\n\nSupporting databases that change\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nFrom the beginning of the project, Datasette has been designed with read-only databases in mind. If a database is guaranteed not to change it opens up all kinds of interesting opportunities - from taking advantage of SQLite immutable mode and HTTP caching to bundling static copies of the database directly in a Docker container. `The interesting ideas in Datasette <https://simonwillison.net/2018/Oct/4/datasette-ideas/>`__ explores this idea in detail.\n\nAs my goals for the project have developed, I realized that read-only databases are no longer the right default. SQLite actually supports concurrent access very well provided only one thread attempts to write to a database at a time, and I keep encountering sensible use-cases for running Datasette on top of a database that is processing inserts and updates.\n\nSo, as-of version 0.28 Datasette no longer assumes that a database file will not change. It is now safe to point Datasette at a SQLite database which is being updated by another process.\n\nMaking this change was a lot of work - see tracking tickets :issue:`418`, :issue:`419` and :issue:`420`. It required new thinking around how Datasette should calculate table counts (an expensive operation against a large, changing database) and also meant reconsidering the \"content hash\" URLs Datasette has used in the past to optimize the performance of HTTP caches.\n\nDatasette can still run against immutable files and gains numerous performance benefits from doing so, but this is no longer the default behaviour. Take a look at the new :ref:`performance` documentation section for details on how to make the most of Datasette against data that you know will be staying read-only and immutable.\n\n.. _v0_28_faceting:\n\nFaceting improvements, and faceting plugins\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette :ref:`facets` provide an intuitive way to quickly summarize and interact with data. Previously the only supported faceting technique was column faceting, but 0.28 introduces two powerful new capabilities: facet-by-JSON-array and the ability to define further facet types using plugins.\n\nFacet by array (:issue:`359`) is only available if your SQLite installation provides the ``json1`` extension. Datasette will automatically detect columns that contain JSON arrays of values and offer a faceting interface against those columns - useful for modelling things like tags without needing to break them out into a new table. See :ref:`facet_by_json_array` for more.\n\nThe new :ref:`plugin_register_facet_classes` plugin hook (`#445 <https://github.com/simonw/datasette/pull/445>`__) can be used to register additional custom facet classes. Each facet class should provide two methods: ``suggest()`` which suggests facet selections that might be appropriate for a provided SQL query, and ``facet_results()`` which executes a facet operation and returns results. Datasette's own faceting implementations have been refactored to use the same API as these plugins.\n\n.. _v0_28_publish_cloudrun:\n\ndatasette publish cloudrun\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n`Google Cloud Run <https://cloud.google.com/run/>`__ is a brand new serverless hosting platform from Google, which allows you to build a Docker container which will run only when HTTP traffic is received and will shut down (and hence cost you nothing) the rest of the time. It's similar to Zeit's Now v1 Docker hosting platform which sadly is `no longer accepting signups <https://hyperion.alpha.spectrum.chat/zeit/now/cannot-create-now-v1-deployments~d206a0d4-5835-4af5-bb5c-a17f0171fb25?m=MTU0Njk2NzgwODM3OA==>`__ from new users.\n\nThe new ``datasette publish cloudrun`` command was contributed by Romain Primet (`#434 <https://github.com/simonw/datasette/pull/434>`__) and publishes selected databases to a new Datasette instance running on Google Cloud Run.\n\nSee :ref:`publish_cloud_run` for full documentation.\n\n.. _v0_28_register_output_renderer:\n\nregister_output_renderer plugins\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nRuss Garrett implemented a new Datasette plugin hook called :ref:`register_output_renderer <plugin_register_output_renderer>` (`#441 <https://github.com/simonw/datasette/pull/441>`__) which allows plugins to create additional output renderers in addition to Datasette's default ``.json`` and ``.csv``.\n\nRuss's in-development `datasette-geo <https://github.com/russss/datasette-geo>`__ plugin includes `an example <https://github.com/russss/datasette-geo/blob/d4cecc020848bbde91e9e17bf352f7c70bc3dccf/datasette_plugin_geo/geojson.py>`__ of this hook being used to output ``.geojson`` automatically converted from SpatiaLite.\n\n.. _v0_28_medium_changes:\n\nMedium changes\n~~~~~~~~~~~~~~\n\n- Datasette now conforms to the `Black coding style <https://github.com/python/black>`__ (`#449 <https://github.com/simonw/datasette/pull/449>`__) - and has a unit test to enforce this in the future\n- New :ref:`json_api_table_arguments`:\n   - ``?columnname__in=value1,value2,value3`` filter for executing SQL IN queries against a table, see :ref:`table_arguments` (:issue:`433`)\n   - ``?columnname__date=yyyy-mm-dd`` filter which returns rows where the spoecified datetime column falls on the specified date (`583b22a <https://github.com/simonw/datasette/commit/583b22aa28e26c318de0189312350ab2688c90b1>`__)\n   - ``?tags__arraycontains=tag`` filter which acts against a JSON array contained in a column (`78e45ea <https://github.com/simonw/datasette/commit/78e45ead4d771007c57b307edf8fc920101f8733>`__)\n   - ``?_where=sql-fragment`` filter for the table view  (:issue:`429`)\n   - ``?_fts_table=mytable`` and ``?_fts_pk=mycolumn`` query string options can be used to specify which FTS table to use for a search query - see :ref:`full_text_search_table_or_view` (:issue:`428`)\n- You can now pass the same table filter multiple times - for example, ``?content__not=world&content__not=hello`` will return all rows where the content column is neither ``hello`` or ``world`` (:issue:`288`)\n- You can now specify ``about`` and ``about_url`` metadata (in addition to ``source`` and ``license``) linking to further information about a project - see :ref:`metadata_source_license_about`\n- New ``?_trace=1`` parameter now adds debug information showing every SQL query that was executed while constructing the page (:issue:`435`)\n- ``datasette inspect`` now just calculates table counts, and does not introspect other database metadata (:issue:`462`)\n- Removed ``/-/inspect`` page entirely - this will be replaced by something similar in the future, see :issue:`465`\n- Datasette can now run against an in-memory SQLite database. You can do this by starting it without passing any files or by using the new ``--memory`` option to ``datasette serve``. This can be useful for experimenting with SQLite queries that do not access any data, such as ``SELECT 1+1`` or ``SELECT sqlite_version()``.\n\n.. _v0_28_small_changes:\n\nSmall changes\n~~~~~~~~~~~~~\n\n- We now show the size of the database file next to the download link (:issue:`172`)\n- New ``/-/databases`` introspection page shows currently connected databases (:issue:`470`)\n- Binary data is no longer displayed on the table and row pages (`#442 <https://github.com/simonw/datasette/pull/442>`__ - thanks, Russ Garrett)\n- New show/hide SQL links on custom query pages (:issue:`415`)\n- The :ref:`extra_body_script <plugin_hook_extra_body_script>` plugin hook now accepts an optional ``view_name`` argument (`#443 <https://github.com/simonw/datasette/pull/443>`__ - thanks, Russ Garrett)\n- Bumped Jinja2 dependency to 2.10.1 (`#426 <https://github.com/simonw/datasette/pull/426>`__)\n- All table filters are now documented, and documentation is enforced via unit tests (`2c19a27 <https://github.com/simonw/datasette/commit/2c19a27d15a913e5f3dd443f04067169a6f24634>`__)\n- New project guideline: master should stay shippable at all times! (`31f36e1 <https://github.com/simonw/datasette/commit/31f36e1b97ccc3f4387c80698d018a69798b6228>`__)\n- Fixed a bug where ``sqlite_timelimit()`` occasionally failed to clean up after itself (`bac4e01 <https://github.com/simonw/datasette/commit/bac4e01f40ae7bd19d1eab1fb9349452c18de8f5>`__)\n- We no longer load additional plugins when executing pytest (:issue:`438`)\n- Homepage now links to database views if there are less than five tables in a database (:issue:`373`)\n- The ``--cors`` option is now respected by error pages (:issue:`453`)\n- ``datasette publish heroku`` now uses the ``--include-vcs-ignore`` option, which means it works under Travis CI (`#407 <https://github.com/simonw/datasette/pull/407>`__)\n- ``datasette publish heroku`` now publishes using Python 3.6.8 (`666c374 <https://github.com/simonw/datasette/commit/666c37415a898949fae0437099d62a35b1e9c430>`__)\n- Renamed ``datasette publish now`` to ``datasette publish nowv1`` (:issue:`472`)\n- ``datasette publish nowv1`` now accepts multiple ``--alias`` parameters (`09ef305 <https://github.com/simonw/datasette/commit/09ef305c687399384fe38487c075e8669682deb4>`__)\n- Removed the ``datasette skeleton`` command (:issue:`476`)\n- The :ref:`documentation on how to build the documentation <contributing_documentation>` now recommends ``sphinx-autobuild``\n\n.. _v0_27_1:\n\n0.27.1 (2019-05-09)\n-------------------\n\n- Tiny bugfix release: don't install ``tests/`` in the wrong place. Thanks, Veit Heller.\n\n.. _v0_27:\n\n0.27 (2019-01-31)\n-----------------\n\n- New command: ``datasette plugins`` (:ref:`documentation <plugins_installed>`) shows you the currently installed list of plugins.\n- Datasette can now output `newline-delimited JSON <http://ndjson.org/>`__ using the new ``?_shape=array&_nl=on`` query string option.\n- Added documentation on :ref:`ecosystem`.\n- Now using Python 3.7.2 as the base for the official `Datasette Docker image <https://hub.docker.com/r/datasetteproject/datasette/>`__.\n\n.. _v0_26_1:\n\n0.26.1 (2019-01-10)\n-------------------\n\n- ``/-/versions`` now includes SQLite ``compile_options`` (:issue:`396`)\n- `datasetteproject/datasette <https://hub.docker.com/r/datasetteproject/datasette>`__ Docker image now uses SQLite 3.26.0 (:issue:`397`)\n- Cleaned up some deprecation warnings under Python 3.7\n\n.. _v0_26:\n\n0.26 (2019-01-02)\n-----------------\n\n- ``datasette serve --reload`` now restarts Datasette if a database file changes on disk.\n- ``datasette publish now`` now takes an optional ``--alias mysite.now.sh`` argument. This will attempt to set an alias after the deploy completes.\n- Fixed a bug where the advanced CSV export form failed to include the currently selected filters (:issue:`393`)\n\n.. _v0_25_2:\n\n0.25.2 (2018-12-16)\n-------------------\n\n- ``datasette publish heroku`` now uses the ``python-3.6.7`` runtime\n- Added documentation on :ref:`how to build the documentation <contributing_documentation>`\n- Added documentation covering :ref:`our release process <contributing_release>`\n- Upgraded to pytest 4.0.2\n\n.. _v0_25_1:\n\n0.25.1 (2018-11-04)\n-------------------\n\nDocumentation improvements plus a fix for publishing to Zeit Now.\n\n- ``datasette publish now`` now uses Zeit's v1 platform, to work around the new 100MB image limit. Thanks, @slygent - closes :issue:`366`.\n\n.. _v0_25:\n\n0.25 (2018-09-19)\n-----------------\n\nNew plugin hooks, improved database view support and an easier way to use more recent versions of SQLite.\n\n- New ``publish_subcommand`` plugin hook. A plugin can now add additional ``datasette publish`` publishers in addition to the default ``now`` and ``heroku``, both of which have been refactored into default plugins. :ref:`publish_subcommand documentation <plugin_hook_publish_subcommand>`. Closes :issue:`349`\n- New ``render_cell`` plugin hook. Plugins can now customize how values are displayed in the HTML tables produced by Datasette's browsable interface. `datasette-json-html <https://github.com/simonw/datasette-json-html>`__ and `datasette-render-images <https://github.com/simonw/datasette-render-images>`__ are two new plugins that use this hook. :ref:`render_cell documentation <plugin_hook_render_cell>`. Closes :issue:`352`\n- New ``extra_body_script`` plugin hook, enabling plugins to provide additional JavaScript that should be added to the page footer. :ref:`extra_body_script documentation <plugin_hook_extra_body_script>`.\n- ``extra_css_urls`` and ``extra_js_urls`` hooks now take additional optional parameters, allowing them to be more selective about which pages they apply to. :ref:`Documentation <plugin_hook_extra_css_urls>`.\n- You can now use the :ref:`sortable_columns metadata setting <table_configuration_sortable_columns>` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables.\n- The new ``fts_table`` and ``fts_pk`` metadata settings can now be used to :ref:`explicitly configure full-text search for a table or a view <full_text_search_table_or_view>`, even if that table is not directly coupled to the SQLite FTS feature in the database schema itself.\n- Datasette will now use `pysqlite3 <https://github.com/coleifer/pysqlite3>`__ in place of the standard library ``sqlite3`` module if it has been installed in the current environment. This makes it much easier to run Datasette against a more recent version of SQLite, including the just-released `SQLite 3.25.0 <https://www.sqlite.org/releaselog/3_25_0.html>`__ which adds window function support. More details on how to use this in :issue:`360`\n- New mechanism that allows :ref:`plugin configuration options <plugins_configuration>` to be set using ``metadata.json``.\n\n\n.. _v0_24:\n\n0.24 (2018-07-23)\n-----------------\n\nA number of small new features:\n\n- ``datasette publish heroku`` now supports ``--extra-options``, fixes `#334 <https://github.com/simonw/datasette/issues/334>`_\n- Custom error message if SpatiaLite is needed for specified database, closes `#331 <https://github.com/simonw/datasette/issues/331>`_\n- New config option: ``truncate_cells_html`` for :ref:`truncating long cell values <setting_truncate_cells_html>` in HTML view - closes `#330 <https://github.com/simonw/datasette/issues/330>`_\n- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_\n- Fixed compatibility with Python 3.7\n- ``datasette publish heroku`` now supports app names via the ``-n`` option, which can also be used to overwrite an existing application [Russ Garrett]\n- Title and description metadata can now be set for :ref:`canned SQL queries <canned_queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_\n- New ``force_https_on`` config option, fixes ``https://`` API URLs when deploying to Zeit Now - closes `#333 <https://github.com/simonw/datasette/issues/333>`_\n- ``?_json_infinity=1`` query string argument for handling Infinity/-Infinity values in JSON, closes `#332 <https://github.com/simonw/datasette/issues/332>`_\n- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_\n\n.. _v0_23_2:\n\n0.23.2 (2018-07-07)\n-------------------\n\nMinor bugfix and documentation release.\n\n- CSV export now respects ``--cors``, fixes `#326 <https://github.com/simonw/datasette/issues/326>`_\n- :ref:`Installation instructions <installation>`, including docker image - closes `#328 <https://github.com/simonw/datasette/issues/328>`_\n- Fix for row pages for tables with / in, closes `#325 <https://github.com/simonw/datasette/issues/325>`_\n\n.. _v0_23_1:\n\n0.23.1 (2018-06-21)\n-------------------\n\nMinor bugfix release.\n\n- Correctly display empty strings in HTML table, closes `#314 <https://github.com/simonw/datasette/issues/314>`_\n- Allow \".\" in database filenames, closes `#302 <https://github.com/simonw/datasette/issues/302>`_\n- 404s ending in slash redirect to remove that slash, closes `#309 <https://github.com/simonw/datasette/issues/309>`_\n- Fixed incorrect display of compound primary keys with foreign key\n  references. Closes `#319 <https://github.com/simonw/datasette/issues/319>`_\n- Docs + example of canned SQL query using || concatenation. Closes `#321 <https://github.com/simonw/datasette/issues/321>`_\n- Correctly display facets with value of 0 - closes `#318 <https://github.com/simonw/datasette/issues/318>`_\n- Default 'expand labels' to checked in CSV advanced export\n\n.. _v0_23:\n\n0.23 (2018-06-18)\n-----------------\n\nThis release features CSV export, improved options for foreign key expansions,\nnew configuration settings and improved support for SpatiaLite.\n\nSee `datasette/compare/0.22.1...0.23\n<https://github.com/simonw/datasette/compare/0.22.1...0.23>`_ for a full list of\ncommits added since the last release.\n\nCSV export\n~~~~~~~~~~\n\nAny Datasette table, view or custom SQL query can now be exported as CSV.\n\n.. image:: https://github.com/simonw/datasette-screenshots/blob/0.62/advanced-export.png?raw=true\n   :alt: Advanced export form. You can get the data in different JSON shapes, and CSV options are download file, expand labels and stream all rows.\n\nCheck out the :ref:`CSV export documentation <csv_export>` for more details, or\ntry the feature out on\nhttps://fivethirtyeight.datasettes.com/fivethirtyeight/bechdel%2Fmovies\n\nIf your table has more than :ref:`setting_max_returned_rows` (default 1,000)\nDatasette provides the option to *stream all rows*. This option takes advantage\nof async Python and Datasette's efficient :ref:`pagination <pagination>` to\niterate through the entire matching result set and stream it back as a\ndownloadable CSV file.\n\nForeign key expansions\n~~~~~~~~~~~~~~~~~~~~~~\n\nWhen Datasette detects a foreign key reference it attempts to resolve a label\nfor that reference (automatically or using the :ref:`table_configuration_label_column` metadata\noption) so it can display a link to the associated row.\n\nThis expansion is now also available for JSON and CSV representations of the\ntable, using the new ``_labels=on`` query string option. See\n:ref:`expand_foreign_keys` for more details.\n\nNew configuration settings\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette's :ref:`settings` now also supports boolean settings. A number of new\nconfiguration options have been added:\n\n* ``num_sql_threads`` - the number of threads used to execute SQLite queries. Defaults to 3.\n* ``allow_facet`` - enable or disable custom :ref:`facets` using the `_facet=` parameter. Defaults to on.\n* ``suggest_facets`` - should Datasette suggest facets? Defaults to on.\n* ``allow_download`` - should users be allowed to download the entire SQLite database? Defaults to on.\n* ``allow_sql`` - should users be allowed to execute custom SQL queries? Defaults to on.\n* ``default_cache_ttl`` - Default HTTP caching max-age header in seconds. Defaults to 365 days - caching can be disabled entirely by settings this to 0.\n* ``cache_size_kb`` - Set the amount of memory SQLite uses for its `per-connection cache <https://www.sqlite.org/pragma.html#pragma_cache_size>`_, in KB.\n* ``allow_csv_stream`` - allow users to stream entire result sets as a single CSV file. Defaults to on.\n* ``max_csv_mb`` - maximum size of a returned CSV file in MB. Defaults to 100MB, set to 0 to disable this limit.\n\nControl HTTP caching with ?_ttl=\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can now customize the HTTP max-age header that is sent on a per-URL basis, using the new ``?_ttl=`` query string parameter.\n\nYou can set this to any value in seconds, or you can set it to 0 to disable HTTP caching entirely.\n\nConsider for example this query which returns a randomly selected member of the Avengers::\n\n    select * from [avengers/avengers] order by random() limit 1\n\nIf you hit the following page repeatedly you will get the same result, due to HTTP caching:\n\n`/fivethirtyeight?sql=select+*+from+%5Bavengers%2Favengers%5D+order+by+random%28%29+limit+1 <https://fivethirtyeight.datasettes.com/fivethirtyeight?sql=select+*+from+%5Bavengers%2Favengers%5D+order+by+random%28%29+limit+1>`_\n\nBy adding `?_ttl=0` to the zero you can ensure the page will not be cached and get back a different super hero every time:\n\n`/fivethirtyeight?sql=select+*+from+%5Bavengers%2Favengers%5D+order+by+random%28%29+limit+1&_ttl=0 <https://fivethirtyeight.datasettes.com/fivethirtyeight?sql=select+*+from+%5Bavengers%2Favengers%5D+order+by+random%28%29+limit+1&_ttl=0>`_\n\nImproved support for SpatiaLite\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe `SpatiaLite module <https://www.gaia-gis.it/fossil/libspatialite/index>`_\nfor SQLite adds robust geospatial features to the database.\n\nGetting SpatiaLite working can be tricky, especially if you want to use the most\nrecent alpha version (with support for K-nearest neighbor).\n\nDatasette now includes :ref:`extensive documentation on SpatiaLite\n<spatialite>`, and thanks to `Ravi Kotecha <https://github.com/r4vi>`_ our GitHub\nrepo includes a `Dockerfile\n<https://github.com/simonw/datasette/blob/master/Dockerfile>`_ that can build\nthe latest SpatiaLite and configure it for use with Datasette.\n\nThe ``datasette publish`` and ``datasette package`` commands now accept a new\n``--spatialite`` argument which causes them to install and configure SpatiaLite\nas part of the container they deploy.\n\nlatest.datasette.io\n~~~~~~~~~~~~~~~~~~~\n\nEvery commit to Datasette master is now automatically deployed by Travis CI to\nhttps://latest.datasette.io/ - ensuring there is always a live demo of the\nlatest version of the software.\n\nThe demo uses `the fixtures\n<https://github.com/simonw/datasette/blob/master/tests/fixtures.py>`_ from our\nunit tests, ensuring it demonstrates the same range of functionality that is\ncovered by the tests.\n\nYou can see how the deployment mechanism works in our `.travis.yml\n<https://github.com/simonw/datasette/blob/master/.travis.yml>`_ file.\n\nMiscellaneous\n~~~~~~~~~~~~~\n\n* Got JSON data in one of your columns? Use the new ``?_json=COLNAME`` argument\n  to tell Datasette to return that JSON value directly rather than encoding it\n  as a string.\n* If you just want an array of the first value of each row, use the new\n  ``?_shape=arrayfirst`` option - `example\n  <https://latest.datasette.io/fixtures.json?sql=select+neighborhood+from+facetable+order+by+pk+limit+101&_shape=arrayfirst>`_.\n\n0.22.1 (2018-05-23)\n-------------------\n\nBugfix release, plus we now use `versioneer <https://github.com/warner/python-versioneer>`_ for our version numbers.\n\n- Faceting no longer breaks pagination, fixes `#282 <https://github.com/simonw/datasette/issues/282>`_\n- Add ``__version_info__`` derived from `__version__` [Robert Gieseke]\n\n  This might be tuple of more than two values (major and minor\n  version) if commits have been made after a release.\n- Add version number support with Versioneer. [Robert Gieseke]\n\n  Versioneer Licence:\n  Public Domain (CC0-1.0)\n\n  Closes `#273 <https://github.com/simonw/datasette/issues/273>`_\n- Refactor inspect logic [Russ Garrett]\n\n0.22 (2018-05-20)\n-----------------\n\nThe big new feature in this release is :ref:`facets`. Datasette can now apply faceted browse to any column in any table. It will also suggest possible facets. See the `Datasette Facets <https://simonwillison.net/2018/May/20/datasette-facets/>`_ announcement post for more details.\n\nIn addition to the work on facets:\n\n- Added `docs for introspection endpoints <https://docs.datasette.io/en/stable/introspection.html>`_\n\n- New ``--config`` option, added ``--help-config``, closes `#274 <https://github.com/simonw/datasette/issues/274>`_\n\n  Removed the ``--page_size=`` argument to ``datasette serve`` in favour of::\n\n      datasette serve --config default_page_size:50 mydb.db\n\n  Added new help section::\n\n      datasette --help-config\n\n  ::\n\n      Config options:\n        default_page_size            Default page size for the table view\n                                     (default=100)\n        max_returned_rows            Maximum rows that can be returned from a table\n                                     or custom query (default=1000)\n        sql_time_limit_ms            Time limit for a SQL query in milliseconds\n                                     (default=1000)\n        default_facet_size           Number of values to return for requested facets\n                                     (default=30)\n        facet_time_limit_ms          Time limit for calculating a requested facet\n                                     (default=200)\n        facet_suggest_time_limit_ms  Time limit for calculating a suggested facet\n                                     (default=50)\n- Only apply responsive table styles to ``.rows-and-column``\n\n  Otherwise they interfere with tables in the description, e.g. on\n  https://fivethirtyeight.datasettes.com/fivethirtyeight/nba-elo%2Fnbaallelo\n\n- Refactored views into new ``views/`` modules, refs `#256 <https://github.com/simonw/datasette/issues/256>`_\n- `Documentation for SQLite full-text search <https://docs.datasette.io/en/stable/full_text_search.html>`_ support, closes `#253 <https://github.com/simonw/datasette/issues/253>`_\n- ``/-/versions`` now includes SQLite ``fts_versions``, closes `#252 <https://github.com/simonw/datasette/issues/252>`_\n\n0.21 (2018-05-05)\n-----------------\n\nNew JSON ``_shape=`` options, the ability to set table ``_size=`` and a mechanism for searching within specific columns.\n\n- Default tests to using a longer timelimit\n\n  Every now and then a test will fail in Travis CI on Python 3.5 because it hit\n  the default 20ms SQL time limit.\n\n  Test fixtures now default to a 200ms time limit, and we only use the 20ms time\n  limit for the specific test that tests query interruption. This should make\n  our tests on Python 3.5 in Travis much more stable.\n- Support ``_search_COLUMN=text`` searches, closes `#237 <https://github.com/simonw/datasette/issues/237>`_\n- Show version on ``/-/plugins`` page, closes `#248 <https://github.com/simonw/datasette/issues/248>`_\n- ``?_size=max`` option, closes `#249 <https://github.com/simonw/datasette/issues/249>`_\n- Added ``/-/versions`` and ``/-/versions.json``, closes `#244 <https://github.com/simonw/datasette/issues/244>`_\n\n  Sample output::\n\n      {\n        \"python\": {\n          \"version\": \"3.6.3\",\n          \"full\": \"3.6.3 (default, Oct  4 2017, 06:09:38) \\n[GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.37)]\"\n        },\n        \"datasette\": {\n          \"version\": \"0.20\"\n        },\n        \"sqlite\": {\n          \"version\": \"3.23.1\",\n          \"extensions\": {\n            \"json1\": null,\n            \"spatialite\": \"4.3.0a\"\n          }\n        }\n      }\n- Renamed ``?_sql_time_limit_ms=`` to ``?_timelimit``, closes `#242 <https://github.com/simonw/datasette/issues/242>`_\n- New ``?_shape=array`` option + tweaks to ``_shape``, closes `#245 <https://github.com/simonw/datasette/issues/245>`_\n\n  * Default is now ``?_shape=arrays`` (renamed from ``lists``)\n  * New ``?_shape=array`` returns an array of objects as the root object\n  * Changed ``?_shape=object`` to return the object as the root\n  * Updated docs\n\n- FTS tables now detected by ``inspect()``, closes `#240 <https://github.com/simonw/datasette/issues/240>`_\n- New ``?_size=XXX`` query string parameter for table view, closes `#229 <https://github.com/simonw/datasette/issues/229>`_\n\n  Also added documentation for all of the ``_special`` arguments.\n\n  Plus deleted some duplicate logic implementing ``_group_count``.\n- If ``max_returned_rows==page_size``, increment ``max_returned_rows`` - fixes `#230 <https://github.com/simonw/datasette/issues/230>`_\n- New ``hidden: True`` option for table metadata, closes `#239 <https://github.com/simonw/datasette/issues/239>`_\n- Hide ``idx_*`` tables if spatialite detected, closes `#228 <https://github.com/simonw/datasette/issues/228>`_\n- Added ``class=rows-and-columns`` to custom query results table\n- Added CSS class ``rows-and-columns`` to main table\n- ``label_column`` option in ``metadata.json`` - closes `#234 <https://github.com/simonw/datasette/issues/234>`_\n\n0.20 (2018-04-20)\n-----------------\n\nMostly new work on the :ref:`plugins` mechanism: plugins can now bundle static assets and custom templates, and ``datasette publish`` has a new ``--install=name-of-plugin`` option.\n\n- Add col-X classes to HTML table on custom query page\n- Fixed out-dated template in documentation\n- Plugins can now bundle custom templates, `#224 <https://github.com/simonw/datasette/issues/224>`_\n- Added /-/metadata /-/plugins /-/inspect, `#225 <https://github.com/simonw/datasette/issues/225>`_\n- Documentation for --install option, refs `#223 <https://github.com/simonw/datasette/issues/223>`_\n- Datasette publish/package --install option, `#223 <https://github.com/simonw/datasette/issues/223>`_\n- Fix for plugins in Python 3.5, `#222 <https://github.com/simonw/datasette/issues/222>`_\n- New plugin hooks: extra_css_urls() and extra_js_urls(), `#214 <https://github.com/simonw/datasette/issues/214>`_\n- /-/static-plugins/PLUGIN_NAME/ now serves static/ from plugins\n- <th> now gets class=\"col-X\" - plus added col-X documentation\n- Use to_css_class for table cell column classes\n\n  This ensures that columns with spaces in the name will still\n  generate usable CSS class names. Refs `#209 <https://github.com/simonw/datasette/issues/209>`_\n- Add column name classes to <td>s, make PK bold [Russ Garrett]\n- Don't duplicate simple primary keys in the link column [Russ Garrett]\n\n  When there's a simple (single-column) primary key, it looks weird to\n  duplicate it in the link column.\n\n  This change removes the second PK column and treats the link column as\n  if it were the PK column from a header/sorting perspective.\n- Correct escaping for HTML display of row links [Russ Garrett]\n- Longer time limit for test_paginate_compound_keys\n\n  It was failing intermittently in Travis - see `#209 <https://github.com/simonw/datasette/issues/209>`_\n- Use application/octet-stream for downloadable databases\n- Updated PyPI classifiers\n- Updated PyPI link to pypi.org\n\n0.19 (2018-04-16)\n-----------------\n\nThis is the first preview of the new Datasette plugins mechanism. Only two\nplugin hooks are available so far - for custom SQL functions and custom template\nfilters. There's plenty more to come - read `the documentation\n<https://docs.datasette.io/en/stable/plugins.html>`_ and get involved in\n`the tracking ticket <https://github.com/simonw/datasette/issues/14>`_ if you\nhave feedback on the direction so far.\n\n- Fix for ``_sort_desc=sortable_with_nulls`` test, refs `#216 <https://github.com/simonw/datasette/issues/216>`_\n\n- Fixed `#216 <https://github.com/simonw/datasette/issues/216>`_ - paginate correctly when sorting by nullable column\n\n- Initial documentation for plugins, closes `#213 <https://github.com/simonw/datasette/issues/213>`_\n\n  https://docs.datasette.io/en/stable/plugins.html\n\n- New ``--plugins-dir=plugins/`` option (`#212 <https://github.com/simonw/datasette/issues/212>`_)\n\n  New option causing Datasette to load and evaluate all of the Python files in\n  the specified directory and register any plugins that are defined in those\n  files.\n\n  This new option is available for the following commands::\n\n      datasette serve mydb.db --plugins-dir=plugins/\n      datasette publish now/heroku mydb.db --plugins-dir=plugins/\n      datasette package mydb.db --plugins-dir=plugins/\n\n- Start of the plugin system, based on pluggy (`#210 <https://github.com/simonw/datasette/issues/14>`_)\n\n  Uses https://pluggy.readthedocs.io/ originally created for the py.test project\n\n  We're starting with two plugin hooks:\n\n  ``prepare_connection(conn)``\n\n  This is called when a new SQLite connection is created. It can be used to register custom SQL functions.\n\n  ``prepare_jinja2_environment(env)``\n\n  This is called with the Jinja2 environment. It can be used to register custom template tags and filters.\n\n  An example plugin which uses these two hooks can be found at https://github.com/simonw/datasette-plugin-demos or installed using ``pip install datasette-plugin-demos``\n\n  Refs `#14 <https://github.com/simonw/datasette/issues/14>`_\n\n- Return HTTP 405 on InvalidUsage rather than 500. [Russ Garrett]\n\n  This also stops it filling up the logs. This happens for HEAD requests\n  at the moment - which perhaps should be handled better, but that's a\n  different issue.\n\n\n0.18 (2018-04-14)\n-----------------\n\nThis release introduces `support for units <https://docs.datasette.io/en/stable/metadata.html#specifying-units-for-a-column>`_,\ncontributed by Russ Garrett (`#203 <https://github.com/simonw/datasette/issues/203>`_).\nYou can now optionally specify the units for specific columns using ``metadata.json``.\nOnce specified, units will be displayed in the HTML view of your table. They also become\navailable for use in filters - if a column is configured with a unit of distance, you can\nrequest all rows where that column is less than 50 meters or more than 20 feet for example.\n\n- Link foreign keys which don't have labels. [Russ Garrett]\n\n  This renders unlabeled FKs as simple links.\n\n  Also includes bonus fixes for two minor issues:\n\n  * In foreign key link hrefs the primary key was escaped using HTML\n    escaping rather than URL escaping. This broke some non-integer PKs.\n  * Print tracebacks to console when handling 500 errors.\n\n- Fix SQLite error when loading rows with no incoming FKs. [Russ\n  Garrett]\n\n  This fixes an error caused by an invalid query when loading incoming FKs.\n\n  The error was ignored due to async but it still got printed to the\n  console.\n\n- Allow custom units to be registered with Pint. [Russ Garrett]\n- Support units in filters. [Russ Garrett]\n- Tidy up units support. [Russ Garrett]\n\n  * Add units to exported JSON\n  * Units key in metadata skeleton\n  * Docs\n\n- Initial units support. [Russ Garrett]\n\n  Add support for specifying units for a column in ``metadata.json`` and\n  rendering them on display using\n  `pint <https://pint.readthedocs.io/en/latest/>`_\n\n\n0.17 (2018-04-13)\n-----------------\n- Release 0.17 to fix issues with PyPI\n\n\n0.16 (2018-04-13)\n-----------------\n- Better mechanism for handling errors; 404s for missing table/database\n\n  New error mechanism closes `#193 <https://github.com/simonw/datasette/issues/193>`_\n\n  404s for missing tables/databases closes `#184 <https://github.com/simonw/datasette/issues/184>`_\n\n- long_description in markdown for the new PyPI\n- Hide SpatiaLite system tables. [Russ Garrett]\n- Allow ``explain select`` / ``explain query plan select`` `#201 <https://github.com/simonw/datasette/issues/201>`_\n- Datasette inspect now finds primary_keys `#195 <https://github.com/simonw/datasette/issues/195>`_\n- Ability to sort using form fields (for mobile portrait mode) `#199 <https://github.com/simonw/datasette/issues/199>`_\n\n  We now display sort options as a select box plus a descending checkbox, which\n  means you can apply sort orders even in portrait mode on a mobile phone where\n  the column headers are hidden.\n\n0.15 (2018-04-09)\n-----------------\n\nThe biggest new feature in this release is the ability to sort by column. On the\ntable page the column headers can now be clicked to apply sort (or descending\nsort), or you can specify ``?_sort=column`` or ``?_sort_desc=column`` directly\nin the URL.\n\n- ``table_rows`` => ``table_rows_count``, ``filtered_table_rows`` =>\n  ``filtered_table_rows_count``\n\n  Renamed properties. Closes `#194 <https://github.com/simonw/datasette/issues/194>`_\n\n- New ``sortable_columns`` option in ``metadata.json`` to control sort options.\n\n  You can now explicitly set which columns in a table can be used for sorting\n  using the ``_sort`` and ``_sort_desc`` arguments using ``metadata.json``::\n\n      {\n          \"databases\": {\n              \"database1\": {\n                  \"tables\": {\n                      \"example_table\": {\n                          \"sortable_columns\": [\n                              \"height\",\n                              \"weight\"\n                          ]\n                      }\n                  }\n              }\n          }\n      }\n\n  Refs `#189 <https://github.com/simonw/datasette/issues/189>`_\n\n- Column headers now link to sort/desc sort - refs `#189 <https://github.com/simonw/datasette/issues/189>`_\n\n- ``_sort`` and ``_sort_desc`` parameters for table views\n\n  Allows for paginated sorted results based on a specified column.\n\n  Refs `#189 <https://github.com/simonw/datasette/issues/189>`_\n\n- Total row count now correct even if ``_next`` applied\n\n- Use .custom_sql() for _group_count implementation (refs `#150 <https://github.com/simonw/datasette/issues/150>`_)\n\n- Make HTML title more readable in query template (`#180 <https://github.com/simonw/datasette/issues/180>`_) [Ryan Pitts]\n\n- New ``?_shape=objects/object/lists`` param for JSON API (`#192 <https://github.com/simonw/datasette/issues/192>`_)\n\n  New ``_shape=`` parameter replacing old ``.jsono`` extension\n\n  Now instead of this::\n\n      /database/table.jsono\n\n  We use the ``_shape`` parameter like this::\n\n      /database/table.json?_shape=objects\n\n  Also introduced a new ``_shape`` called ``object`` which looks like this::\n\n      /database/table.json?_shape=object\n\n  Returning an object for the rows key::\n\n      ...\n      \"rows\": {\n          \"pk1\": {\n              ...\n          },\n          \"pk2\": {\n              ...\n          }\n      }\n\n  Refs `#122 <https://github.com/simonw/datasette/issues/122>`_\n\n- Utility for writing test database fixtures to a .db file\n\n  ``python tests/fixtures.py /tmp/hello.db``\n\n  This is useful for making a SQLite database of the test fixtures for\n  interactive exploration.\n\n- Compound primary key ``_next=`` now plays well with extra filters\n\n  Closes `#190 <https://github.com/simonw/datasette/issues/190>`_\n\n- Fixed bug with keyset pagination over compound primary keys\n\n  Refs `#190 <https://github.com/simonw/datasette/issues/190>`_\n\n- Database/Table views inherit ``source/license/source_url/license_url``\n  metadata\n\n  If you set the ``source_url/license_url/source/license`` fields in your root\n  metadata those values will now be inherited all the way down to the database\n  and table templates.\n\n  The ``title/description`` are NOT inherited.\n\n  Also added unit tests for the HTML generated by the metadata.\n\n  Refs `#185 <https://github.com/simonw/datasette/issues/185>`_\n\n- Add metadata, if it exists, to heroku temp dir (`#178 <https://github.com/simonw/datasette/issues/178>`_) [Tony Hirst]\n- Initial documentation for pagination\n- Broke up test_app into test_api and test_html\n- Fixed bug with .json path regular expression\n\n  I had a table called ``geojson`` and it caused an exception because the regex\n  was matching ``.json`` and not ``\\.json``\n\n- Deploy to Heroku with Python 3.6.3\n\n0.14 (2017-12-09)\n-----------------\n\nThe theme of this release is customization: Datasette now allows every aspect\nof its presentation `to be customized <https://docs.datasette.io/en/stable/custom_templates.html>`_\neither using additional CSS or by providing entirely new templates.\n\nDatasette's `metadata.json format <https://docs.datasette.io/en/stable/metadata.html>`_\nhas also been expanded, to allow per-database and per-table metadata. A new\n``datasette skeleton`` command can be used to generate a skeleton JSON file\nready to be filled in with per-database and per-table details.\n\nThe ``metadata.json`` file can also be used to define\n`canned queries <https://docs.datasette.io/en/stable/sql_queries.html#canned-queries>`_,\nas a more powerful alternative to SQL views.\n\n- ``extra_css_urls``/``extra_js_urls`` in metadata\n\n  A mechanism in the ``metadata.json`` format for adding custom CSS and JS urls.\n\n  Create a ``metadata.json`` file that looks like this::\n\n      {\n          \"extra_css_urls\": [\n              \"https://simonwillison.net/static/css/all.bf8cd891642c.css\"\n          ],\n          \"extra_js_urls\": [\n              \"https://code.jquery.com/jquery-3.2.1.slim.min.js\"\n          ]\n      }\n\n  Then start datasette like this::\n\n      datasette mydb.db --metadata=metadata.json\n\n  The CSS and JavaScript files will be linked in the ``<head>`` of every page.\n\n  You can also specify a SRI (subresource integrity hash) for these assets::\n\n      {\n          \"extra_css_urls\": [\n              {\n                  \"url\": \"https://simonwillison.net/static/css/all.bf8cd891642c.css\",\n                  \"sri\": \"sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI\"\n              }\n          ],\n          \"extra_js_urls\": [\n              {\n                  \"url\": \"https://code.jquery.com/jquery-3.2.1.slim.min.js\",\n                  \"sri\": \"sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=\"\n              }\n          ]\n      }\n\n  Modern browsers will only execute the stylesheet or JavaScript if the SRI hash\n  matches the content served. You can generate hashes using https://www.srihash.org/\n\n- Auto-link column values that look like URLs (`#153 <https://github.com/simonw/datasette/issues/153>`_)\n\n- CSS styling hooks as classes on the body (`#153 <https://github.com/simonw/datasette/issues/153>`_)\n\n  Every template now gets CSS classes in the body designed to support custom\n  styling.\n\n  The index template (the top level page at ``/``) gets this::\n\n      <body class=\"index\">\n\n  The database template (``/dbname/``) gets this::\n\n      <body class=\"db db-dbname\">\n\n  The table template (``/dbname/tablename``) gets::\n\n      <body class=\"table db-dbname table-tablename\">\n\n  The row template (``/dbname/tablename/rowid``) gets::\n\n      <body class=\"row db-dbname table-tablename\">\n\n  The ``db-x`` and ``table-x`` classes use the database or table names themselves IF\n  they are valid CSS identifiers. If they aren't, we strip any invalid\n  characters out and append a 6 character md5 digest of the original name, in\n  order to ensure that multiple tables which resolve to the same stripped\n  character version still have different CSS classes.\n\n  Some examples (extracted from the unit tests)::\n\n      \"simple\" => \"simple\"\n      \"MixedCase\" => \"MixedCase\"\n      \"-no-leading-hyphens\" => \"no-leading-hyphens-65bea6\"\n      \"_no-leading-underscores\" => \"no-leading-underscores-b921bc\"\n      \"no spaces\" => \"no-spaces-7088d7\"\n      \"-\" => \"336d5e\"\n      \"no $ characters\" => \"no--characters-59e024\"\n\n- ``datasette --template-dir=mytemplates/`` argument\n\n  You can now pass an additional argument specifying a directory to look for\n  custom templates in.\n\n  Datasette will fall back on the default templates if a template is not\n  found in that directory.\n\n- Ability to over-ride templates for individual tables/databases.\n\n  It is now possible to over-ride templates on a per-database / per-row or per-\n  table basis.\n\n  When you access e.g. ``/mydatabase/mytable`` Datasette will look for the following::\n\n      - table-mydatabase-mytable.html\n      - table.html\n\n  If you provided a ``--template-dir`` argument to datasette serve it will look in\n  that directory first.\n\n  The lookup rules are as follows::\n\n      Index page (/):\n          index.html\n\n      Database page (/mydatabase):\n          database-mydatabase.html\n          database.html\n\n      Table page (/mydatabase/mytable):\n          table-mydatabase-mytable.html\n          table.html\n\n      Row page (/mydatabase/mytable/id):\n          row-mydatabase-mytable.html\n          row.html\n\n  If a table name has spaces or other unexpected characters in it, the template\n  filename will follow the same rules as our custom ``<body>`` CSS classes\n  - for example, a table called \"Food Trucks\"\n  will attempt to load the following templates::\n\n      table-mydatabase-Food-Trucks-399138.html\n      table.html\n\n  It is possible to extend the default templates using Jinja template\n  inheritance. If you want to customize EVERY row template with some additional\n  content you can do so by creating a row.html template like this::\n\n      {% extends \"default:row.html\" %}\n\n      {% block content %}\n      <h1>EXTRA HTML AT THE TOP OF THE CONTENT BLOCK</h1>\n      <p>This line renders the original block:</p>\n      {{ super() }}\n      {% endblock %}\n\n- ``--static`` option for datasette serve (`#160 <https://github.com/simonw/datasette/issues/160>`_)\n\n  You can now tell Datasette to serve static files from a specific location at a\n  specific mountpoint.\n\n  For example::\n\n    datasette serve mydb.db --static extra-css:/tmp/static/css\n\n  Now if you visit this URL::\n\n    http://localhost:8001/extra-css/blah.css\n\n  The following file will be served::\n\n    /tmp/static/css/blah.css\n\n- Canned query support.\n\n  Named canned queries can now be defined in ``metadata.json`` like this::\n\n      {\n          \"databases\": {\n              \"timezones\": {\n                  \"queries\": {\n                      \"timezone_for_point\": \"select tzid from timezones ...\"\n                  }\n              }\n          }\n      }\n\n  These will be shown in a new \"Queries\" section beneath \"Views\" on the database page.\n\n- New ``datasette skeleton`` command for generating ``metadata.json`` (`#164 <https://github.com/simonw/datasette/issues/164>`_)\n\n- ``metadata.json`` support for per-table/per-database metadata (`#165 <https://github.com/simonw/datasette/issues/165>`_)\n\n  Also added support for descriptions and HTML descriptions.\n\n  Here's an example metadata.json file illustrating custom per-database and per-\n  table metadata::\n\n      {\n          \"title\": \"Overall datasette title\",\n          \"description_html\": \"This is a <em>description with HTML</em>.\",\n          \"databases\": {\n              \"db1\": {\n                  \"title\": \"First database\",\n                  \"description\": \"This is a string description & has no HTML\",\n                  \"license_url\": \"http://example.com/\",\n              \"license\": \"The example license\",\n                  \"queries\": {\n                    \"canned_query\": \"select * from table1 limit 3;\"\n                  },\n                  \"tables\": {\n                      \"table1\": {\n                          \"title\": \"Custom title for table1\",\n                          \"description\": \"Tables can have descriptions too\",\n                          \"source\": \"This has a custom source\",\n                          \"source_url\": \"http://example.com/\"\n                      }\n                  }\n              }\n          }\n      }\n\n- Renamed ``datasette build`` command to ``datasette inspect`` (`#130 <https://github.com/simonw/datasette/issues/130>`_)\n\n- Upgrade to Sanic 0.7.0 (`#168 <https://github.com/simonw/datasette/issues/168>`_)\n\n  https://github.com/channelcat/sanic/releases/tag/0.7.0\n\n- Package and publish commands now accept ``--static`` and ``--template-dir``\n\n  Example usage::\n\n      datasette package --static css:extra-css/ --static js:extra-js/ \\\n        sf-trees.db --template-dir templates/ --tag sf-trees --branch master\n\n  This creates a local Docker image that includes copies of the templates/,\n  extra-css/ and extra-js/ directories. You can then run it like this::\n\n    docker run -p 8001:8001 sf-trees\n\n  For publishing to Zeit now::\n\n    datasette publish now --static css:extra-css/ --static js:extra-js/ \\\n      sf-trees.db --template-dir templates/ --name sf-trees --branch master\n\n- HTML comment showing which templates were considered for a page (`#171 <https://github.com/simonw/datasette/issues/171>`_)\n\n0.13 (2017-11-24)\n-----------------\n- Search now applies to current filters.\n\n  Combined search into the same form as filters.\n\n  Closes `#133`_\n\n- Much tidier design for table view header.\n\n  Closes `#147`_\n\n- Added ``?column__not=blah`` filter.\n\n  Closes `#148`_\n\n- Row page now resolves foreign keys.\n\n  Closes `#132`_\n\n- Further tweaks to select/input filter styling.\n\n  Refs `#86`_ - thanks for the help, @natbat!\n\n- Show linked foreign key in table cells.\n\n- Added UI for editing table filters.\n\n  Refs `#86`_\n\n- Hide FTS-created tables on index pages.\n\n  Closes `#129`_\n\n- Add publish to heroku support [Jacob Kaplan-Moss]\n\n  ``datasette publish heroku mydb.db``\n\n  Pull request `#104`_\n\n- Initial implementation of ``?_group_count=column``.\n\n  URL shortcut for counting rows grouped by one or more columns.\n\n  ``?_group_count=column1&_group_count=column2`` works as well.\n\n  SQL generated looks like this::\n\n      select \"qSpecies\", count(*) as \"count\"\n      from Street_Tree_List\n      group by \"qSpecies\"\n      order by \"count\" desc limit 100\n\n  Or for two columns like this::\n\n      select \"qSpecies\", \"qSiteInfo\", count(*) as \"count\"\n      from Street_Tree_List\n      group by \"qSpecies\", \"qSiteInfo\"\n      order by \"count\" desc limit 100\n\n  Refs `#44`_\n\n- Added ``--build=master`` option to datasette publish and package.\n\n  The ``datasette publish`` and ``datasette package`` commands both now accept an\n  optional ``--build`` argument. If provided, this can be used to specify a branch\n  published to GitHub that should be built into the container.\n\n  This makes it easier to test code that has not yet been officially released to\n  PyPI, e.g.::\n\n      datasette publish now mydb.db --branch=master\n\n- Implemented ``?_search=XXX`` + UI if a FTS table is detected.\n\n  Closes `#131`_\n\n- Added ``datasette --version`` support.\n\n- Table views now show expanded foreign key references, if possible.\n\n  If a table has foreign key columns, and those foreign key tables have\n  ``label_columns``, the TableView will now query those other tables for the\n  corresponding values and display those values as links in the corresponding\n  table cells.\n\n  label_columns are currently detected by the ``inspect()`` function, which looks\n  for any table that has just two columns - an ID column and one other - and\n  sets the ``label_column`` to be that second non-ID column.\n\n- Don't prevent tabbing to \"Run SQL\" button (`#117`_) [Robert Gieseke]\n\n  See comment in `#115`_\n\n- Add keyboard shortcut to execute SQL query (`#115`_) [Robert Gieseke]\n\n- Allow ``--load-extension`` to be set via environment variable.\n\n- Add support for ``?field__isnull=1`` (`#107`_) [Ray N]\n\n- Add spatialite, switch to debian and local build (`#114`_) [Ariel Núñez]\n\n- Added ``--load-extension`` argument to datasette serve.\n\n  Allows loading of SQLite extensions. Refs `#110`_.\n\n.. _#133: https://github.com/simonw/datasette/issues/133\n.. _#147: https://github.com/simonw/datasette/issues/147\n.. _#148: https://github.com/simonw/datasette/issues/148\n.. _#132: https://github.com/simonw/datasette/issues/132\n.. _#86: https://github.com/simonw/datasette/issues/86\n.. _#129: https://github.com/simonw/datasette/issues/129\n.. _#104: https://github.com/simonw/datasette/issues/104\n.. _#44: https://github.com/simonw/datasette/issues/44\n.. _#131: https://github.com/simonw/datasette/issues/131\n.. _#115: https://github.com/simonw/datasette/issues/115\n.. _#117: https://github.com/simonw/datasette/issues/117\n.. _#107: https://github.com/simonw/datasette/issues/107\n.. _#114: https://github.com/simonw/datasette/issues/114\n.. _#110: https://github.com/simonw/datasette/issues/110\n\n0.12 (2017-11-16)\n-----------------\n- Added ``__version__``, now displayed as tooltip in page footer (`#108`_).\n- Added initial docs, including a changelog (`#99`_).\n- Turned on auto-escaping in Jinja.\n- Added a UI for editing named parameters (`#96`_).\n\n  You can now construct a custom SQL statement using SQLite named\n  parameters (e.g. ``:name``) and datasette will display form fields for\n  editing those parameters. `Here’s an example`_ which lets you see the\n  most popular names for dogs of different species registered through\n  various dog registration schemes in Australia.\n\n.. _Here’s an example: https://australian-dogs.now.sh/australian-dogs-3ba9628?sql=select+name%2C+count%28*%29+as+n+from+%28%0D%0A%0D%0Aselect+upper%28%22Animal+name%22%29+as+name+from+%5BAdelaide-City-Council-dog-registrations-2013%5D+where+Breed+like+%3Abreed%0D%0A%0D%0Aunion+all%0D%0A%0D%0Aselect+upper%28Animal_Name%29+as+name+from+%5BAdelaide-City-Council-dog-registrations-2014%5D+where+Breed_Description+like+%3Abreed%0D%0A%0D%0Aunion+all+%0D%0A%0D%0Aselect+upper%28Animal_Name%29+as+name+from+%5BAdelaide-City-Council-dog-registrations-2015%5D+where+Breed_Description+like+%3Abreed%0D%0A%0D%0Aunion+all%0D%0A%0D%0Aselect+upper%28%22AnimalName%22%29+as+name+from+%5BCity-of-Port-Adelaide-Enfield-Dog_Registrations_2016%5D+where+AnimalBreed+like+%3Abreed%0D%0A%0D%0Aunion+all%0D%0A%0D%0Aselect+upper%28%22Animal+Name%22%29+as+name+from+%5BMitcham-dog-registrations-2015%5D+where+Breed+like+%3Abreed%0D%0A%0D%0Aunion+all%0D%0A%0D%0Aselect+upper%28%22DOG_NAME%22%29+as+name+from+%5Bburnside-dog-registrations-2015%5D+where+DOG_BREED+like+%3Abreed%0D%0A%0D%0Aunion+all+%0D%0A%0D%0Aselect+upper%28%22Animal_Name%22%29+as+name+from+%5Bcity-of-playford-2015-dog-registration%5D+where+Breed_Description+like+%3Abreed%0D%0A%0D%0Aunion+all%0D%0A%0D%0Aselect+upper%28%22Animal+Name%22%29+as+name+from+%5Bcity-of-prospect-dog-registration-details-2016%5D+where%22Breed+Description%22+like+%3Abreed%0D%0A%0D%0A%29+group+by+name+order+by+n+desc%3B&breed=pug\n\n- Pin to specific Jinja version. (`#100`_).\n- Default to 127.0.0.1 not 0.0.0.0. (`#98`_).\n- Added extra metadata options to publish and package commands. (`#92`_).\n\n  You can now run these commands like so::\n\n      datasette now publish mydb.db \\\n          --title=\"My Title\" \\\n          --source=\"Source\" \\\n          --source_url=\"http://www.example.com/\" \\\n          --license=\"CC0\" \\\n          --license_url=\"https://creativecommons.org/publicdomain/zero/1.0/\"\n\n  This will write those values into the metadata.json that is packaged with the\n  app. If you also pass ``--metadata=metadata.json`` that file will be updated with the extra\n  values before being written into the Docker image.\n- Added production-ready Dockerfile (`#94`_) [Andrew\n  Cutler]\n- New ``?_sql_time_limit_ms=10`` argument to database and table page (`#95`_)\n- SQL syntax highlighting with Codemirror (`#89`_) [Tom Dyson]\n\n.. _#89: https://github.com/simonw/datasette/issues/89\n.. _#92: https://github.com/simonw/datasette/issues/92\n.. _#94: https://github.com/simonw/datasette/issues/94\n.. _#95: https://github.com/simonw/datasette/issues/95\n.. _#96: https://github.com/simonw/datasette/issues/96\n.. _#98: https://github.com/simonw/datasette/issues/98\n.. _#99: https://github.com/simonw/datasette/issues/99\n.. _#100: https://github.com/simonw/datasette/issues/100\n.. _#108: https://github.com/simonw/datasette/issues/108\n\n0.11 (2017-11-14)\n-----------------\n- Added ``datasette publish now --force`` option.\n\n  This calls ``now`` with ``--force`` - useful as it means you get a fresh copy of datasette even if Now has already cached that docker layer.\n- Enable ``--cors`` by default when running in a container.\n\n0.10 (2017-11-14)\n-----------------\n- Fixed `#83`_ - 500 error on individual row pages.\n- Stop using sqlite WITH RECURSIVE in our tests.\n\n  The version of Python 3 running in Travis CI doesn't support this.\n\n.. _#83: https://github.com/simonw/datasette/issues/83\n\n0.9 (2017-11-13)\n----------------\n- Added ``--sql_time_limit_ms`` and ``--extra-options``.\n\n  The serve command now accepts ``--sql_time_limit_ms`` for customizing the SQL time\n  limit.\n\n  The publish and package commands now accept ``--extra-options`` which can be used\n  to specify additional options to be passed to the datasite serve command when\n  it executes inside the resulting Docker containers.\n\n0.8 (2017-11-13)\n----------------\n- V0.8 - added PyPI metadata, ready to ship.\n- Implemented offset/limit pagination for views (`#70`_).\n- Improved pagination. (`#78`_)\n- Limit on max rows returned, controlled by ``--max_returned_rows`` option. (`#69`_)\n\n  If someone executes 'select * from table' against a table with a million rows\n  in it, we could run into problems: just serializing that much data as JSON is\n  likely to lock up the server.\n\n  Solution: we now have a hard limit on the maximum number of rows that can be\n  returned by a query. If that limit is exceeded, the server will return a\n  ``\"truncated\": true`` field in the JSON.\n\n  This limit can be optionally controlled by the new ``--max_returned_rows``\n  option. Setting that option to 0 disables the limit entirely.\n\n.. _#70: https://github.com/simonw/datasette/issues/70\n.. _#78: https://github.com/simonw/datasette/issues/78\n.. _#69: https://github.com/simonw/datasette/issues/69\n"
  },
  {
    "path": "docs/cli-reference.rst",
    "content": ".. _cli_reference:\n\n===============\n CLI reference\n===============\n\nThe ``datasette`` CLI tool provides a number of commands.\n\nRunning ``datasette`` without specifying a command runs the default command, ``datasette serve``.  See :ref:`cli_help_serve___help` for the full list of options for that command.\n\n.. [[[cog\n    from datasette import cli\n    from click.testing import CliRunner\n    import textwrap\n    def help(args):\n        title = \"datasette \" + \" \".join(args)\n        cog.out(\"\\n::\\n\\n\")\n        result = CliRunner().invoke(cli.cli, args)\n        output = result.output.replace(\"Usage: cli \", \"Usage: datasette \")\n        cog.out(textwrap.indent(output, '    '))\n        cog.out(\"\\n\\n\")\n.. ]]]\n.. [[[end]]]\n\n.. _cli_help___help:\n\ndatasette --help\n================\n\nRunning ``datasette --help`` shows a list of all of the available commands.\n\n.. [[[cog\n    help([\"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette [OPTIONS] COMMAND [ARGS]...\n\n      Datasette is an open source multi-tool for exploring and publishing data\n\n      About Datasette: https://datasette.io/\n      Full documentation: https://docs.datasette.io/\n\n    Options:\n      --version  Show the version and exit.\n      --help     Show this message and exit.\n\n    Commands:\n      serve*        Serve up specified SQLite database files with a web UI\n      create-token  Create a signed API token for the specified actor ID\n      inspect       Generate JSON summary of provided database files\n      install       Install plugins and packages from PyPI into the same...\n      package       Package SQLite files into a Datasette Docker container\n      plugins       List currently installed plugins\n      publish       Publish specified SQLite database files to the internet...\n      uninstall     Uninstall plugins and Python packages from the Datasette...\n\n\n.. [[[end]]]\n\nAdditional commands added by plugins that use the :ref:`plugin_hook_register_commands` hook will be listed here as well.\n\n.. _cli_help_serve___help:\n\ndatasette serve\n===============\n\nThis command starts the Datasette web application running on your machine::\n\n    datasette serve mydatabase.db\n\nOr since this is the default command you can run this instead::\n\n    datasette mydatabase.db\n\nOnce started you can access it at ``http://localhost:8001``\n\n.. [[[cog\n    help([\"serve\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette serve [OPTIONS] [FILES]...\n\n      Serve up specified SQLite database files with a web UI\n\n    Options:\n      -i, --immutable PATH            Database files to open in immutable mode\n      -h, --host TEXT                 Host for server. Defaults to 127.0.0.1 which\n                                      means only connections from the local machine\n                                      will be allowed. Use 0.0.0.0 to listen to all\n                                      IPs and allow access from other machines.\n      -p, --port INTEGER RANGE        Port for server, defaults to 8001. Use -p 0 to\n                                      automatically assign an available port.\n                                      [0<=x<=65535]\n      --uds TEXT                      Bind to a Unix domain socket\n      --reload                        Automatically reload if code or metadata\n                                      change detected - useful for development\n      --cors                          Enable CORS by serving Access-Control-Allow-\n                                      Origin: *\n      --load-extension PATH:ENTRYPOINT?\n                                      Path to a SQLite extension to load, and\n                                      optional entrypoint\n      --inspect-file TEXT             Path to JSON file created using \"datasette\n                                      inspect\"\n      -m, --metadata FILENAME         Path to JSON/YAML file containing\n                                      license/source metadata\n      --template-dir DIRECTORY        Path to directory containing custom templates\n      --plugins-dir DIRECTORY         Path to directory containing custom plugins\n      --static MOUNT:DIRECTORY        Serve static files from this directory at\n                                      /MOUNT/...\n      --memory                        Make /_memory database available\n      -c, --config FILENAME           Path to JSON/YAML Datasette configuration file\n      -s, --setting SETTING...        nested.key, value setting to use in Datasette\n                                      configuration\n      --secret TEXT                   Secret used for signing secure values, such as\n                                      signed cookies\n      --root                          Output URL that sets a cookie authenticating\n                                      the root user\n      --default-deny                  Deny all permissions by default\n      --get TEXT                      Run an HTTP GET request against this path,\n                                      print results and exit\n      --headers                       Include HTTP headers in --get output\n      --token TEXT                    API token to send with --get requests\n      --actor TEXT                    Actor to use for --get requests (JSON string)\n      --version-note TEXT             Additional note to show on /-/versions\n      --help-settings                 Show available settings\n      --pdb                           Launch debugger on any errors\n      -o, --open                      Open Datasette in your web browser\n      --create                        Create database files if they do not exist\n      --crossdb                       Enable cross-database joins using the /_memory\n                                      database\n      --nolock                        Ignore locking, open locked files in read-only\n                                      mode\n      --ssl-keyfile TEXT              SSL key file\n      --ssl-certfile TEXT             SSL certificate file\n      --internal PATH                 Path to a persistent Datasette internal SQLite\n                                      database\n      --help                          Show this message and exit.\n\n\n.. [[[end]]]\n\n.. _cli_datasette_serve_env:\n\nEnvironment variables\n---------------------\n\nSome of the ``datasette serve`` options can be provided by environment variables:\n\n- ``DATASETTE_SECRET``: Equivalent to the ``--secret`` option.\n- ``DATASETTE_SSL_KEYFILE``: Equivalent to the ``--ssl-keyfile`` option.\n- ``DATASETTE_SSL_CERTFILE``: Equivalent to the ``--ssl-certfile`` option.\n- ``DATASETTE_LOAD_EXTENSION``: Equivalent to the ``--load-extension`` option.\n\n.. _cli_datasette_get:\n\ndatasette --get\n---------------\n\nThe ``--get`` option to ``datasette serve`` (or just ``datasette``) specifies the path to a page within Datasette and causes Datasette to output the content from that path without starting the web server.\n\nThis means that all of Datasette's functionality can be accessed directly from the command-line.\n\nFor example:\n\n.. code-block:: bash\n\n    datasette --get '/-/versions.json' | jq .\n\n.. code-block:: json\n\n    {\n      \"python\": {\n        \"version\": \"3.8.5\",\n        \"full\": \"3.8.5 (default, Jul 21 2020, 10:48:26) \\n[Clang 11.0.3 (clang-1103.0.32.62)]\"\n      },\n      \"datasette\": {\n        \"version\": \"0.46+15.g222a84a.dirty\"\n      },\n      \"asgi\": \"3.0\",\n      \"uvicorn\": \"0.11.8\",\n      \"sqlite\": {\n        \"version\": \"3.32.3\",\n        \"fts_versions\": [\n          \"FTS5\",\n          \"FTS4\",\n          \"FTS3\"\n        ],\n        \"extensions\": {\n          \"json1\": null\n        },\n        \"compile_options\": [\n          \"COMPILER=clang-11.0.3\",\n          \"ENABLE_COLUMN_METADATA\",\n          \"ENABLE_FTS3\",\n          \"ENABLE_FTS3_PARENTHESIS\",\n          \"ENABLE_FTS4\",\n          \"ENABLE_FTS5\",\n          \"ENABLE_GEOPOLY\",\n          \"ENABLE_JSON1\",\n          \"ENABLE_PREUPDATE_HOOK\",\n          \"ENABLE_RTREE\",\n          \"ENABLE_SESSION\",\n          \"MAX_VARIABLE_NUMBER=250000\",\n          \"THREADSAFE=1\"\n        ]\n      }\n    }\n\nYou can use the ``--token TOKEN`` option to send an :ref:`API token <CreateTokenView>` with the simulated request.\n\nOr you can make a request as a specific actor by passing a JSON representation of that actor to ``--actor``:\n\n.. code-block:: bash\n\n    datasette --memory --actor '{\"id\": \"root\"}' --get '/-/actor.json'\n\nThe exit code of ``datasette --get`` will be 0 if the request succeeds and 1 if the request produced an HTTP status code other than 200 - e.g. a 404 or 500 error.\n\nThis lets you use ``datasette --get /`` to run tests against a Datasette application in a continuous integration environment such as GitHub Actions.\n\n.. _cli_help_serve___help_settings:\n\ndatasette serve --help-settings\n-------------------------------\n\nThis command outputs all of the available Datasette :ref:`settings <settings>`.\n\nThese can be passed to ``datasette serve`` using ``datasette serve --setting name value``.\n\n.. [[[cog\n    help([\"--help-settings\"])\n.. ]]]\n\n::\n\n    Settings:\n      default_page_size            Default page size for the table view\n                                   (default=100)\n      max_returned_rows            Maximum rows that can be returned from a table or\n                                   custom query (default=1000)\n      max_insert_rows              Maximum rows that can be inserted at a time using\n                                   the bulk insert API (default=100)\n      num_sql_threads              Number of threads in the thread pool for\n                                   executing SQLite queries (default=3)\n      sql_time_limit_ms            Time limit for a SQL query in milliseconds\n                                   (default=1000)\n      default_facet_size           Number of values to return for requested facets\n                                   (default=30)\n      facet_time_limit_ms          Time limit for calculating a requested facet\n                                   (default=200)\n      facet_suggest_time_limit_ms  Time limit for calculating a suggested facet\n                                   (default=50)\n      allow_facet                  Allow users to specify columns to facet using\n                                   ?_facet= parameter (default=True)\n      allow_download               Allow users to download the original SQLite\n                                   database files (default=True)\n      allow_signed_tokens          Allow users to create and use signed API tokens\n                                   (default=True)\n      default_allow_sql            Allow anyone to run arbitrary SQL queries\n                                   (default=True)\n      max_signed_tokens_ttl        Maximum allowed expiry time for signed API tokens\n                                   (default=0)\n      suggest_facets               Calculate and display suggested facets\n                                   (default=True)\n      default_cache_ttl            Default HTTP cache TTL (used in Cache-Control:\n                                   max-age= header) (default=5)\n      cache_size_kb                SQLite cache size in KB (0 == use SQLite default)\n                                   (default=0)\n      allow_csv_stream             Allow .csv?_stream=1 to download all rows\n                                   (ignoring max_returned_rows) (default=True)\n      max_csv_mb                   Maximum size allowed for CSV export in MB - set 0\n                                   to disable this limit (default=100)\n      truncate_cells_html          Truncate cells longer than this in HTML table\n                                   view - set 0 to disable (default=2048)\n      force_https_urls             Force URLs in API output to always use https://\n                                   protocol (default=False)\n      template_debug               Allow display of template debug information with\n                                   ?_context=1 (default=False)\n      trace_debug                  Allow display of SQL trace debug information with\n                                   ?_trace=1 (default=False)\n      base_url                     Datasette URLs should use this base path\n                                   (default=/)\n\n\n\n.. [[[end]]]\n\n.. _cli_help_plugins___help:\n\ndatasette plugins\n=================\n\nOutput JSON showing all currently installed plugins, their versions, whether they include static files or templates and which :ref:`plugin_hooks` they use.\n\n.. [[[cog\n    help([\"plugins\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette plugins [OPTIONS]\n\n      List currently installed plugins\n\n    Options:\n      --all                    Include built-in default plugins\n      --requirements           Output requirements.txt of installed plugins\n      --plugins-dir DIRECTORY  Path to directory containing custom plugins\n      --help                   Show this message and exit.\n\n\n.. [[[end]]]\n\nExample output:\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette-geojson\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": \"0.3.1\",\n            \"hooks\": [\n                \"register_output_renderer\"\n            ]\n        },\n        {\n            \"name\": \"datasette-geojson-map\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.4.0\",\n            \"hooks\": [\n                \"extra_body_script\",\n                \"extra_css_urls\",\n                \"extra_js_urls\"\n            ]\n        },\n        {\n            \"name\": \"datasette-leaflet\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.2.2\",\n            \"hooks\": [\n                \"extra_body_script\",\n                \"extra_template_vars\"\n            ]\n        }\n    ]\n\n\n.. _cli_help_install___help:\n\ndatasette install\n=================\n\nInstall new Datasette plugins. This command works like ``pip install`` but ensures that your plugins will be installed into the same environment as Datasette.\n\nThis command::\n\n    datasette install datasette-cluster-map\n\nWould install the `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster-map>`__ plugin.\n\n.. [[[cog\n    help([\"install\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette install [OPTIONS] [PACKAGES]...\n\n      Install plugins and packages from PyPI into the same environment as Datasette\n\n    Options:\n      -U, --upgrade           Upgrade packages to latest version\n      -r, --requirement PATH  Install from requirements file\n      -e, --editable TEXT     Install a project in editable mode from this path\n      --help                  Show this message and exit.\n\n\n.. [[[end]]]\n\n.. _cli_help_uninstall___help:\n\ndatasette uninstall\n===================\n\nUninstall one or more plugins.\n\n.. [[[cog\n    help([\"uninstall\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette uninstall [OPTIONS] PACKAGES...\n\n      Uninstall plugins and Python packages from the Datasette environment\n\n    Options:\n      -y, --yes  Don't ask for confirmation\n      --help     Show this message and exit.\n\n\n.. [[[end]]]\n\n.. _cli_help_publish___help:\n\ndatasette publish\n=================\n\nShows a list of available deployment targets for :ref:`publishing data <publishing>` with Datasette.\n\nAdditional deployment targets can be added by plugins that use the :ref:`plugin_hook_publish_subcommand` hook.\n\n.. [[[cog\n    help([\"publish\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette publish [OPTIONS] COMMAND [ARGS]...\n\n      Publish specified SQLite database files to the internet along with a\n      Datasette-powered interface and API\n\n    Options:\n      --help  Show this message and exit.\n\n    Commands:\n      cloudrun  Publish databases to Datasette running on Cloud Run\n      heroku    Publish databases to Datasette running on Heroku\n\n\n.. [[[end]]]\n\n\n.. _cli_help_publish_cloudrun___help:\n\ndatasette publish cloudrun\n==========================\n\nSee :ref:`publish_cloud_run`.\n\n.. [[[cog\n    help([\"publish\", \"cloudrun\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette publish cloudrun [OPTIONS] [FILES]...\n\n      Publish databases to Datasette running on Cloud Run\n\n    Options:\n      -m, --metadata FILENAME         Path to JSON/YAML file containing metadata to\n                                      publish\n      --extra-options TEXT            Extra options to pass to datasette serve\n      --branch TEXT                   Install datasette from a GitHub branch e.g.\n                                      main\n      --template-dir DIRECTORY        Path to directory containing custom templates\n      --plugins-dir DIRECTORY         Path to directory containing custom plugins\n      --static MOUNT:DIRECTORY        Serve static files from this directory at\n                                      /MOUNT/...\n      --install TEXT                  Additional packages (e.g. plugins) to install\n      --plugin-secret <TEXT TEXT TEXT>...\n                                      Secrets to pass to plugins, e.g. --plugin-\n                                      secret datasette-auth-github client_id xxx\n      --version-note TEXT             Additional note to show on /-/versions\n      --secret TEXT                   Secret used for signing secure values, such as\n                                      signed cookies\n      --title TEXT                    Title for metadata\n      --license TEXT                  License label for metadata\n      --license_url TEXT              License URL for metadata\n      --source TEXT                   Source label for metadata\n      --source_url TEXT               Source URL for metadata\n      --about TEXT                    About label for metadata\n      --about_url TEXT                About URL for metadata\n      -n, --name TEXT                 Application name to use when building\n      --service TEXT                  Cloud Run service to deploy (or over-write)\n      --spatialite                    Enable SpatialLite extension\n      --show-files                    Output the generated Dockerfile and\n                                      metadata.json\n      --memory TEXT                   Memory to allocate in Cloud Run, e.g. 1Gi\n      --cpu [1|2|4]                   Number of vCPUs to allocate in Cloud Run\n      --timeout INTEGER               Build timeout in seconds\n      --apt-get-install TEXT          Additional packages to apt-get install\n      --max-instances INTEGER         Maximum Cloud Run instances (use 0 to remove\n                                      the limit)  [default: 1]\n      --min-instances INTEGER         Minimum Cloud Run instances\n      --artifact-repository TEXT      Artifact Registry repository to store the\n                                      image  [default: datasette]\n      --artifact-region TEXT          Artifact Registry location (region or multi-\n                                      region)  [default: us]\n      --artifact-project TEXT         Project ID for Artifact Registry (defaults to\n                                      the active project)\n      --help                          Show this message and exit.\n\n\n.. [[[end]]]\n\n\n.. _cli_help_publish_heroku___help:\n\ndatasette publish heroku\n========================\n\nSee :ref:`publish_heroku`.\n\n.. [[[cog\n    help([\"publish\", \"heroku\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette publish heroku [OPTIONS] [FILES]...\n\n      Publish databases to Datasette running on Heroku\n\n    Options:\n      -m, --metadata FILENAME         Path to JSON/YAML file containing metadata to\n                                      publish\n      --extra-options TEXT            Extra options to pass to datasette serve\n      --branch TEXT                   Install datasette from a GitHub branch e.g.\n                                      main\n      --template-dir DIRECTORY        Path to directory containing custom templates\n      --plugins-dir DIRECTORY         Path to directory containing custom plugins\n      --static MOUNT:DIRECTORY        Serve static files from this directory at\n                                      /MOUNT/...\n      --install TEXT                  Additional packages (e.g. plugins) to install\n      --plugin-secret <TEXT TEXT TEXT>...\n                                      Secrets to pass to plugins, e.g. --plugin-\n                                      secret datasette-auth-github client_id xxx\n      --version-note TEXT             Additional note to show on /-/versions\n      --secret TEXT                   Secret used for signing secure values, such as\n                                      signed cookies\n      --title TEXT                    Title for metadata\n      --license TEXT                  License label for metadata\n      --license_url TEXT              License URL for metadata\n      --source TEXT                   Source label for metadata\n      --source_url TEXT               Source URL for metadata\n      --about TEXT                    About label for metadata\n      --about_url TEXT                About URL for metadata\n      -n, --name TEXT                 Application name to use when deploying\n      --tar TEXT                      --tar option to pass to Heroku, e.g.\n                                      --tar=/usr/local/bin/gtar\n      --generate-dir DIRECTORY        Output generated application files and stop\n                                      without deploying\n      --help                          Show this message and exit.\n\n\n.. [[[end]]]\n\n.. _cli_help_package___help:\n\ndatasette package\n=================\n\nPackage SQLite files into a Datasette Docker container, see :ref:`cli_package`.\n\n.. [[[cog\n    help([\"package\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette package [OPTIONS] FILES...\n\n      Package SQLite files into a Datasette Docker container\n\n    Options:\n      -t, --tag TEXT            Name for the resulting Docker container, can\n                                optionally use name:tag format\n      -m, --metadata FILENAME   Path to JSON/YAML file containing metadata to\n                                publish\n      --extra-options TEXT      Extra options to pass to datasette serve\n      --branch TEXT             Install datasette from a GitHub branch e.g. main\n      --template-dir DIRECTORY  Path to directory containing custom templates\n      --plugins-dir DIRECTORY   Path to directory containing custom plugins\n      --static MOUNT:DIRECTORY  Serve static files from this directory at /MOUNT/...\n      --install TEXT            Additional packages (e.g. plugins) to install\n      --spatialite              Enable SpatialLite extension\n      --version-note TEXT       Additional note to show on /-/versions\n      --secret TEXT             Secret used for signing secure values, such as\n                                signed cookies\n      -p, --port INTEGER RANGE  Port to run the server on, defaults to 8001\n                                [1<=x<=65535]\n      --title TEXT              Title for metadata\n      --license TEXT            License label for metadata\n      --license_url TEXT        License URL for metadata\n      --source TEXT             Source label for metadata\n      --source_url TEXT         Source URL for metadata\n      --about TEXT              About label for metadata\n      --about_url TEXT          About URL for metadata\n      --help                    Show this message and exit.\n\n\n.. [[[end]]]\n\n\n.. _cli_help_inspect___help:\n\ndatasette inspect\n=================\n\nOutputs JSON representing introspected data about one or more SQLite database files.\n\nIf you are opening an immutable database, you can pass this file to the ``--inspect-data`` option to improve Datasette's performance by allowing it to skip running row counts against the database when it first starts running::\n\n    datasette inspect mydatabase.db > inspect-data.json\n    datasette serve -i mydatabase.db --inspect-file inspect-data.json\n\nThis performance optimization is used automatically by some of the ``datasette publish`` commands. You are unlikely to need to apply this optimization manually.\n\n.. [[[cog\n    help([\"inspect\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette inspect [OPTIONS] [FILES]...\n\n      Generate JSON summary of provided database files\n\n      This can then be passed to \"datasette --inspect-file\" to speed up count\n      operations against immutable database files.\n\n    Options:\n      --inspect-file TEXT\n      --load-extension PATH:ENTRYPOINT?\n                                      Path to a SQLite extension to load, and\n                                      optional entrypoint\n      --help                          Show this message and exit.\n\n\n.. [[[end]]]\n\n\n.. _cli_help_create_token___help:\n\ndatasette create-token\n======================\n\nCreate a signed API token, see :ref:`authentication_cli_create_token`.\n\n.. [[[cog\n    help([\"create-token\", \"--help\"])\n.. ]]]\n\n::\n\n    Usage: datasette create-token [OPTIONS] ID\n\n      Create a signed API token for the specified actor ID\n\n      Example:\n\n          datasette create-token root --secret mysecret\n\n      To allow only \"view-database-download\" for all databases:\n\n          datasette create-token root --secret mysecret \\\n              --all view-database-download\n\n      To allow \"create-table\" against a specific database:\n\n          datasette create-token root --secret mysecret \\\n              --database mydb create-table\n\n      To allow \"insert-row\" against a specific table:\n\n          datasette create-token root --secret myscret \\\n              --resource mydb mytable insert-row\n\n      Restricted actions can be specified multiple times using multiple --all,\n      --database, and --resource options.\n\n      Add --debug to see a decoded version of the token.\n\n    Options:\n      --secret TEXT                   Secret used for signing the API tokens\n                                      [required]\n      -e, --expires-after INTEGER     Token should expire after this many seconds\n      -a, --all ACTION                Restrict token to this action\n      -d, --database DB ACTION        Restrict token to this action on this database\n      -r, --resource DB RESOURCE ACTION\n                                      Restrict token to this action on this database\n                                      resource (a table, SQL view or named query)\n      --debug                         Show decoded token\n      --plugins-dir DIRECTORY         Path to directory containing custom plugins\n      --help                          Show this message and exit.\n\n\n.. [[[end]]]\n"
  },
  {
    "path": "docs/codespell-ignore-words.txt",
    "content": "alls\nfo\nro\nte\nths\nnotin"
  },
  {
    "path": "docs/conf.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n#\n# Datasette documentation build configuration file, created by\n# sphinx-quickstart on Thu Nov 16 06:50:13 2017.\n#\n# This file is execfile()d with the current directory set to its\n# containing dir.\n#\n# Note that not all possible configuration values are present in this\n# autogenerated file.\n#\n# All configuration values have a default; values that are commented out\n# serve to show the default.\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n#\nimport os\n\n# import sys\n# sys.path.insert(0, os.path.abspath('.'))\n\n\n# -- General configuration ------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n#\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = [\n    \"sphinx.ext.extlinks\",\n    \"sphinx.ext.autodoc\",\n    \"sphinx_copybutton\",\n    \"myst_parser\",\n    \"sphinx_markdown_builder\",\n]\nif not os.environ.get(\"DISABLE_SPHINX_INLINE_TABS\"):\n    extensions += [\"sphinx_inline_tabs\"]\n\nautodoc_member_order = \"bysource\"\n\nmyst_enable_extensions = [\"colon_fence\"]\n\nmarkdown_http_base = \"https://docs.datasette.io/en/stable\"\nmarkdown_uri_doc_suffix = \".html\"\n\nextlinks = {\n    \"issue\": (\"https://github.com/simonw/datasette/issues/%s\", \"#%s\"),\n}\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# The suffix(es) of source filenames.\n# You can specify multiple suffix as a list of string:\n#\n# source_suffix = ['.rst', '.md']\nsource_suffix = {\n    \".rst\": \"restructuredtext\",\n    \".md\": \"markdown\",\n}\n\n# The master toctree document.\nmaster_doc = \"index\"\n\n# General information about the project.\nproject = \"Datasette\"\ncopyright = \"2017-2022, Simon Willison\"\nauthor = \"Simon Willison\"\n\n# Disable -- turning into –\nsmartquotes = False\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\n# The short X.Y version.\nversion = \"\"\n# The full version, including alpha/beta/rc tags.\nrelease = \"\"\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n#\n# This is also used if you do content translation via gettext catalogs.\n# Usually you set \"language\" from the command line for these cases.\nlanguage = \"en\"\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This patterns also effect to html_static_path and html_extra_path\nexclude_patterns = [\"_build\", \"Thumbs.db\", \".DS_Store\"]\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = \"sphinx\"\n\n# If true, `todo` and `todoList` produce output, else they produce nothing.\ntodo_include_todos = False\n\n\n# -- Options for HTML output ----------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\nhtml_theme = \"furo\"\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\nhtml_theme_options = {\n    \"sidebar_hide_name\": True,\n}\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = [\"_static\"]\n\nhtml_logo = \"datasette-logo.svg\"\nhtml_favicon = \"_static/datasette-favicon.png\"\n\nhtml_css_files = [\n    \"css/custom.css\",\n]\nhtml_js_files = [\"js/custom.js\"]\n\n# -- Options for HTMLHelp output ------------------------------------------\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = \"Datasettedoc\"\n\n\n# -- Options for LaTeX output ---------------------------------------------\n\nlatex_elements = {\n    # The paper size ('letterpaper' or 'a4paper').\n    #\n    # 'papersize': 'letterpaper',\n    # The font size ('10pt', '11pt' or '12pt').\n    #\n    # 'pointsize': '10pt',\n    # Additional stuff for the LaTeX preamble.\n    #\n    # 'preamble': '',\n    # Latex figure (float) alignment\n    #\n    # 'figure_align': 'htbp',\n}\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title,\n#  author, documentclass [howto, manual, or own class]).\nlatex_documents = [\n    (\n        master_doc,\n        \"Datasette.tex\",\n        \"Datasette Documentation\",\n        \"Simon Willison\",\n        \"manual\",\n    ),\n]\n\n\n# -- Options for manual page output ---------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [(master_doc, \"datasette\", \"Datasette Documentation\", [author], 1)]\n\n\n# -- Options for Texinfo output -------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (\n        master_doc,\n        \"Datasette\",\n        \"Datasette Documentation\",\n        author,\n        \"Datasette\",\n        \"One line description of project.\",\n        \"Miscellaneous\",\n    ),\n]\n"
  },
  {
    "path": "docs/configuration.rst",
    "content": ".. _configuration:\n\nConfiguration\n=============\n\nDatasette offers several ways to configure your Datasette instances: server settings, plugin configuration, authentication, and more.\n\nMost configuration can be handled using a ``datasette.yaml`` configuration file, passed to datasette using the ``-c/--config`` flag:\n\n.. code-block:: bash\n\n    datasette mydatabase.db --config datasette.yaml\n\nThis file can also use JSON, as ``datasette.json``. YAML is recommended over JSON due to its support for comments and multi-line strings.\n\n.. _configuration_cli:\n\nConfiguration via the command-line\n----------------------------------\n\nThe recommended way to configure Datasette is using a ``datasette.yaml`` file passed to ``-c/--config``. You can also pass individual settings to Datasette using the ``-s/--setting`` option, which can be used multiple times:\n\n.. code-block:: bash\n\n    datasette mydatabase.db \\\n      --setting settings.default_page_size 50 \\\n      --setting settings.sql_time_limit_ms 3500\n\nThis option takes dotted-notation for the first argument and a value for the second argument. This means you can use it to set any configuration value that would be valid in a ``datasette.yaml`` file.\n\nIt also works for plugin configuration, for example for `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster-map>`_:\n\n.. code-block:: bash\n\n    datasette mydatabase.db \\\n      --setting plugins.datasette-cluster-map.latitude_column xlat \\\n      --setting plugins.datasette-cluster-map.longitude_column xlon\n\nIf the value you provide is a valid JSON object or list it will be treated as nested data, allowing you to configure plugins that accept lists such as `datasette-proxy-url <https://datasette.io/plugins/datasette-proxy-url>`_:\n\n.. code-block:: bash\n\n    datasette mydatabase.db \\\n      -s plugins.datasette-proxy-url.paths '[{\"path\": \"/proxy\", \"backend\": \"http://example.com/\"}]'\n\nThis is equivalent to a ``datasette.yaml`` file containing the following:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n      plugins:\n        datasette-proxy-url:\n          paths:\n          - path: /proxy\n            backend: http://example.com/\n      \"\"\").strip()\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        plugins:\n          datasette-proxy-url:\n            paths:\n            - path: /proxy\n              backend: http://example.com/\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-proxy-url\": {\n              \"paths\": [\n                {\n                  \"path\": \"/proxy\",\n                  \"backend\": \"http://example.com/\"\n                }\n              ]\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _configuration_reference:\n\n``datasette.yaml`` reference\n----------------------------\n\nThe following example shows some of the valid configuration options that can exist inside ``datasette.yaml``.\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        # Datasette settings block\n        settings:\n          default_page_size: 50\n          sql_time_limit_ms: 3500\n          max_returned_rows: 2000\n\n        # top-level plugin configuration\n        plugins:\n          datasette-my-plugin:\n            key: valueA\n\n        # Database and table-level configuration\n        databases:\n          your_db_name:\n            # plugin configuration for the your_db_name database\n            plugins:\n              datasette-my-plugin:\n                key: valueA\n            tables:\n              your_table_name:\n                allow:\n                  # Only the root user can access this table\n                  id: root\n                # plugin configuration for the your_table_name table\n                # inside your_db_name database\n                plugins:\n                  datasette-my-plugin:\n                    key: valueB\n        \"\"\")\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n        # Datasette settings block\n        settings:\n          default_page_size: 50\n          sql_time_limit_ms: 3500\n          max_returned_rows: 2000\n\n        # top-level plugin configuration\n        plugins:\n          datasette-my-plugin:\n            key: valueA\n\n        # Database and table-level configuration\n        databases:\n          your_db_name:\n            # plugin configuration for the your_db_name database\n            plugins:\n              datasette-my-plugin:\n                key: valueA\n            tables:\n              your_table_name:\n                allow:\n                  # Only the root user can access this table\n                  id: root\n                # plugin configuration for the your_table_name table\n                # inside your_db_name database\n                plugins:\n                  datasette-my-plugin:\n                    key: valueB\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"settings\": {\n            \"default_page_size\": 50,\n            \"sql_time_limit_ms\": 3500,\n            \"max_returned_rows\": 2000\n          },\n          \"plugins\": {\n            \"datasette-my-plugin\": {\n              \"key\": \"valueA\"\n            }\n          },\n          \"databases\": {\n            \"your_db_name\": {\n              \"plugins\": {\n                \"datasette-my-plugin\": {\n                  \"key\": \"valueA\"\n                }\n              },\n              \"tables\": {\n                \"your_table_name\": {\n                  \"allow\": {\n                    \"id\": \"root\"\n                  },\n                  \"plugins\": {\n                    \"datasette-my-plugin\": {\n                      \"key\": \"valueB\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _configuration_reference_settings:\n\nSettings\n~~~~~~~~\n\n:ref:`settings` can be configured in ``datasette.yaml`` with the ``settings`` key:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        # inside datasette.yaml\n        settings:\n          default_allow_sql: off\n          default_page_size: 50\n        \"\"\").strip()\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        # inside datasette.yaml\n        settings:\n          default_allow_sql: off\n          default_page_size: 50\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"settings\": {\n            \"default_allow_sql\": \"off\",\n            \"default_page_size\": 50\n          }\n        }\n.. [[[end]]]\n\nThe full list of settings is available in the :ref:`settings documentation <settings>`. Settings can also be passed to Datasette using one or more ``--setting name value`` command line options.`\n\n.. _configuration_reference_plugins:\n\nPlugin configuration\n~~~~~~~~~~~~~~~~~~~~\n\n:ref:`Datasette plugins <plugins>` often require configuration. This plugin configuration should be placed in ``plugins`` keys inside ``datasette.yaml``.\n\nMost plugins are configured at the top-level of the file, using the ``plugins`` key:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        # inside datasette.yaml\n        plugins:\n          datasette-my-plugin:\n            key: my_value\n        \"\"\").strip()\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        # inside datasette.yaml\n        plugins:\n          datasette-my-plugin:\n            key: my_value\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-my-plugin\": {\n              \"key\": \"my_value\"\n            }\n          }\n        }\n.. [[[end]]]\n\nSome plugins can be configured at the database or table level. These should use a ``plugins`` key nested under the appropriate place within the ``databases`` object:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        # inside datasette.yaml\n        databases:\n          my_database:\n            # plugin configuration for the my_database database\n            plugins:\n              datasette-my-plugin:\n                key: my_value\n          my_other_database:\n            tables:\n              my_table:\n                # plugin configuration for the my_table table inside the my_other_database database\n                plugins:\n                  datasette-my-plugin:\n                    key: my_value\n      \"\"\").strip()\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        # inside datasette.yaml\n        databases:\n          my_database:\n            # plugin configuration for the my_database database\n            plugins:\n              datasette-my-plugin:\n                key: my_value\n          my_other_database:\n            tables:\n              my_table:\n                # plugin configuration for the my_table table inside the my_other_database database\n                plugins:\n                  datasette-my-plugin:\n                    key: my_value\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"my_database\": {\n              \"plugins\": {\n                \"datasette-my-plugin\": {\n                  \"key\": \"my_value\"\n                }\n              }\n            },\n            \"my_other_database\": {\n              \"tables\": {\n                \"my_table\": {\n                  \"plugins\": {\n                    \"datasette-my-plugin\": {\n                      \"key\": \"my_value\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n\n.. _configuration_reference_permissions:\n\nPermissions configuration\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette's :ref:`authentication and permissions <authentication>` system can also be configured using ``datasette.yaml``.\n\nHere is a simple example:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        # Instance is only available to users 'sharon' and 'percy':\n        allow:\n          id:\n          - sharon\n          - percy\n\n        # Only 'percy' is allowed access to the accounting database:\n        databases:\n          accounting:\n            allow:\n              id: percy\n      \"\"\").strip()\n      )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        # Instance is only available to users 'sharon' and 'percy':\n        allow:\n          id:\n          - sharon\n          - percy\n\n        # Only 'percy' is allowed access to the accounting database:\n        databases:\n          accounting:\n            allow:\n              id: percy\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"allow\": {\n            \"id\": [\n              \"sharon\",\n              \"percy\"\n            ]\n          },\n          \"databases\": {\n            \"accounting\": {\n              \"allow\": {\n                \"id\": \"percy\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n:ref:`authentication_permissions_config` has the full details.\n\n.. _configuration_reference_canned_queries:\n\nCanned queries configuration\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n:ref:`Canned queries <canned_queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:\n\n.. [[[cog\n    from metadata_doc import config_example, config_example\n    config_example(cog, {\n        \"databases\": {\n           \"sf-trees\": {\n               \"queries\": {\n                   \"just_species\": {\n                       \"sql\": \"select qSpecies from Street_Tree_List\"\n                   }\n               }\n           }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            queries:\n              just_species:\n                sql: select qSpecies from Street_Tree_List\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"queries\": {\n                \"just_species\": {\n                  \"sql\": \"select qSpecies from Street_Tree_List\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nSee the :ref:`canned queries documentation <canned_queries>` for more, including how to configure :ref:`writable canned queries <canned_queries_writable>`.\n\n.. _configuration_reference_css_js:\n\nCustom CSS and JavaScript\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDatasette can load additional CSS and JavaScript files, configured in ``datasette.yaml`` like this:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, \"\"\"\n        extra_css_urls:\n        - https://simonwillison.net/static/css/all.bf8cd891642c.css\n        extra_js_urls:\n        - https://code.jquery.com/jquery-3.2.1.slim.min.js\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            extra_css_urls:\n            - https://simonwillison.net/static/css/all.bf8cd891642c.css\n            extra_js_urls:\n            - https://code.jquery.com/jquery-3.2.1.slim.min.js\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"extra_css_urls\": [\n            \"https://simonwillison.net/static/css/all.bf8cd891642c.css\"\n          ],\n          \"extra_js_urls\": [\n            \"https://code.jquery.com/jquery-3.2.1.slim.min.js\"\n          ]\n        }\n.. [[[end]]]\n\nThe extra CSS and JavaScript files will be linked in the ``<head>`` of every page:\n\n.. code-block:: html\n\n    <link rel=\"stylesheet\" href=\"https://simonwillison.net/static/css/all.bf8cd891642c.css\">\n    <script src=\"https://code.jquery.com/jquery-3.2.1.slim.min.js\"></script>\n\nYou can also specify a SRI (subresource integrity hash) for these assets:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        extra_css_urls:\n        - url: https://simonwillison.net/static/css/all.bf8cd891642c.css\n          sri: sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI\n        extra_js_urls:\n        - url: https://code.jquery.com/jquery-3.2.1.slim.min.js\n          sri: sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            extra_css_urls:\n            - url: https://simonwillison.net/static/css/all.bf8cd891642c.css\n              sri: sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI\n            extra_js_urls:\n            - url: https://code.jquery.com/jquery-3.2.1.slim.min.js\n              sri: sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"extra_css_urls\": [\n            {\n              \"url\": \"https://simonwillison.net/static/css/all.bf8cd891642c.css\",\n              \"sri\": \"sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI\"\n            }\n          ],\n          \"extra_js_urls\": [\n            {\n              \"url\": \"https://code.jquery.com/jquery-3.2.1.slim.min.js\",\n              \"sri\": \"sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=\"\n            }\n          ]\n        }\n.. [[[end]]]\n\nThis will produce:\n\n.. code-block:: html\n\n    <link rel=\"stylesheet\" href=\"https://simonwillison.net/static/css/all.bf8cd891642c.css\"\n        integrity=\"sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI\"\n        crossorigin=\"anonymous\">\n    <script src=\"https://code.jquery.com/jquery-3.2.1.slim.min.js\"\n        integrity=\"sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=\"\n        crossorigin=\"anonymous\"></script>\n\nModern browsers will only execute the stylesheet or JavaScript if the SRI hash\nmatches the content served. You can generate hashes using `www.srihash.org <https://www.srihash.org/>`_\n\nItems in ``\"extra_js_urls\"`` can specify ``\"module\": true`` if they reference JavaScript that uses `JavaScript modules <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules>`__. This configuration:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n        extra_js_urls:\n        - url: https://example.datasette.io/module.js\n          module: true\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            extra_js_urls:\n            - url: https://example.datasette.io/module.js\n              module: true\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"extra_js_urls\": [\n            {\n              \"url\": \"https://example.datasette.io/module.js\",\n              \"module\": true\n            }\n          ]\n        }\n.. [[[end]]]\n\nWill produce this HTML:\n\n.. code-block:: html\n\n    <script type=\"module\" src=\"https://example.datasette.io/module.js\"></script>\n\n.. _configuration_reference_table:\n\nTable configuration\n~~~~~~~~~~~~~~~~~~~\n\nDatasette supports a number of table-level configuration options inside ``datasette.yaml``. These are placed under ``databases.database_name.tables.table_name``.\n\n.. _table_configuration_sort:\n\n``sort`` / ``sort_desc``\n^^^^^^^^^^^^^^^^^^^^^^^^\n\nBy default Datasette tables are sorted by primary key. You can set a default sort order for a specific table using the ``sort`` or ``sort_desc`` properties:\n\n.. [[[cog\n    from metadata_doc import config_example\n    import textwrap\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sort: created\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sort: created\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"sort\": \"created\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nOr use ``sort_desc`` to sort in descending order:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sort_desc: created\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sort_desc: created\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"sort_desc\": \"created\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _table_configuration_size:\n\n``size``\n^^^^^^^^\n\nDatasette defaults to displaying 100 rows per page, for both tables and views. You can change this on a per-table or per-view basis using the ``size`` key:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                size: 10\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                size: 10\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"size\": 10\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThis size can still be over-ridden by passing e.g. ``?_size=50`` in the query string.\n\n.. _table_configuration_sortable_columns:\n\n``sortable_columns``\n^^^^^^^^^^^^^^^^^^^^\n\nDatasette allows any column to be used for sorting by default. If you need to control which columns are available for sorting you can do so using ``sortable_columns``:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sortable_columns:\n                - height\n                - weight\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                sortable_columns:\n                - height\n                - weight\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"sortable_columns\": [\n                    \"height\",\n                    \"weight\"\n                  ]\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThis will restrict sorting of ``example_table`` to just the ``height`` and ``weight`` columns.\n\nYou can also disable sorting entirely by setting ``\"sortable_columns\": []``\n\nYou can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          my_database:\n            tables:\n              name_of_view:\n                sortable_columns:\n                - clicks\n                - impressions\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          my_database:\n            tables:\n              name_of_view:\n                sortable_columns:\n                - clicks\n                - impressions\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"my_database\": {\n              \"tables\": {\n                \"name_of_view\": {\n                  \"sortable_columns\": [\n                    \"clicks\",\n                    \"impressions\"\n                  ]\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _table_configuration_label_column:\n\n``label_column``\n^^^^^^^^^^^^^^^^\n\nDatasette's HTML interface attempts to display foreign key references as labelled hyperlinks. By default, it automatically detects a label column using the following rules (in order):\n\n1. If there is exactly one unique text column, use that.\n2. If there is a column called ``name`` or ``title`` (case-insensitive), use that.\n3. If the table has only two columns - a primary key and one other - use the non-primary-key column.\n\nYou can override this automatic detection by specifying which column should be used for the link label with the ``label_column`` property:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                label_column: title\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                label_column: title\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"label_column\": \"title\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _table_configuration_hidden:\n\n``hidden``\n^^^^^^^^^^\n\nYou can hide tables from the database listing view (in the same way that FTS and SpatiaLite tables are automatically hidden) using ``\"hidden\": true``:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                hidden: true\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                hidden: true\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"hidden\": true\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _table_configuration_facets:\n\n``facets`` / ``facet_size``\n^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nYou can turn on facets by default for specific tables. ``facet_size`` controls how many unique values are shown for each facet on that table (the default is controlled by the :ref:`setting_default_facet_size` setting). See :ref:`facets_metadata` for full details.\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          sf-trees:\n            tables:\n              Street_Tree_List:\n                facets:\n                - qLegalStatus\n                facet_size: 10\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            tables:\n              Street_Tree_List:\n                facets:\n                - qLegalStatus\n                facet_size: 10\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"tables\": {\n                \"Street_Tree_List\": {\n                  \"facets\": [\n                    \"qLegalStatus\"\n                  ],\n                  \"facet_size\": 10\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nYou can also specify :ref:`array <facet_by_json_array>` or :ref:`date <facet_by_date>` facets using JSON objects with a single key of ``array`` or ``date``:\n\n.. code-block:: yaml\n\n    facets:\n    - array: tags\n    - date: created\n\n.. _table_configuration_fts:\n\n``fts_table`` / ``fts_pk`` / ``searchmode``\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nThese configure :ref:`full-text search <full_text_search>` for a table or view. See :ref:`full_text_search_table_or_view` for full details.\n\n``fts_table`` specifies which FTS table to use for search. ``fts_pk`` sets the primary key column if it is something other than ``rowid``. ``searchmode`` can be set to ``\"raw\"`` to enable `SQLite advanced search operators <https://www.sqlite.org/fts5.html#full_text_query_syntax>`__.\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          russian-ads:\n            tables:\n              display_ads:\n                fts_table: ads_fts\n                fts_pk: id\n                searchmode: raw\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          russian-ads:\n            tables:\n              display_ads:\n                fts_table: ads_fts\n                fts_pk: id\n                searchmode: raw\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"russian-ads\": {\n              \"tables\": {\n                \"display_ads\": {\n                  \"fts_table\": \"ads_fts\",\n                  \"fts_pk\": \"id\",\n                  \"searchmode\": \"raw\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _table_configuration_column_types:\n\n``column_types``\n^^^^^^^^^^^^^^^^\n\nYou can assign semantic column types to columns, which affect how values are rendered, validated, and transformed. Built-in column types include ``url``, ``email``, and ``json``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.\n\nColumn types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, and ``json`` column types are all restricted to ``TEXT`` columns.\n\nThe simplest form maps column names to type name strings:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                column_types:\n                  website: url\n                  contact: email\n                  extra_data: json\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                column_types:\n                  website: url\n                  contact: email\n                  extra_data: json\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"column_types\": {\n                    \"website\": \"url\",\n                    \"contact\": \"email\",\n                    \"extra_data\": \"json\"\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nFor column types that accept additional configuration, use an object with ``type`` and ``config`` keys:\n\n.. [[[cog\n    config_example(cog, textwrap.dedent(\n      \"\"\"\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                column_types:\n                  website:\n                    type: url\n                    config:\n                      prefix: \"https://\"\n      \"\"\").strip()\n    )\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            tables:\n              example_table:\n                column_types:\n                  website:\n                    type: url\n                    config:\n                      prefix: \"https://\"\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"column_types\": {\n                    \"website\": {\n                      \"type\": \"url\",\n                      \"config\": {\n                        \"prefix\": \"https://\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n"
  },
  {
    "path": "docs/contributing.rst",
    "content": ".. _contributing:\n\nContributing\n============\n\nDatasette is an open source project. We welcome contributions!\n\nThis document describes how to contribute to Datasette core. You can also contribute to the wider Datasette ecosystem by creating new :ref:`plugins`.\n\nGeneral guidelines\n------------------\n\n* **main should always be releasable**. Incomplete features should live in branches. This ensures that any small bug fixes can be quickly released.\n* **The ideal commit** should bundle together the implementation, unit tests and associated documentation updates. The commit message should link to an associated issue.\n* **New plugin hooks** should only be shipped if accompanied by a separate release of a non-demo plugin that uses them.\n* **New user-facing views and documentation** should be added or updated alongside their implementation. The `/docs` folder includes pages for plugin hooks and built-in views—please ensure any new hooks or views are reflected there so the documentation tests continue to pass.\n\n.. _devenvironment:\n\nSetting up a development environment\n------------------------------------\n\nIf you have Python 3.10 or higher installed on your computer (on OS X the quickest way to do this `is using homebrew <https://docs.python-guide.org/starting/install3/osx/>`__) you can install an editable copy of Datasette using the following steps.\n\nIf you want to use GitHub to publish your changes, first `create a fork of datasette <https://github.com/simonw/datasette/fork>`__ under your own GitHub account.\n\nNow clone that repository somewhere on your computer::\n\n    git clone git@github.com:YOURNAME/datasette\n\nIf you want to get started without creating your own fork, you can do this instead::\n\n    git clone git@github.com:simonw/datasette\n\nThe quickest way to set up a development environment is to use `uv <https://github.com/astral-sh/uv>`__. From the repository root you can run the tests directly::\n\n    cd datasette\n    uv run pytest\n\nThis will create a local ``.venv/`` and install Datasette plus its development dependencies.\n\nIf you prefer to manage your own virtual environment with pip, create and activate one and then install the development dependency group::\n\n    python3 -m venv ./venv\n    source venv/bin/activate\n    python3 -m pip install -e . --group dev\n\n.. _contributing_running_tests:\n\nRunning the tests\n-----------------\n\nOnce you have done this, you can run the Datasette unit tests from inside your ``datasette/`` directory using `pytest <https://docs.pytest.org/>`__ like so::\n\n    uv run pytest\n\nYou can run the tests faster using multiple CPU cores with `pytest-xdist <https://pypi.org/project/pytest-xdist/>`__ like this::\n\n    uv run pytest -n auto -m \"not serial\"\n\n``-n auto`` detects the number of available cores automatically. The ``-m \"not serial\"`` skips tests that don't work well in a parallel test environment. You can run those tests separately like so::\n\n    uv run pytest -m \"serial\"\n\n.. _contributing_using_fixtures:\n\nUsing fixtures\n--------------\n\nTo run Datasette itself, type ``datasette``.\n\nYou're going to need at least one SQLite database. A quick way to get started is to use the fixtures database that Datasette uses for its own tests.\n\nYou can create a copy of that database by running this command::\n\n    uv run python tests/fixtures.py fixtures.db\n\nNow you can run Datasette against the new fixtures database like so::\n\n    uv run datasette fixtures.db\n\nThis will start a server at ``http://127.0.0.1:8001/``.\n\nAny changes you make in the ``datasette/templates`` or ``datasette/static`` folder will be picked up immediately (though you may need to do a force-refresh in your browser to see changes to CSS or JavaScript).\n\nIf you want to change Datasette's Python code you can use the ``--reload`` option to cause Datasette to automatically reload any time the underlying code changes::\n\n    uv run datasette --reload fixtures.db\n\nYou can also use the ``fixtures.py`` script to recreate the testing version of ``metadata.json`` used by the unit tests. To do that::\n\n    uv run python tests/fixtures.py fixtures.db fixtures-metadata.json\n\nOr to output the plugins used by the tests, run this::\n\n    uv run python tests/fixtures.py fixtures.db fixtures-metadata.json fixtures-plugins\n    Test tables written to fixtures.db\n    - metadata written to fixtures-metadata.json\n    Wrote plugin: fixtures-plugins/register_output_renderer.py\n    Wrote plugin: fixtures-plugins/view_name.py\n    Wrote plugin: fixtures-plugins/my_plugin.py\n    Wrote plugin: fixtures-plugins/messages_output_renderer.py\n    Wrote plugin: fixtures-plugins/my_plugin_2.py\n\nThen run Datasette like this::\n\n    uv run datasette fixtures.db -m fixtures-metadata.json --plugins-dir=fixtures-plugins/\n\n.. _contributing_debugging:\n\nDebugging\n---------\n\nAny errors that occur while Datasette is running while display a stack trace on the console.\n\nYou can tell Datasette to open an interactive ``pdb`` (or ``ipdb``, if present) debugger session if an error occurs using the ``--pdb`` option::\n\n    uv run datasette --pdb fixtures.db\n\nFor `ipdb <https://pypi.org/project/ipdb/>`__, first run this::\n\n    uv run datasette install ipdb\n\n.. _contributing_formatting:\n\nCode formatting\n---------------\n\nDatasette uses opinionated code formatters: `Black <https://github.com/psf/black>`__ for Python and `Prettier <https://prettier.io/>`__ for JavaScript.\n\nThese formatters are enforced by Datasette's continuous integration: if a commit includes Python or JavaScript code that does not match the style enforced by those tools, the tests will fail.\n\nWhen developing locally, you can verify and correct the formatting of your code using these tools.\n\nIf you are using `Just <https://github.com/casey/just>`__ the quickest way to run these is like so::\n\n    just black\n    just prettier\n\nOr run both at the same time::\n\n    just format\n\n.. _contributing_formatting_black:\n\nRunning Black\n~~~~~~~~~~~~~\n\nBlack is installed as part of the development dependency group. To test that your code complies with Black, run the following in your root ``datasette`` repository checkout::\n\n   uv run black . --check\n\n::\n\n    All done! ✨ 🍰 ✨\n    95 files would be left unchanged.\n\nIf any of your code does not conform to Black you can run this to automatically fix those problems::\n\n    uv run black .\n\n::\n\n    reformatted ../datasette/app.py\n    All done! ✨ 🍰 ✨\n    1 file reformatted, 94 files left unchanged.\n\n.. _contributing_formatting_blacken_docs:\n\nblacken-docs\n~~~~~~~~~~~~\n\nThe `blacken-docs <https://pypi.org/project/blacken-docs/>`__ command applies Black formatting rules to code examples in the documentation. Run it like this::\n\n    uv run blacken-docs -l 60 docs/*.rst\n\n.. _contributing_formatting_prettier:\n\nPrettier\n~~~~~~~~\n\nTo install Prettier, `install Node.js <https://nodejs.org/en/download/package-manager/>`__ and then run the following in the root of your ``datasette`` repository checkout::\n\n    npm install\n\nThis will install Prettier in a ``node_modules`` directory. You can then check that your code matches the coding style like so::\n\n    npm run prettier -- --check\n\n::\n\n    > prettier\n    > prettier 'datasette/static/*[!.min].js' \"--check\"\n\n    Checking formatting...\n    [warn] datasette/static/plugins.js\n    [warn] Code style issues found in the above file(s). Forgot to run Prettier?\n\nYou can fix any problems by running::\n\n    npm run fix\n\n.. _contributing_documentation:\n\nEditing and building the documentation\n--------------------------------------\n\nDatasette's documentation lives in the ``docs/`` directory and is deployed automatically using `Read The Docs <https://readthedocs.org/>`__.\n\nThe documentation is written using reStructuredText. You may find this article on `The subset of reStructuredText worth committing to memory <https://simonwillison.net/2018/Aug/25/restructuredtext/>`__ useful.\n\nYou can build it locally once you have installed the development dependency group (which includes Sphinx and related tools) and then running ``make html`` directly in the ``docs/`` directory::\n\n    cd docs/\n    uv run make html\n\nThis will create the HTML version of the documentation in ``docs/_build/html``. You can open it in your browser like so::\n\n    open _build/html/index.html\n\nAny time you make changes to a ``.rst`` file you can re-run ``make html`` to update the built documents, then refresh them in your browser.\n\nFor added productivity, you can use use `sphinx-autobuild <https://pypi.org/project/sphinx-autobuild/>`__ to run Sphinx in auto-build mode. This will run a local webserver serving the docs that automatically rebuilds them and refreshes the page any time you hit save in your editor.\n\n``sphinx-autobuild`` is included in the development dependency group. In your ``docs/`` directory you can start the server by running the following::\n\n    uv run make livehtml\n\nNow browse to ``http://localhost:8000/`` to view the documentation. Any edits you make should be instantly reflected in your browser.\n\n.. _contributing_documentation_cog:\n\nRunning Cog\n~~~~~~~~~~~\n\nSome pages of documentation (in particular the :ref:`cli_reference`) are automatically updated using `Cog <https://github.com/nedbat/cog>`__.\n\nTo update these pages, run the following command::\n\n    uv run cog -r docs/*.rst\n\n.. _contributing_continuous_deployment:\n\nContinuously deployed demo instances\n------------------------------------\n\nThe demo instance at `latest.datasette.io <https://latest.datasette.io/>`__ is re-deployed automatically to Google Cloud Run for every push to ``main`` that passes the test suite. This is implemented by the GitHub Actions workflow at `.github/workflows/deploy-latest.yml <https://github.com/simonw/datasette/blob/main/.github/workflows/deploy-latest.yml>`__.\n\nSpecific branches can also be set to automatically deploy by adding them to the ``on: push: branches`` block at the top of the workflow YAML file. Branches configured in this way will be deployed to a new Cloud Run service whether or not their tests pass.\n\nThe Cloud Run URL for a branch demo can be found in the GitHub Actions logs.\n\n.. _contributing_release:\n\nRelease process\n---------------\n\nDatasette releases are performed using tags. When a new release is published on GitHub, a `GitHub Action workflow <https://github.com/simonw/datasette/blob/main/.github/workflows/deploy-latest.yml>`__ will perform the following:\n\n* Run the unit tests against all supported Python versions. If the tests pass...\n* Build a Docker image of the release and push a tag to https://hub.docker.com/r/datasetteproject/datasette\n* Re-point the \"latest\" tag on Docker Hub to the new image\n* Build a wheel bundle of the underlying Python source code\n* Push that new wheel up to PyPI: https://pypi.org/project/datasette/\n* If the release is an alpha, navigate to https://readthedocs.org/projects/datasette/versions/ and search for the tag name in the \"Activate a version\" filter, then mark that version as \"active\" to ensure it will appear on the public ReadTheDocs documentation site.\n\nTo deploy new releases you will need to have push access to the main Datasette GitHub repository.\n\nDatasette follows `Semantic Versioning <https://semver.org/>`__::\n\n    major.minor.patch\n\nWe increment ``major`` for backwards-incompatible releases. Datasette is currently pre-1.0 so the major version is always ``0``.\n\nWe increment ``minor`` for new features.\n\nWe increment ``patch`` for bugfix releass.\n\n:ref:`contributing_alpha_beta` may have an additional ``a0`` or ``b0`` prefix - the integer component will be incremented with each subsequent alpha or beta.\n\nTo release a new version, first create a commit that updates the version number in ``datasette/version.py`` and the :ref:`the changelog <changelog>` with highlights of the new version. An example `commit can be seen here <https://github.com/simonw/datasette/commit/0e1e89c6ba3d0fbdb0823272952cf356f3016def>`__::\n\n    # Update changelog\n    git commit -m \" Release 0.51a1\n\n    Refs #1056, #1039, #998, #1045, #1033, #1036, #1034, #976, #1057, #1058, #1053, #1064, #1066\" -a\n    git push\n\nReferencing the issues that are part of the release in the commit message ensures the name of the release shows up on those issue pages, e.g. `here <https://github.com/simonw/datasette/issues/581#ref-commit-d56f402>`__.\n\nYou can generate the list of issue references for a specific release by copying and pasting text from the release notes or GitHub changes-since-last-release view into this `Extract issue numbers from pasted text <https://observablehq.com/@simonw/extract-issue-numbers-from-pasted-text>`__ tool.\n\nTo create the tag for the release, create `a new release <https://github.com/simonw/datasette/releases/new>`__ on GitHub matching the new version number. You can convert the release notes to Markdown by copying and pasting the rendered HTML into this `Paste to Markdown tool <https://euangoddard.github.io/clipboard2markdown/>`__.\n\nDon't forget to create the release from the correct branch - usually ``main``, but sometimes ``0.64.x`` or similar for a bugfix release.\n\nWhile the release is running you can confirm that the correct commits made it into the release using the https://github.com/simonw/datasette/compare/0.64.6...0.64.7 URL.\n\nFinally, post a news item about the release on `datasette.io <https://datasette.io/>`__ by editing the `news.yaml <https://github.com/simonw/datasette.io/blob/main/news.yaml>`__ file in that site's repository.\n\n.. _contributing_alpha_beta:\n\nAlpha and beta releases\n-----------------------\n\nAlpha and beta releases are published to preview upcoming features that may not yet be stable - in particular to preview new plugin hooks.\n\nYou are welcome to try these out, but please be aware that details may change before the final release.\n\nPlease join `discussions on the issue tracker <https://github.com/simonw/datasette/issues>`__ to share your thoughts and experiences with on alpha and beta features that you try out.\n\n.. _contributing_bug_fix_branch:\n\nReleasing bug fixes from a branch\n---------------------------------\n\nIf it's necessary to publish a bug fix release without shipping new features that have landed on ``main`` a release branch can be used.\n\nCreate it from the relevant last tagged release like so::\n\n    git branch 0.52.x 0.52.4\n    git checkout 0.52.x\n\nNext cherry-pick the commits containing the bug fixes::\n\n    git cherry-pick COMMIT\n\nWrite the release notes in the branch, and update the version number in ``version.py``. Then push the branch::\n\n    git push -u origin 0.52.x\n\nOnce the tests have completed, publish the release from that branch target using the GitHub `Draft a new release <https://github.com/simonw/datasette/releases/new>`__ form.\n\nFinally, cherry-pick the commit with the release notes and version number bump across to ``main``::\n\n    git checkout main\n    git cherry-pick COMMIT\n    git push\n\n.. _contributing_upgrading_codemirror:\n\nUpgrading CodeMirror\n--------------------\n\nDatasette bundles `CodeMirror <https://codemirror.net/>`__ for the SQL editing interface, e.g. on `this page <https://latest.datasette.io/fixtures>`__. Here are the steps for upgrading to a new version of CodeMirror:\n\n* Install the packages with::\n\n    npm i codemirror @codemirror/lang-sql\n\n* Build the bundle using the version number from package.json with::\n\n    node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \\\n      -f iife \\\n      -n cm \\\n      -o datasette/static/cm-editor-6.0.1.bundle.js \\\n      -p @rollup/plugin-node-resolve \\\n      -p @rollup/plugin-terser\n\n* Update the version reference in the ``codemirror.html`` template.\n"
  },
  {
    "path": "docs/csv_export.rst",
    "content": ".. _csv_export:\n\nCSV export\n==========\n\nAny Datasette table, view or custom SQL query can be exported as CSV.\n\nTo obtain the CSV representation of the table you are looking, click the \"this\ndata as CSV\" link.\n\nYou can also use the advanced export form for more control over the resulting\nfile, which looks like this and has the following options:\n\n.. image:: https://github.com/simonw/datasette-screenshots/blob/0.62/advanced-export.png?raw=true\n   :alt: Advanced export form. You can get the data in different JSON shapes, and CSV options are download file, expand labels and stream all rows.\n\n* **download file** - instead of displaying CSV in your browser, this forces\n  your browser to download the CSV to your downloads directory.\n\n* **expand labels** - if your table has any foreign key references this option\n  will cause the CSV to gain additional ``COLUMN_NAME_label`` columns with a\n  label for each foreign key derived from the linked table. `In this example\n  <https://latest.datasette.io/fixtures/facetable.csv?_labels=on&_size=max>`_\n  the ``city_id`` column is accompanied by a ``city_id_label`` column.\n\n* **stream all rows** - by default CSV files only contain the first\n  :ref:`setting_max_returned_rows` records. This option will cause Datasette to\n  loop through every matching record and return them as a single CSV file.\n\nYou can try that out on https://latest.datasette.io/fixtures/facetable?_size=4\n\n.. _csv_export_url_parameters:\n\nURL parameters\n--------------\n\nThe following options can be used to customize the CSVs returned by Datasette.\n\n``?_header=off``\n    This removes the first row of the CSV file specifying the headings - only the row data will be returned.\n\n``?_stream=on``\n    Stream all matching records, not just the first page of results. See below.\n\n``?_dl=on``\n    Causes Datasette to return a ``content-disposition: attachment; filename=\"filename.csv\"`` header.\n\nStreaming all records\n---------------------\n\nThe *stream all rows* option is designed to be as efficient as possible -\nunder the hood it takes advantage of Python 3 asyncio capabilities and\nDatasette's efficient :ref:`pagination <pagination>` to stream back the full\nCSV file.\n\nSince databases can get pretty large, by default this option is capped at 100MB -\nif a table returns more than 100MB of data the last line of the CSV will be a\ntruncation error message.\n\nYou can increase or remove this limit using the :ref:`setting_max_csv_mb` config\nsetting. You can also disable the CSV export feature entirely using\n:ref:`setting_allow_csv_stream`.\n"
  },
  {
    "path": "docs/custom_templates.rst",
    "content": ".. _customization:\n\nCustom pages and templates\n==========================\n\nDatasette provides a number of ways of customizing the way data is displayed.\n\nCSS classes on the <body>\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nEvery default template includes CSS classes in the body designed to support\ncustom styling.\n\nThe index template (the top level page at ``/``) gets this:\n\n.. code-block:: html\n\n    <body class=\"index\">\n\nThe database template (``/dbname``) gets this:\n\n.. code-block:: html\n\n    <body class=\"db db-dbname\">\n\nThe custom SQL template (``/dbname?sql=...``) gets this:\n\n.. code-block:: html\n\n    <body class=\"query db-dbname\">\n\nA canned query template (``/dbname/queryname``) gets this:\n\n.. code-block:: html\n\n    <body class=\"query db-dbname query-queryname\">\n\nThe table template (``/dbname/tablename``) gets:\n\n.. code-block:: html\n\n    <body class=\"table db-dbname table-tablename\">\n\nThe row template (``/dbname/tablename/rowid``) gets:\n\n.. code-block:: html\n\n    <body class=\"row db-dbname table-tablename\">\n\nThe ``db-x`` and ``table-x`` classes use the database or table names themselves if\nthey are valid CSS identifiers. If they aren't, we strip any invalid\ncharacters out and append a 6 character md5 digest of the original name, in\norder to ensure that multiple tables which resolve to the same stripped\ncharacter version still have different CSS classes.\n\nSome examples::\n\n    \"simple\" => \"simple\"\n    \"MixedCase\" => \"MixedCase\"\n    \"-no-leading-hyphens\" => \"no-leading-hyphens-65bea6\"\n    \"_no-leading-underscores\" => \"no-leading-underscores-b921bc\"\n    \"no spaces\" => \"no-spaces-7088d7\"\n    \"-\" => \"336d5e\"\n    \"no $ characters\" => \"no--characters-59e024\"\n\n``<td>`` and ``<th>`` elements also get custom CSS classes reflecting the\ndatabase column they are representing, for example:\n\n.. code-block:: html\n\n    <table>\n        <thead>\n            <tr>\n                <th class=\"col-id\" scope=\"col\">id</th>\n                <th class=\"col-name\" scope=\"col\">name</th>\n            </tr>\n        </thead>\n        <tbody>\n            <tr>\n                <td class=\"col-id\"><a href=\"...\">1</a></td>\n                <td class=\"col-name\">SMITH</td>\n            </tr>\n        </tbody>\n    </table>\n\n.. _customization_css:\n\nWriting custom CSS\n~~~~~~~~~~~~~~~~~~\n\nCustom templates need to take Datasette's default CSS into account. The pattern portfolio at ``/-/patterns`` (`example here <https://latest.datasette.io/-/patterns>`__) is a useful reference for understanding the available CSS classes.\n\nThe ``core`` class is particularly useful - you can apply this directly to a ``<input>`` or ``<button>`` element to get Datasette's default form styles, or you can apply it to a containing element (such as ``<form>``) to apply those styles to all of the form elements within it.\n\n.. _customization_static_files:\n\nServing static files\n~~~~~~~~~~~~~~~~~~~~\n\nDatasette can serve static files for you, using the ``--static`` option.\nConsider the following directory structure::\n\n    metadata.json\n    static-files/styles.css\n    static-files/app.js\n\nYou can start Datasette using ``--static assets:static-files/`` to serve those\nfiles from the ``/assets/`` mount point::\n\n    datasette --config datasette.yaml --static assets:static-files/ --memory\n\nThe following URLs will now serve the content from those CSS and JS files::\n\n    http://localhost:8001/assets/styles.css\n    http://localhost:8001/assets/app.js\n\nYou can reference those files from ``datasette.yaml`` like this, see :ref:`custom CSS and JavaScript <configuration_reference_css_js>` for more details:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, \"\"\"\n        extra_css_urls:\n        - /assets/styles.css\n        extra_js_urls:\n        - /assets/app.js\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n            extra_css_urls:\n            - /assets/styles.css\n            extra_js_urls:\n            - /assets/app.js\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"extra_css_urls\": [\n            \"/assets/styles.css\"\n          ],\n          \"extra_js_urls\": [\n            \"/assets/app.js\"\n          ]\n        }\n.. [[[end]]]\n\nPublishing static assets\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe :ref:`cli_publish` command can be used to publish your static assets,\nusing the same syntax as above::\n\n    datasette publish cloudrun mydb.db --static assets:static-files/\n\nThis will upload the contents of the ``static-files/`` directory as part of the\ndeployment, and configure Datasette to correctly serve the assets from ``/assets/``.\n\n.. _customization_custom_templates:\n\nCustom templates\n----------------\n\nBy default, Datasette uses default templates that ship with the package.\n\nYou can over-ride these templates by specifying a custom ``--template-dir`` like\nthis::\n\n    datasette mydb.db --template-dir=mytemplates/\n\nDatasette will now first look for templates in that directory, and fall back on\nthe defaults if no matches are found.\n\nIt is also possible to over-ride templates on a per-database, per-row or per-\ntable basis.\n\nThe lookup rules Datasette uses are as follows::\n\n    Index page (/):\n        index.html\n\n    Database page (/mydatabase):\n        database-mydatabase.html\n        database.html\n\n    Custom query page (/mydatabase?sql=...):\n        query-mydatabase.html\n        query.html\n\n    Canned query page (/mydatabase/canned-query):\n        query-mydatabase-canned-query.html\n        query-mydatabase.html\n        query.html\n\n    Table page (/mydatabase/mytable):\n        table-mydatabase-mytable.html\n        table.html\n\n    Row page (/mydatabase/mytable/id):\n        row-mydatabase-mytable.html\n        row.html\n\n    Table of rows and columns include on table page:\n        _table-table-mydatabase-mytable.html\n        _table-mydatabase-mytable.html\n        _table.html\n\n    Table of rows and columns include on row page:\n        _table-row-mydatabase-mytable.html\n        _table-mydatabase-mytable.html\n        _table.html\n\nIf a table name has spaces or other unexpected characters in it, the template\nfilename will follow the same rules as our custom ``<body>`` CSS classes - for\nexample, a table called \"Food Trucks\" will attempt to load the following\ntemplates::\n\n    table-mydatabase-Food-Trucks-399138.html\n    table.html\n\nYou can find out which templates were considered for a specific page by viewing\nsource on that page and looking for an HTML comment at the bottom. The comment\nwill look something like this::\n\n    <!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->\n\nThis example is from the canned query page for a query called \"tz\" in the\ndatabase called \"mydb\". The asterisk shows which template was selected - so in\nthis case, Datasette found a template file called ``query-mydb-tz.html`` and\nused that - but if that template had not been found, it would have tried for\n``query-mydb.html`` or the default ``query.html``.\n\nIt is possible to extend the default templates using Jinja template\ninheritance. If you want to customize EVERY row template with some additional\ncontent you can do so by creating a ``row.html`` template like this:\n\n.. code-block:: jinja\n\n    {% extends \"default:row.html\" %}\n\n    {% block content %}\n    <h1>EXTRA HTML AT THE TOP OF THE CONTENT BLOCK</h1>\n    <p>This line renders the original block:</p>\n    {{ super() }}\n    {% endblock %}\n\nNote the ``default:row.html`` template name, which ensures Jinja will inherit\nfrom the default template.\n\nThe ``_table.html`` template is included by both the row and the table pages,\nand a list of rows. The default ``_table.html`` template renders them as an\nHTML template and `can be seen here <https://github.com/simonw/datasette/blob/main/datasette/templates/_table.html>`_.\n\nYou can provide a custom template that applies to all of your databases and\ntables, or you can provide custom templates for specific tables using the\ntemplate naming scheme described above.\n\nIf you want to present your data in a format other than an HTML table, you\ncan do so by looping through ``display_rows`` in your own ``_table.html``\ntemplate. You can use ``{{ row[\"column_name\"] }}`` to output the raw value\nof a specific column.\n\nIf you want to output the rendered HTML version of a column, including any\nlinks to foreign keys, you can use ``{{ row.display(\"column_name\") }}``.\n\nHere is an example of a custom ``_table.html`` template:\n\n.. code-block:: jinja\n\n    {% for row in display_rows %}\n        <div>\n            <h2>{{ row[\"title\"] }}</h2>\n            <p>{{ row[\"description\"] }}<lp>\n            <p>Category: {{ row.display(\"category_id\") }}</p>\n        </div>\n    {% endfor %}\n\n.. _custom_pages:\n\nCustom pages\n------------\n\nYou can add templated pages to your Datasette instance by creating HTML files in a ``pages`` directory within your ``templates`` directory.\n\nFor example, to add a custom page that is served at ``http://localhost/about`` you would create a file in ``templates/pages/about.html``, then start Datasette like this::\n\n    datasette mydb.db --template-dir=templates/\n\nYou can nest directories within pages to create a nested structure. To create a ``http://localhost:8001/about/map`` page you would create ``templates/pages/about/map.html``.\n\n.. _custom_pages_parameters:\n\nPath parameters for pages\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can define custom pages that match multiple paths by creating files with ``{variable}`` definitions in their filenames.\n\nFor example, to capture any request to a URL matching ``/about/*``, you would create a template in the following location::\n\n    templates/pages/about/{slug}.html\n\nA hit to ``/about/news`` would render that template and pass in a variable called ``slug`` with a value of ``\"news\"``.\n\nIf you use this mechanism don't forget to return a 404 if the referenced content could not be found. You can do this using ``{{ raise_404() }}`` described below.\n\nTemplates defined using custom page routes work particularly well with the ``sql()`` template function from `datasette-template-sql <https://github.com/simonw/datasette-template-sql>`__ or the ``graphql()`` template function from `datasette-graphql <https://github.com/simonw/datasette-graphql#the-graphql-template-function>`__.\n\n.. _custom_pages_headers:\n\nCustom headers and status codes\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nCustom pages default to being served with a content-type of ``text/html; charset=utf-8`` and a ``200`` status code. You can change these by calling a custom function from within your template.\n\nFor example, to serve a custom page with a ``418 I'm a teapot`` HTTP status code, create a file in ``pages/teapot.html`` containing the following:\n\n.. code-block:: jinja\n\n    {{ custom_status(418) }}\n    <html>\n    <head><title>Teapot</title></head>\n    <body>\n    I'm a teapot\n    </body>\n    </html>\n\nTo serve a custom HTTP header, add a ``custom_header(name, value)`` function call. For example:\n\n.. code-block:: jinja\n\n    {{ custom_status(418) }}\n    {{ custom_header(\"x-teapot\", \"I am\") }}\n    <html>\n    <head><title>Teapot</title></head>\n    <body>\n    I'm a teapot\n    </body>\n    </html>\n\nYou can verify this is working using ``curl`` like this::\n\n    curl -I 'http://127.0.0.1:8001/teapot'\n    HTTP/1.1 418\n    date: Sun, 26 Apr 2020 18:38:30 GMT\n    server: uvicorn\n    x-teapot: I am\n    content-type: text/html; charset=utf-8\n\n.. _custom_pages_404:\n\nReturning 404s\n~~~~~~~~~~~~~~\n\nTo indicate that content could not be found and display the default 404 page you can use the ``raise_404(message)`` function:\n\n.. code-block:: jinja\n\n    {% if not rows %}\n        {{ raise_404(\"Content not found\") }}\n    {% endif %}\n\nIf you call ``raise_404()`` the other content in your template will be ignored.\n\n.. _custom_pages_redirects:\n\nCustom redirects\n~~~~~~~~~~~~~~~~\n\nYou can use the ``custom_redirect(location)`` function to redirect users to another page, for example in a file called ``pages/datasette.html``:\n\n.. code-block:: jinja\n\n    {{ custom_redirect(\"https://github.com/simonw/datasette\") }}\n\nNow requests to ``http://localhost:8001/datasette`` will result in a redirect.\n\nThese redirects are served with a ``302 Found`` status code by default. You can send a ``301 Moved Permanently`` code by passing ``301`` as the second argument to the function:\n\n.. code-block:: jinja\n\n    {{ custom_redirect(\"https://github.com/simonw/datasette\", 301) }}\n\n.. _custom_pages_errors:\n\nCustom error pages\n------------------\n\nDatasette returns an error page if an unexpected error occurs, access is forbidden or content cannot be found.\n\nYou can customize the response returned for these errors by providing a custom error page template.\n\nContent not found errors use a ``404.html`` template. Access denied errors use ``403.html``. Invalid input errors use ``400.html``. Unexpected errors of other kinds use ``500.html``.\n\nIf a template for the specific error code is not found a template called ``error.html`` will be used instead. If you do not provide that template Datasette's `default error.html template <https://github.com/simonw/datasette/blob/main/datasette/templates/error.html>`__ will be used.\n\nThe error template will be passed the following context:\n\n``status`` - integer\n    The integer HTTP status code, e.g. 404, 500, 403, 400.\n\n``error`` - string\n    Details of the specific error, usually a full sentence.\n\n``title`` - string or None\n    A title for the page representing the class of error. This is often ``None`` for errors that do not provide a title separate from their ``error`` message.\n"
  },
  {
    "path": "docs/deploying.rst",
    "content": ".. _deploying:\n\n=====================\n Deploying Datasette\n=====================\n\nThe quickest way to deploy a Datasette instance on the internet is to use the ``datasette publish`` command, described in :ref:`publishing`. This can be used to quickly deploy Datasette to a number of hosting providers including Heroku, Google Cloud Run and Vercel.\n\nYou can deploy Datasette to other hosting providers using the instructions on this page.\n\n.. _deploying_fundamentals:\n\nDeployment fundamentals\n=======================\n\nDatasette can be deployed as a single ``datasette`` process that listens on a port. Datasette is not designed to be run as root, so that process should listen on a higher port such as port 8000.\n\nIf you want to serve Datasette on port 80 (the HTTP default port) or port 443 (for HTTPS) you should run it behind a proxy server, such as nginx, Apache or HAProxy. The proxy server can listen on port 80/443 and forward traffic on to Datasette.\n\n.. _deploying_systemd:\n\nRunning Datasette using systemd\n===============================\n\nYou can run Datasette on Ubuntu or Debian systems using ``systemd``.\n\nFirst, ensure you have Python 3 and ``pip`` installed. On Ubuntu you can use ``sudo apt-get install python3 python3-pip``.\n\nYou can install Datasette into a virtual environment, or you can install it system-wide. To install system-wide, use ``sudo pip3 install datasette``.\n\nNow create a folder for your Datasette databases, for example using ``mkdir /home/ubuntu/datasette-root``.\n\nYou can copy a test database into that folder like so::\n\n    cd /home/ubuntu/datasette-root\n    curl -O https://latest.datasette.io/fixtures.db\n\nCreate a file at ``/etc/systemd/system/datasette.service`` with the following contents:\n\n.. code-block:: ini\n\n    [Unit]\n    Description=Datasette\n    After=network.target\n\n    [Service]\n    Type=simple\n    User=ubuntu\n    Environment=DATASETTE_SECRET=\n    WorkingDirectory=/home/ubuntu/datasette-root\n    ExecStart=datasette serve . -h 127.0.0.1 -p 8000\n    Restart=on-failure\n\n    [Install]\n    WantedBy=multi-user.target\n\nAdd a random value for the ``DATASETTE_SECRET`` - this will be used to sign Datasette cookies such as the CSRF token cookie. You can generate a suitable value like so::\n\n    python3 -c 'import secrets; print(secrets.token_hex(32))'\n\nThis configuration will run Datasette against all database files contained in the ``/home/ubuntu/datasette-root`` directory. If that directory contains a ``metadata.yml`` (or ``.json``) file or a ``templates/`` or ``plugins/`` sub-directory those will automatically be loaded by Datasette - see :ref:`config_dir` for details.\n\nYou can start the Datasette process running using the following::\n\n    sudo systemctl daemon-reload\n    sudo systemctl start datasette.service\n\nYou will need to restart the Datasette service after making changes to its ``metadata.json`` configuration or adding a new database file to that directory. You can do that using::\n\n    sudo systemctl restart datasette.service\n\nOnce the service has started you can confirm that Datasette is running on port 8000 like so::\n\n    curl 127.0.0.1:8000/-/versions.json\n    # Should output JSON showing the installed version\n\nDatasette will not be accessible from outside the server because it is listening on ``127.0.0.1``. You can expose it by instead listening on ``0.0.0.0``, but a better way is to set up a proxy such as ``nginx`` - see :ref:`deploying_proxy`.\n\n.. _deploying_openrc:\n\nRunning Datasette using OpenRC\n==============================\nOpenRC is the service manager on non-systemd Linux distributions like `Alpine Linux <https://www.alpinelinux.org/>`__ and `Gentoo <https://www.gentoo.org/>`__.\n\nCreate an init script at ``/etc/init.d/datasette`` with the following contents:\n\n.. code-block:: sh\n\n    #!/sbin/openrc-run\n\n    name=\"datasette\"\n    command=\"datasette\"\n    command_args=\"serve -h 0.0.0.0 /path/to/db.db\"\n    command_background=true\n    pidfile=\"/run/${RC_SVCNAME}.pid\"\n\nYou then need to configure the service to run at boot and start it::\n\n    rc-update add datasette\n    rc-service datasette start\n\n.. _deploying_buildpacks:\n\nDeploying using buildpacks\n==========================\n\nSome hosting providers such as `Heroku <https://www.heroku.com/>`__, `DigitalOcean App Platform <https://www.digitalocean.com/docs/app-platform/>`__ and `Scalingo <https://scalingo.com/>`__ support the `Buildpacks standard <https://buildpacks.io/>`__ for deploying Python web applications.\n\nDeploying Datasette on these platforms requires two files: ``requirements.txt`` and ``Procfile``.\n\nThe ``requirements.txt`` file lets the platform know which Python packages should be installed. It should contain ``datasette`` at a minimum, but can also list any Datasette plugins you wish to install - for example::\n\n    datasette\n    datasette-vega\n\nThe ``Procfile`` lets the hosting platform know how to run the command that serves web traffic. It should look like this::\n\n    web: datasette . -h 0.0.0.0 -p $PORT --cors\n\nThe ``$PORT`` environment variable is provided by the hosting platform. ``--cors`` enables CORS requests from JavaScript running on other websites to your domain - omit this if you don't want to allow CORS. You can add additional Datasette :ref:`settings` options here too.\n\nThese two files should be enough to deploy Datasette on any host that supports buildpacks. Datasette will serve any SQLite files that are included in the root directory of the application.\n\nIf you want to build SQLite files or download them as part of the deployment process you can do so using a ``bin/post_compile`` file. For example, the following ``bin/post_compile`` will download an example database that will then be served by Datasette::\n\n    wget https://fivethirtyeight.datasettes.com/fivethirtyeight.db\n\n`simonw/buildpack-datasette-demo <https://github.com/simonw/buildpack-datasette-demo>`__ is an example GitHub repository showing a Datasette configuration that can be deployed to a buildpack-supporting host.\n\n.. _deploying_proxy:\n\nRunning Datasette behind a proxy\n================================\n\nYou may wish to run Datasette behind an Apache or nginx proxy, using a path within your existing site.\n\nYou can use the :ref:`setting_base_url` configuration setting to tell Datasette to serve traffic with a specific URL prefix. For example, you could run Datasette like this::\n\n    datasette my-database.db --setting base_url /my-datasette/ -p 8009\n\nThis will run Datasette with the following URLs:\n\n- ``http://127.0.0.1:8009/my-datasette/`` - the Datasette homepage\n- ``http://127.0.0.1:8009/my-datasette/my-database`` - the page for the ``my-database.db`` database\n- ``http://127.0.0.1:8009/my-datasette/my-database/some_table`` - the page for the ``some_table`` table\n\nYou can now set your nginx or Apache server to proxy the ``/my-datasette/`` path to this Datasette instance.\n\nNginx proxy configuration\n-------------------------\n\nHere is an example of an `nginx <https://nginx.org/>`__ configuration file that will proxy traffic to Datasette::\n\n    daemon off;\n\n    events {\n      worker_connections  1024;\n    }\n    http {\n      server {\n        listen 80;\n        location /my-datasette {\n          proxy_pass http://127.0.0.1:8009/my-datasette;\n          proxy_set_header Host $host;\n        }\n      }\n    }\n\nYou can also use the ``--uds`` option to Datasette to listen on a Unix domain socket instead of a port, configuring the nginx upstream proxy like this::\n\n    daemon off;\n    events {\n      worker_connections  1024;\n    }\n    http {\n      server {\n        listen 80;\n        location /my-datasette {\n          proxy_pass http://datasette/my-datasette;\n          proxy_set_header Host $host;\n        }\n      }\n      upstream datasette {\n        server unix:/tmp/datasette.sock;\n      }\n    }\n\nThen run Datasette with ``datasette --uds /tmp/datasette.sock path/to/database.db --setting base_url /my-datasette/``.\n\nApache proxy configuration\n--------------------------\n\nFor `Apache <https://httpd.apache.org/>`__, you can use the ``ProxyPass`` directive. First make sure the following lines are uncommented::\n\n    LoadModule proxy_module lib/httpd/modules/mod_proxy.so\n    LoadModule proxy_http_module lib/httpd/modules/mod_proxy_http.so\n\nThen add these directives to proxy traffic::\n\n    ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/\n    ProxyPreserveHost On\n\nA live demo of Datasette running behind Apache using this proxy setup can be seen at `datasette-apache-proxy-demo.datasette.io/prefix/ <https://datasette-apache-proxy-demo.datasette.io/prefix/>`__. The code for that demo can be found in the `demos/apache-proxy <https://github.com/simonw/datasette/tree/main/demos/apache-proxy>`__ directory.\n\nUsing ``--uds`` you can use Unix domain sockets similar to the nginx example::\n\n    ProxyPass /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/\n\nThe `ProxyPreserveHost On <https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypreservehost>`__ directive ensures that the original ``Host:`` header from the incoming request is passed through to Datasette. Datasette needs this to correctly assemble links to other pages using the :ref:`datasette_absolute_url` method.\n"
  },
  {
    "path": "docs/ecosystem.rst",
    "content": ".. _ecosystem:\n\n=======================\nThe Datasette Ecosystem\n=======================\n\nDatasette sits at the center of a growing ecosystem of open source tools aimed at making it as easy as possible to gather, analyze and publish interesting data.\n\nThese tools are divided into two main groups: tools for building SQLite databases (for use with Datasette) and plugins that extend Datasette's functionality.\n\nThe `Datasette project website <https://datasette.io/>`__ includes a directory of plugins and a directory of tools:\n\n- `Plugins directory on datasette.io <https://datasette.io/plugins>`__\n- `Tools directory on datasette.io <https://datasette.io/tools>`__\n\nsqlite-utils\n============\n\n`sqlite-utils <https://sqlite-utils.datasette.io/>`__ is a key building block for the wider Datasette ecosystem. It provides a collection of utilities for manipulating SQLite databases, both as a Python library and a command-line utility. Features include:\n\n- Insert data into a SQLite database from JSON, CSV or TSV, automatically creating tables with the correct schema or altering existing tables to add missing columns.\n- Configure tables for use with SQLite full-text search, including creating triggers needed to keep the search index up-to-date.\n- Modify tables in ways that are not supported by SQLite's default ``ALTER TABLE`` syntax - for example changing the types of columns or selecting a new primary key for a table.\n- Adding foreign keys to existing database tables.\n- Extracting columns of data into a separate lookup table.\n\nDogsheep\n========\n\n`Dogsheep <https://dogsheep.github.io/>`__ is a collection of tools for personal analytics using SQLite and Datasette. The project provides tools like `github-to-sqlite <https://datasette.io/tools/github-to-sqlite>`__ and `twitter-to-sqlite <https://datasette.io/tools/twitter-to-sqlite>`__ that can import data from different sources in order to create a personal data warehouse. `Personal Data Warehouses: Reclaiming Your Data <https://simonwillison.net/2020/Nov/14/personal-data-warehouses/>`__ is a talk that explains Dogsheep and demonstrates it in action.\n\n"
  },
  {
    "path": "docs/events.md",
    "content": "(events)=\n# Events\n\nDatasette includes a mechanism for tracking events that occur while the software is running. This is primarily intended to be used by plugins, which can both trigger events and listen for events.\n\nThe core Datasette application triggers events when certain things happen. This page describes those events.\n\nPlugins can listen for events using the {ref}`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes - or additional classes {ref}`registered by other plugins <plugin_hook_register_events>`.\n\n```{eval-rst}\n.. automodule:: datasette.events\n    :members:\n    :exclude-members: Event\n```\n"
  },
  {
    "path": "docs/facets.rst",
    "content": ".. _facets:\n\nFacets\n======\n\nDatasette facets can be used to add a faceted browse interface to any database table.\nWith facets, tables are displayed along with a summary showing the most common values in specified columns.\nThese values can be selected to further filter the table.\n\nHere's `an example <https://congress-legislators.datasettes.com/legislators/legislator_terms?_facet=type&_facet=party&_facet=state&_facet_size=10>`__:\n\n.. image:: https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png\n   :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.\n\nFacets can be specified in two ways: using query string parameters, or in ``metadata.json`` configuration for the table.\n\nFacets in query strings\n-----------------------\n\nTo turn on faceting for specific columns on a Datasette table view, add one or more ``_facet=COLUMN`` parameters to the URL.\nFor example, if you want to turn on facets for the ``city_id`` and ``state`` columns, construct a URL that looks like this::\n\n    /dbname/tablename?_facet=state&_facet=city_id\n\nThis works for both the HTML interface and the ``.json`` view.\nWhen enabled, facets will cause a ``facet_results`` block to be added to the JSON output, looking something like this:\n\n.. code-block:: json\n\n    {\n      \"state\": {\n        \"name\": \"state\",\n        \"results\": [\n          {\n            \"value\": \"CA\",\n            \"label\": \"CA\",\n            \"count\": 10,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&state=CA\",\n            \"selected\": false\n          },\n          {\n            \"value\": \"MI\",\n            \"label\": \"MI\",\n            \"count\": 4,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&state=MI\",\n            \"selected\": false\n          },\n          {\n            \"value\": \"MC\",\n            \"label\": \"MC\",\n            \"count\": 1,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&state=MC\",\n            \"selected\": false\n          }\n        ],\n        \"truncated\": false\n      }\n      \"city_id\": {\n        \"name\": \"city_id\",\n        \"results\": [\n          {\n            \"value\": 1,\n            \"label\": \"San Francisco\",\n            \"count\": 6,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&city_id=1\",\n            \"selected\": false\n          },\n          {\n            \"value\": 2,\n            \"label\": \"Los Angeles\",\n            \"count\": 4,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&city_id=2\",\n            \"selected\": false\n          },\n          {\n            \"value\": 3,\n            \"label\": \"Detroit\",\n            \"count\": 4,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&city_id=3\",\n            \"selected\": false\n          },\n          {\n            \"value\": 4,\n            \"label\": \"Memnonia\",\n            \"count\": 1,\n            \"toggle_url\": \"http://...?_facet=city_id&_facet=state&city_id=4\",\n            \"selected\": false\n          }\n        ],\n        \"truncated\": false\n      }\n    }\n\nIf Datasette detects that a column is a foreign key, the ``\"label\"`` property will be automatically derived from the detected label column on the referenced table.\n\nThe default number of facet results returned is 30, controlled by the :ref:`setting_default_facet_size` setting.\nYou can increase this on an individual page by adding ``?_facet_size=100`` to the query string, up to a maximum of :ref:`setting_max_returned_rows` (which defaults to 1000).\n\n.. _facets_metadata:\n\nFacets in configuration\n-----------------------\n\nYou can turn facets on by default for specific tables by adding a ``\"facets\"`` key to the table configuration in ``datasette.yaml``. See also the :ref:`table configuration reference <table_configuration_facets>` for a quick overview.\n\nHere's an example that turns on faceting by default for the ``qLegalStatus`` column in the ``Street_Tree_List`` table in the ``sf-trees`` database:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, {\n      \"databases\": {\n        \"sf-trees\": {\n          \"tables\": {\n            \"Street_Tree_List\": {\n              \"facets\": [\"qLegalStatus\"]\n            }\n          }\n        }\n      }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            tables:\n              Street_Tree_List:\n                facets:\n                - qLegalStatus\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"tables\": {\n                \"Street_Tree_List\": {\n                  \"facets\": [\n                    \"qLegalStatus\"\n                  ]\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nFacets defined in this way will always be shown in the interface and returned in the API, regardless of the ``_facet`` arguments passed to the view.\n\nFacets defined in configuration will be displayed in the order they are listed. Any additional facets added via query string parameters (e.g. ``?_facet=column_name``) will appear after the configured facets, sorted by the number of unique values.\n\nYou can specify :ref:`array <facet_by_json_array>` or :ref:`date <facet_by_date>` facets using JSON objects with a single key of ``array`` or ``date`` and a value specifying the column, like this:\n\n.. [[[cog\n    config_example(cog, {\n      \"facets\": [\n        {\"array\": \"tags\"},\n        {\"date\": \"created\"}\n      ]\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        facets:\n        - array: tags\n        - date: created\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"facets\": [\n            {\n              \"array\": \"tags\"\n            },\n            {\n              \"date\": \"created\"\n            }\n          ]\n        }\n.. [[[end]]]\n\nYou can change the default facet size (the number of results shown for each facet) for a table using ``facet_size``:\n\n.. [[[cog\n    config_example(cog, {\n      \"databases\": {\n        \"sf-trees\": {\n          \"tables\": {\n            \"Street_Tree_List\": {\n              \"facets\": [\"qLegalStatus\"],\n              \"facet_size\": 10\n            }\n          }\n        }\n      }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            tables:\n              Street_Tree_List:\n                facets:\n                - qLegalStatus\n                facet_size: 10\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"tables\": {\n                \"Street_Tree_List\": {\n                  \"facets\": [\n                    \"qLegalStatus\"\n                  ],\n                  \"facet_size\": 10\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nSuggested facets\n----------------\n\nDatasette's table UI will suggest facets for the user to apply, based on the following criteria:\n\nFor the currently filtered data are there any columns which, if applied as a facet...\n\n* Will return 30 or less unique options\n* Will return more than one unique option\n* Will return less unique options than the total number of filtered rows\n* And the query used to evaluate this criteria can be completed in under 50ms\n\nThat last point is particularly important: Datasette runs a query for every column that is displayed on a page, which could get expensive - so to avoid slow load times it sets a time limit of just 50ms for each of those queries.\nThis means suggested facets are unlikely to appear for tables with millions of records in them.\n\nSpeeding up facets with indexes\n-------------------------------\n\nThe performance of facets can be greatly improved by adding indexes on the columns you wish to facet by.\nAdding indexes can be performed using the ``sqlite3`` command-line utility. Here's how to add an index on the ``state`` column in a table called ``Food_Trucks``::\n\n    sqlite3 mydatabase.db\n\n::\n\n    SQLite version 3.19.3 2017-06-27 16:48:08\n    Enter \".help\" for usage hints.\n    sqlite> CREATE INDEX Food_Trucks_state ON Food_Trucks(\"state\");\n\nOr using the `sqlite-utils <https://sqlite-utils.datasette.io/en/stable/cli.html#creating-indexes>`__ command-line utility::\n\n    sqlite-utils create-index mydatabase.db Food_Trucks state\n\n.. _facet_by_json_array:\n\nFacet by JSON array\n-------------------\n\nIf your SQLite installation provides the ``json1`` extension (you can check using :ref:`JsonDataView_versions`) Datasette will automatically detect columns that contain JSON arrays of values and offer a faceting interface against those columns.\n\nThis is useful for modelling things like tags without needing to break them out into a new table.\n\nExample here: `latest.datasette.io/fixtures/facetable?_facet_array=tags <https://latest.datasette.io/fixtures/facetable?_facet_array=tags>`__\n\n.. _facet_by_date:\n\nFacet by date\n-------------\n\nIf Datasette finds any columns that contain dates in the first 100 values, it will offer a faceting interface against the dates of those values.\nThis works especially well against timestamp values such as ``2019-03-01 12:44:00``.\n\nExample here: `latest.datasette.io/fixtures/facetable?_facet_date=created <https://latest.datasette.io/fixtures/facetable?_facet_date=created>`__\n"
  },
  {
    "path": "docs/full_text_search.rst",
    "content": ".. _full_text_search:\n\nFull-text search\n================\n\nSQLite includes `a powerful mechanism for enabling full-text search <https://www.sqlite.org/fts3.html>`_ against SQLite records. Datasette can detect if a table has had full-text search configured for it in the underlying database and display a search interface for filtering that table.\n\nHere's `an example search <https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&_sort_desc=date>`__:\n\n.. image:: https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/regmem-search.png\n   :alt: Screenshot showing a search for hampers against a table full of items - 453 results are returned.\n\nDatasette automatically detects which tables have been configured for full-text search.\n\n.. _full_text_search_table_view_api:\n\nThe table page and table view API\n---------------------------------\n\nTable 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.\n\nTry this example: `fara.datasettes.com/fara/FARA_All_ShortForms?_search=manafort <https://fara.datasettes.com/fara/FARA_All_ShortForms?_search=manafort>`__\n\nSQLite 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::\n\n    /dbname/tablename/?_search=rob*\n\nThis will return all records containing at least one word that starts with the letters ``rob``.\n\nYou can also run searches against just the content of a specific named column by using ``_search_COLNAME=TERMS`` - for example, this would search for just rows where the ``name`` column in the FTS index mentions ``Sarah``::\n\n    /dbname/tablename/?_search_name=Sarah\n\n\n.. _full_text_search_advanced_queries:\n\nAdvanced SQLite search queries\n------------------------------\n\nSQLite full-text search includes support for `a variety of advanced queries <https://www.sqlite.org/fts5.html#full_text_query_syntax>`__, including ``AND``, ``OR``, ``NOT`` and ``NEAR``.\n\nBy default Datasette disables these features to ensure they do not cause errors or confusion for users who are not aware of them. You can disable this escaping and use the advanced queries by adding ``&_searchmode=raw`` to the table page query string.\n\nIf you want to enable these operators by default for a specific table, you can do so by adding ``\"searchmode\": \"raw\"`` to the metadata configuration for that table, see :ref:`full_text_search_table_or_view`.\n\nIf that option has been specified in the table metadata but you want to over-ride it and return to the default behavior you can append ``&_searchmode=escaped`` to the query string.\n\n.. _full_text_search_table_or_view:\n\nConfiguring full-text search for a table or view\n------------------------------------------------\n\nIf 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.\n\nYou 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.\n\nUse ``?_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:\n\nhttps://latest.datasette.io/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk\n\nThe ``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.\n\nThe ``\"searchmode\": \"raw\"`` property can be used to default the table to accepting SQLite advanced search operators, as described in :ref:`full_text_search_advanced_queries`.\n\nHere is an example which enables full-text search (with SQLite advanced search operators) for a ``display_ads`` view which is defined against the ``ads`` table and hence needs to run FTS against the ``ads_fts`` table, using the ``id`` as the primary key:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, {\n        \"databases\": {\n            \"russian-ads\": {\n                \"tables\": {\n                    \"display_ads\": {\n                        \"fts_table\": \"ads_fts\",\n                        \"fts_pk\": \"id\",\n                        \"searchmode\": \"raw\"\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          russian-ads:\n            tables:\n              display_ads:\n                fts_table: ads_fts\n                fts_pk: id\n                searchmode: raw\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"russian-ads\": {\n              \"tables\": {\n                \"display_ads\": {\n                  \"fts_table\": \"ads_fts\",\n                  \"fts_pk\": \"id\",\n                  \"searchmode\": \"raw\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _full_text_search_custom_sql:\n\nSearches using custom SQL\n-------------------------\n\nYou 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.\n\nYou 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>`_::\n\n    /fara/FARA_All_ShortForms?_search=manafort\n\nIf 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:\n\n.. code-block:: sql\n\n    select\n      rowid,\n      Short_Form_Termination_Date,\n      Short_Form_Date,\n      Short_Form_Last_Name,\n      Short_Form_First_Name,\n      Registration_Number,\n      Registration_Date,\n      Registrant_Name,\n      Address_1,\n      Address_2,\n      City,\n      State,\n      Zip\n    from\n      FARA_All_ShortForms\n    where\n      rowid in (\n        select\n          rowid\n        from\n          FARA_All_ShortForms_fts\n        where\n          FARA_All_ShortForms_fts match escape_fts(:search)\n      )\n    order by\n      rowid\n    limit\n      101\n\n.. _full_text_search_enabling:\n\nEnabling full-text search for a SQLite table\n--------------------------------------------\n\nDatasette takes advantage of the `external content <https://www.sqlite.org/fts3.html#_external_content_fts4_tables_>`_ mechanism in SQLite, which allows a full-text search virtual table to be associated with the contents of another SQLite table.\n\nTo set up full-text search for a table, you need to do two things:\n\n* Create a new FTS virtual table associated with your table\n* Populate that FTS table with the data that you would like to be able to run searches against\n\nConfiguring FTS using sqlite-utils\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n`sqlite-utils <https://sqlite-utils.datasette.io/>`__ is a CLI utility and Python library for manipulating SQLite databases. You can use `it from Python code <https://sqlite-utils.datasette.io/en/latest/python-api.html#enabling-full-text-search>`__ to configure FTS search, or you can achieve the same goal `using the accompanying command-line tool <https://sqlite-utils.datasette.io/en/latest/cli.html#configuring-full-text-search>`__.\n\nHere's how to use ``sqlite-utils`` to enable full-text search for an ``items`` table across the ``name`` and ``description`` columns::\n\n    sqlite-utils enable-fts mydatabase.db items name description\n\nConfiguring FTS using csvs-to-sqlite\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nIf your data starts out in CSV files, you can use Datasette's companion tool `csvs-to-sqlite <https://github.com/simonw/csvs-to-sqlite>`__ to convert that file into a SQLite database and enable full-text search on specific columns. For a file called ``items.csv`` where you want full-text search to operate against the ``name`` and ``description`` columns you would run the following::\n\n    csvs-to-sqlite items.csv items.db -f name -f description\n\nConfiguring FTS by hand\n~~~~~~~~~~~~~~~~~~~~~~~\n\nWe recommend using `sqlite-utils <https://sqlite-utils.datasette.io/>`__, but if you want to hand-roll a SQLite full-text search table you can do so using the following SQL.\n\nTo enable full-text search for a table called ``items`` that works against the ``name`` and ``description`` columns, you would run this SQL to create a new ``items_fts`` FTS virtual table:\n\n.. code-block:: sql\n\n    CREATE VIRTUAL TABLE \"items_fts\" USING FTS4 (\n        name,\n        description,\n        content=\"items\"\n    );\n\nThis creates a set of tables to power full-text search against ``items``. The new ``items_fts`` table will be detected by Datasette as the ``fts_table`` for the ``items`` table.\n\nCreating the table is not enough: you also need to populate it with a copy of the data that you wish to make searchable. You can do that using the following SQL:\n\n.. code-block:: sql\n\n    INSERT INTO \"items_fts\" (rowid, name, description)\n        SELECT rowid, name, description FROM items;\n\nIf your table has columns that are foreign key references to other tables you can include that data in your full-text search index using a join. Imagine the ``items`` table has a foreign key column called ``category_id`` which refers to a ``categories`` table - you could create a full-text search table like this:\n\n.. code-block:: sql\n\n    CREATE VIRTUAL TABLE \"items_fts\" USING FTS4 (\n        name,\n        description,\n        category_name,\n        content=\"items\"\n    );\n\nAnd then populate it like this:\n\n.. code-block:: sql\n\n    INSERT INTO \"items_fts\" (rowid, name, description, category_name)\n        SELECT items.rowid,\n        items.name,\n        items.description,\n        categories.name\n        FROM items JOIN categories ON items.category_id=categories.id;\n\nYou can use this technique to populate the full-text search index from any combination of tables and joins that makes sense for your project.\n\n.. _full_text_search_fts_versions:\n\nFTS versions\n------------\n\nThere are three different versions of the SQLite FTS module: FTS3, FTS4 and FTS5. You can tell which versions are supported by your instance of Datasette by checking the ``/-/versions`` page.\n\nFTS5 is the most advanced module but may not be available in the SQLite version that is bundled with your Python installation. Most importantly, FTS5 is the only version that has the ability to order by search relevance without needing extra code.\n\nIf you can't be sure that FTS5 will be available, you should use FTS4.\n"
  },
  {
    "path": "docs/getting_started.rst",
    "content": "Getting started\n===============\n\n.. _getting_started_demo:\n\nPlay with a live demo\n---------------------\n\nThe best way to experience Datasette for the first time is with a demo:\n\n* `datasette.io/global-power-plants <https://datasette.io/global-power-plants/global-power-plants>`__ provides a searchable database of power plants around the world, using data from the `World Resources Institude <https://www.wri.org/publication/global-power-plant-database>`__ rendered using the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`__ plugin.\n* `fivethirtyeight.datasettes.com <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__ shows Datasette running against over 400 datasets imported from the `FiveThirtyEight GitHub repository <https://github.com/fivethirtyeight/data>`__.\n\n.. _getting_started_tutorial:\n\nFollow a tutorial\n-----------------\n\nDatasette has several `tutorials <https://datasette.io/tutorials>`__ to help you get started with the tool. Try one of the following:\n\n- `Exploring a database with Datasette <https://datasette.io/tutorials/explore>`__ shows how to use the Datasette web interface to explore a new database.\n- `Learn SQL with Datasette <https://datasette.io/tutorials/learn-sql>`__ introduces SQL, and shows how to use that query language to ask questions of your data.\n- `Cleaning data with sqlite-utils and Datasette <https://datasette.io/tutorials/clean-data>`__ guides you through using `sqlite-utils <https://sqlite-utils.datasette.io/>`__ to turn a CSV file into a database that you can explore using Datasette.\n\n.. _getting_started_datasette_lite:\n\nDatasette in your browser with Datasette Lite\n---------------------------------------------\n\n`Datasette Lite <https://lite.datasette.io/>`__ is Datasette packaged using WebAssembly so that it runs entirely in your browser, no Python web application server required.\n\nYou can pass a URL to a CSV, SQLite or raw SQL file directly to Datasette Lite to explore that data in your browser.\n\nThis `example link <https://lite.datasette.io/?url=https%3A%2F%2Fraw.githubusercontent.com%2FNUKnightLab%2Fsql-mysteries%2Fmaster%2Fsql-murder-mystery.db#/sql-murder-mystery>`__ opens Datasette Lite and loads the SQL Murder Mystery example database from `Northwestern University Knight Lab <https://github.com/NUKnightLab/sql-mysteries>`__. \n\n.. _getting_started_codespaces:\n\nTry Datasette without installing anything with Codespaces\n---------------------------------------------------------\n\n`GitHub Codespaces <https://github.com/features/codespaces/>`__ offers a free browser-based development environment that lets you run a development server without installing any local software.\n\nHere's a demo project on GitHub which you can use as the basis for your own experiments:\n\n`github.com/datasette/datasette-studio <https://github.com/datasette/datasette-studio>`__\n\nThe README file in that repository has instructions on how to get started.\n\n.. _getting_started_your_computer:\n\nUsing Datasette on your own computer\n------------------------------------\n\nFirst, follow the :ref:`installation` instructions. Now you can run Datasette against a SQLite file on your computer using the following command:\n\n::\n\n    datasette path/to/database.db\n\nThis will start a web server on port 8001 - visit http://localhost:8001/\nto access the web interface.\n\nAdd ``-o`` to open your browser automatically once Datasette has started::\n\n    datasette path/to/database.db -o\n\nUse Chrome on OS X? You can run datasette against your browser history\nlike so:\n\n::\n\n     datasette ~/Library/Application\\ Support/Google/Chrome/Default/History --nolock\n\nThe ``--nolock`` option ignores any file locks. This is safe as Datasette will open the file in read-only mode.\n\nNow visiting http://localhost:8001/History/downloads will show you a web\ninterface to browse your downloads data:\n\n.. figure:: https://static.simonwillison.net/static/2017/datasette-downloads.png\n   :alt: Downloads table rendered by datasette\n\nhttp://localhost:8001/History/downloads.json will return that data as\nJSON:\n\n::\n\n    {\n        \"database\": \"History\",\n        \"columns\": [\n            \"id\",\n            \"current_path\",\n            \"target_path\",\n            \"start_time\",\n            \"received_bytes\",\n            \"total_bytes\",\n            ...\n        ],\n        \"rows\": [\n            [\n                1,\n                \"/Users/simonw/Downloads/DropboxInstaller.dmg\",\n                \"/Users/simonw/Downloads/DropboxInstaller.dmg\",\n                13097290269022132,\n                626688,\n                0,\n                ...\n            ]\n        ]\n    }\n\nhttp://localhost:8001/History/downloads.json?_shape=objects will return that data as\nJSON in a more convenient format:\n\n::\n\n    {\n        ...\n        \"rows\": [\n            {\n                \"start_time\": 13097290269022132,\n                \"interrupt_reason\": 0,\n                \"hash\": \"\",\n                \"id\": 1,\n                \"site_url\": \"\",\n                \"referrer\": \"https://www.dropbox.com/downloading?src=index\",\n                ...\n            }\n        ]\n    }\n"
  },
  {
    "path": "docs/index.rst",
    "content": "Datasette\n=========\n\n|PyPI| |Changelog| |Python 3.x| |Tests| |License| |docker:\ndatasette| |discord|\n\n.. |PyPI| image:: https://img.shields.io/pypi/v/datasette.svg\n   :target: https://pypi.org/project/datasette/\n.. |Changelog| image:: https://img.shields.io/github/v/release/simonw/datasette?label=changelog\n   :target: https://docs.datasette.io/en/stable/changelog.html\n.. |Python 3.x| image:: https://img.shields.io/pypi/pyversions/datasette.svg?logo=python&logoColor=white\n   :target: https://pypi.org/project/datasette/\n.. |Tests| image:: https://github.com/simonw/datasette/workflows/Test/badge.svg\n   :target: https://github.com/simonw/datasette/actions?query=workflow%3ATest\n.. |License| image:: https://img.shields.io/badge/license-Apache%202.0-blue.svg\n   :target: https://github.com/simonw/datasette/blob/main/LICENSE\n.. |docker: datasette| image:: https://img.shields.io/badge/docker-datasette-blue\n   :target: https://hub.docker.com/r/datasetteproject/datasette\n.. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord\n   :target: https://datasette.io/discord\n\n*An open source multi-tool for exploring and publishing data*\n\nDatasette is a tool for exploring and publishing data. It helps people take data of any shape or size and publish that as an interactive, explorable website and accompanying API.\n\nDatasette is aimed at data journalists, museum curators, archivists, local governments and anyone else who has data that they wish to share with the world. It is part of a :ref:`wider ecosystem of tools and plugins <ecosystem>` dedicated to making working with structured data as productive as possible.\n\n`Explore a demo <https://fivethirtyeight.datasettes.com/fivethirtyeight>`__, watch `a presentation about the project <https://static.simonwillison.net/static/2018/pybay-datasette/>`__.\n\nInterested in learning Datasette? Start with `the official tutorials <https://datasette.io/tutorials>`__.\n\nSupport questions, feedback? Join the `Datasette Discord <https://datasette.io/discord>`__.\n\nContents\n--------\n\n.. toctree::\n   :maxdepth: 3\n\n   getting_started\n   installation\n   configuration\n   ecosystem\n   cli-reference\n   pages\n   publish\n   deploying\n   json_api\n   sql_queries\n   authentication\n   performance\n   csv_export\n   binary_data\n   facets\n   full_text_search\n   spatialite\n   metadata\n   settings\n   introspection\n   custom_templates\n   plugins\n   writing_plugins\n   javascript_plugins\n   plugin_hooks\n   testing_plugins\n   internals\n   events\n   upgrade_guide\n   contributing\n   changelog\n"
  },
  {
    "path": "docs/installation.rst",
    "content": ".. _installation:\n\n==============\n Installation\n==============\n\nThere are two main options for installing Datasette. You can install it directly on to your machine, or you can install it using Docker.\n\nIf you want to start making contributions to the Datasette project by installing a copy that lets you directly modify the code, take a look at our guide to :ref:`devenvironment`.\n\n.. contents::\n   :local:\n   :class: this-will-duplicate-information-and-it-is-still-useful-here\n\n.. _installation_basic:\n\nBasic installation\n==================\n\n.. _installation_datasette_desktop:\n\nDatasette Desktop for Mac\n-------------------------\n\n`Datasette Desktop <https://datasette.io/desktop>`__ is a packaged Mac application which bundles Datasette together with Python and allows you to install and run Datasette directly on your laptop. This is the best option for local installation if you are not comfortable using the command line.\n\n.. _installation_homebrew:\n\nUsing Homebrew\n--------------\n\nIf you have a Mac and use `Homebrew <https://brew.sh/>`__, you can install Datasette by running this command in your terminal::\n\n    brew install datasette\n\nThis should install the latest version. You can confirm by running::\n\n    datasette --version\n\nYou can upgrade to the latest Homebrew packaged version using::\n\n    brew upgrade datasette\n\nOnce you have installed Datasette you can install plugins using the following::\n\n    datasette install datasette-vega\n\nIf the latest packaged release of Datasette has not yet been made available through Homebrew, you can upgrade your Homebrew installation in-place using::\n\n    datasette install -U datasette\n\n.. _installation_pip:\n\nUsing pip\n---------\n\nDatasette requires Python 3.10 or higher. The `Python.org Python For Beginners <https://www.python.org/about/gettingstarted/>`__ page has instructions for getting started.\n\nYou can install Datasette and its dependencies using ``pip``::\n\n    pip install datasette\n\nYou can now run Datasette like so::\n\n    datasette\n\n.. _installation_advanced:\n\nAdvanced installation options\n=============================\n\n.. _installation_pipx:\n\nUsing pipx\n----------\n\n`pipx <https://pipxproject.github.io/pipx/>`__ is a tool for installing Python software with all of its dependencies in an isolated environment, to ensure that they will not conflict with any other installed Python software.\n\nIf you use `Homebrew <https://brew.sh/>`__ on macOS you can install pipx like this::\n\n    brew install pipx\n    pipx ensurepath\n\nWithout Homebrew you can install it like so::\n\n    python3 -m pip install --user pipx\n    python3 -m pipx ensurepath\n\nThe ``pipx ensurepath`` command configures your shell to ensure it can find commands that have been installed by pipx - generally by making sure ``~/.local/bin`` has been added to your ``PATH``.\n\nOnce pipx is installed you can use it to install Datasette like this::\n\n    pipx install datasette\n\nThen run ``datasette --version`` to confirm that it has been successfully installed.\n\nInstalling plugins using pipx\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can install additional datasette plugins with ``pipx inject`` like so::\n\n    pipx inject datasette datasette-json-html\n\n::\n\n    injected package datasette-json-html into venv datasette\n    done! ✨ 🌟 ✨\n\nThen to confirm the plugin was installed correctly:\n\n::\n\n    datasette plugins\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette-json-html\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": \"0.6\"\n        }\n    ]\n\nUpgrading packages using pipx\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can upgrade your pipx installation to the latest release of Datasette using ``pipx upgrade datasette``::\n\n    pipx upgrade datasette\n\n::\n\n    upgraded package datasette from 0.39 to 0.40 (location: /Users/simon/.local/pipx/venvs/datasette)\n\nTo upgrade a plugin within the pipx environment use ``pipx runpip datasette install -U name-of-plugin`` - like this::\n\n    datasette plugins\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette-vega\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.6\"\n        }\n    ]\n\nNow upgrade the plugin::\n\n    pipx runpip datasette install -U datasette-vega-0\n\n::\n\n    Collecting datasette-vega\n    Downloading datasette_vega-0.6.2-py3-none-any.whl (1.8 MB)\n        |████████████████████████████████| 1.8 MB 2.0 MB/s \n    ...\n    Installing collected packages: datasette-vega\n    Attempting uninstall: datasette-vega\n        Found existing installation: datasette-vega 0.6\n        Uninstalling datasette-vega-0.6:\n        Successfully uninstalled datasette-vega-0.6\n    Successfully installed datasette-vega-0.6.2\n\nTo confirm the upgrade::\n\n    datasette plugins\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette-vega\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.6.2\"\n        }\n    ]\n\n.. _installation_docker:\n\nUsing Docker\n------------\n\nA Docker image containing the latest release of Datasette is published to Docker\nHub here: https://hub.docker.com/r/datasetteproject/datasette/\n\nIf you have Docker installed (for example with `Docker for Mac\n<https://www.docker.com/docker-mac>`_ on OS X) you can download and run this\nimage like so::\n\n    docker run -p 8001:8001 -v `pwd`:/mnt \\\n        datasetteproject/datasette \\\n        datasette -p 8001 -h 0.0.0.0 /mnt/fixtures.db\n\nThis will start an instance of Datasette running on your machine's port 8001,\nserving the ``fixtures.db`` file in your current directory.\n\nNow visit http://127.0.0.1:8001/ to access Datasette.\n\n(You can download a copy of ``fixtures.db`` from\nhttps://latest.datasette.io/fixtures.db )\n\nTo upgrade to the most recent release of Datasette, run the following::\n\n    docker pull datasetteproject/datasette\n\nLoading SpatiaLite\n~~~~~~~~~~~~~~~~~~\n\nThe ``datasetteproject/datasette`` image includes a recent version of the\n:ref:`SpatiaLite extension <spatialite>` for SQLite. To load and enable that\nmodule, use the following command::\n\n    docker run -p 8001:8001 -v `pwd`:/mnt \\\n        datasetteproject/datasette \\\n        datasette -p 8001 -h 0.0.0.0 /mnt/fixtures.db \\\n        --load-extension=spatialite\n\nYou can confirm that SpatiaLite is successfully loaded by visiting\nhttp://127.0.0.1:8001/-/versions\n\nInstalling plugins\n~~~~~~~~~~~~~~~~~~\n\nIf you want to install plugins into your local Datasette Docker image you can do\nso using the following recipe. This will install the plugins and then save a\nbrand new local image called ``datasette-with-plugins``::\n\n    docker run datasetteproject/datasette \\\n        pip install datasette-vega\n\n    docker commit $(docker ps -lq) datasette-with-plugins\n\nYou can now run the new custom image like so::\n\n    docker run -p 8001:8001 -v `pwd`:/mnt \\\n        datasette-with-plugins \\\n        datasette -p 8001 -h 0.0.0.0 /mnt/fixtures.db\n\nYou can confirm that the plugins are installed by visiting\nhttp://127.0.0.1:8001/-/plugins\n\nSome plugins such as `datasette-ripgrep <https://datasette.io/plugins/datasette-ripgrep>`__ may need additional system packages. You can install these by running `apt-get install` inside the container::\n\n    docker run datasette-057a0 bash -c '\n        apt-get update && \n        apt-get install ripgrep &&\n        pip install datasette-ripgrep'\n\n    docker commit $(docker ps -lq) datasette-with-ripgrep\n\n.. _installation_extensions:\n\nA note about extensions\n=======================\n\nSQLite supports extensions, such as :ref:`spatialite` for geospatial operations.\n\nThese can be loaded using the ``--load-extension`` argument, like so::\n\n    datasette --load-extension=/usr/local/lib/mod_spatialite.dylib\n\nSome Python installations do not include support for SQLite extensions. If this is the case you will see the following error when you attempt to load an extension:\n\n    Your Python installation does not have the ability to load SQLite extensions.\n\nIn some cases you may see the following error message instead::\n\n    AttributeError: 'sqlite3.Connection' object has no attribute 'enable_load_extension'\n\nOn macOS the easiest fix for this is to install Datasette using Homebrew::\n\n    brew install datasette\n\nUse ``which datasette`` to confirm that ``datasette`` will run that version. The output should look something like this::\n\n    /usr/local/opt/datasette/bin/datasette\n\nIf you get a different location here such as ``/Library/Frameworks/Python.framework/Versions/3.10/bin/datasette`` you can run the following command to cause ``datasette`` to execute the Homebrew version instead::\n\n    alias datasette=$(echo $(brew --prefix datasette)/bin/datasette)\n\nYou can undo this operation using::\n\n    unalias datasette\n\nIf you need to run SQLite with extension support for other Python code, you can do so by install Python itself using Homebrew::\n\n    brew install python\n\nThen executing Python using::\n\n    /usr/local/opt/python@3/libexec/bin/python\n\nA more convenient way to work with this version of Python may be to use it to create a virtual environment::\n\n    /usr/local/opt/python@3/libexec/bin/python -m venv datasette-venv\n\nThen activate it like this::\n\n    source datasette-venv/bin/activate\n\nNow running ``python`` and ``pip`` will work against a version of Python 3 that includes support for SQLite extensions::\n\n    pip install datasette\n    which datasette\n    datasette --version\n"
  },
  {
    "path": "docs/internals.rst",
    "content": ".. _internals:\n\n=======================\n Internals for plugins\n=======================\n\nMany :ref:`plugin_hooks` are passed objects that provide access to internal Datasette functionality. The interface to these objects should not be considered stable with the exception of methods that are documented here.\n\n.. _internals_request:\n\nRequest object\n==============\n\nThe request object is passed to various plugin hooks. It represents an incoming HTTP request. It has the following properties:\n\n``.scope`` - dictionary\n    The ASGI scope that was used to construct this request, described in the `ASGI HTTP connection scope <https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope>`__ specification.\n\n``.method`` - string\n    The HTTP method for this request, usually ``GET`` or ``POST``.\n\n``.url`` - string\n    The full URL for this request, e.g. ``https://latest.datasette.io/fixtures``.\n\n``.scheme`` - string\n    The request scheme - usually ``https`` or ``http``.\n\n``.headers`` - dictionary (str -> str)\n    A dictionary of incoming HTTP request headers. Header names have been converted to lowercase.\n\n``.cookies`` - dictionary (str -> str)\n    A dictionary of incoming cookies\n\n``.host`` - string\n    The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``.\n\n``.path`` - string\n    The path of the request excluding the query string, e.g. ``/fixtures``.\n\n``.full_path`` - string\n    The path of the request including the query string if one is present, e.g. ``/fixtures?sql=select+sqlite_version()``.\n\n``.query_string`` - string\n    The query string component of the request, without the ``?`` - e.g. ``name__contains=sam&age__gt=10``.\n\n``.args`` - MultiParams\n    An object representing the parsed query string parameters, see below.\n\n``.url_vars`` - dictionary (str -> str)\n    Variables extracted from the URL path, if that path was defined using a regular expression. See :ref:`plugin_register_routes`.\n\n``.actor`` - dictionary (str -> Any) or None\n    The currently authenticated actor (see :ref:`actors <authentication_actor>`), or ``None`` if the request is unauthenticated.\n\nThe object also has the following awaitable methods:\n\n``await request.form(files=False, ...)`` - FormData\n    Parses form data from the request body. Supports both ``application/x-www-form-urlencoded`` and ``multipart/form-data`` content types.\n\n    Returns a :ref:`internals_formdata` object with dict-like access to form fields and uploaded files.\n\n    Requirements and errors:\n\n    - A ``Content-Type`` header is required. Missing or unsupported content types raise ``BadRequest``.\n    - For ``multipart/form-data``, the ``boundary=...`` parameter is required.\n\n    Parameters:\n\n    - ``files`` (bool, default ``False``): If ``True``, uploaded files are stored and accessible. If ``False`` (default), file content is discarded but form fields are still available.\n    - ``max_file_size`` (int, default 50MB): Maximum size per uploaded file in bytes.\n    - ``max_request_size`` (int, default 100MB): Maximum total request body size in bytes.\n    - ``max_fields`` (int, default 1000): Maximum number of form fields.\n    - ``max_files`` (int, default 100): Maximum number of uploaded files.\n    - ``max_parts`` (int, default ``max_fields + max_files``): Maximum number of multipart parts in total.\n    - ``max_field_size`` (int, default 100KB): Maximum size of a text field value in bytes.\n    - ``max_memory_file_size`` (int, default 1MB): File size threshold before uploads spill to disk.\n    - ``max_part_header_bytes`` (int, default 16KB): Maximum total bytes allowed in part headers.\n    - ``max_part_header_lines`` (int, default 100): Maximum header lines per part.\n    - ``min_free_disk_bytes`` (int, default 50MB): Minimum free bytes required in the temp directory before accepting file uploads.\n\n    Example usage:\n\n    .. code-block:: python\n\n        # Parse form fields only (files are discarded)\n        form = await request.form()\n        username = form[\"username\"]\n        tags = form.getlist(\"tags\")  # For multiple values\n\n        # Parse form fields AND files\n        form = await request.form(files=True)\n        uploaded = form[\"avatar\"]\n        content = await uploaded.read()\n        print(\n            uploaded.filename, uploaded.content_type, uploaded.size\n        )\n\n    Cleanup note:\n\n    When using ``files=True``, call ``await form.aclose()`` once you are done with the uploads\n    to ensure spooled temporary files are closed promptly. You can also use\n    ``async with form: ...`` for automatic cleanup.\n\n    Don't forget to read about :ref:`internals_csrf`!\n\n``await request.post_vars()`` - dictionary\n    Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead.\n\n``await request.post_body()`` - bytes\n    Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.\n\nAnd a class method that can be used to create fake request objects for use in tests:\n\n``fake(path_with_query_string, method=\"GET\", scheme=\"http\", url_vars=None)``\n    Returns a ``Request`` instance for the specified path and method. For example:\n\n    .. code-block:: python\n\n        from datasette import Request\n        from pprint import pprint\n\n        request = Request.fake(\n            \"/fixtures/facetable/\",\n            url_vars={\"database\": \"fixtures\", \"table\": \"facetable\"},\n        )\n        pprint(request.scope)\n\n    This outputs::\n\n        {'http_version': '1.1',\n         'method': 'GET',\n         'path': '/fixtures/facetable/',\n         'query_string': b'',\n         'raw_path': b'/fixtures/facetable/',\n         'scheme': 'http',\n         'type': 'http',\n         'url_route': {'kwargs': {'database': 'fixtures', 'table': 'facetable'}}}\n\n.. _internals_multiparams:\n\nThe MultiParams class\n=====================\n\n``request.args`` is a ``MultiParams`` object - a dictionary-like object which provides access to query string parameters that may have multiple values.\n\nConsider the query string ``?foo=1&foo=2&bar=3`` - with two values for ``foo`` and one value for ``bar``.\n\n``request.args[key]`` - string\n    Returns the first value for that key, or raises a ``KeyError`` if the key is missing. For the above example ``request.args[\"foo\"]`` would return ``\"1\"``.\n\n``request.args.get(key)`` - string or None\n    Returns the first value for that key, or ``None`` if the key is missing. Pass a second argument to specify a different default, e.g. ``q = request.args.get(\"q\", \"\")``.\n\n``request.args.getlist(key)`` - list of strings\n    Returns the list of strings for that key. ``request.args.getlist(\"foo\")`` would return ``[\"1\", \"2\"]`` in the above example. ``request.args.getlist(\"bar\")`` would return ``[\"3\"]``. If the key is missing an empty list will be returned.\n\n``request.args.keys()`` - list of strings\n    Returns the list of available keys - for the example this would be ``[\"foo\", \"bar\"]``.\n\n``key in request.args`` - True or False\n    You can use ``if key in request.args`` to check if a key is present.\n\n``for key in request.args`` - iterator\n    This lets you loop through every available key.\n\n``len(request.args)`` - integer\n    Returns the number of keys.\n\n.. _internals_formdata:\n\nThe FormData class\n==================\n\n``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``.\n\n``form[key]`` - string or UploadedFile\n    Returns the first value for that key, or raises a ``KeyError`` if the key is missing.\n\n``form.get(key)`` - string, UploadedFile, or None\n    Returns the first value for that key, or ``None`` if the key is missing. Pass a second argument to specify a different default.\n\n``form.getlist(key)`` - list\n    Returns the list of values for that key. If the key is missing an empty list will be returned.\n\n``form.keys()`` - list of strings\n    Returns the list of available keys.\n\n``key in form`` - True or False\n    You can use ``if key in form`` to check if a key is present.\n\n``for key in form`` - iterator\n    This lets you loop through every available key.\n\n``len(form)`` - integer\n    Returns the total number of submitted values.\n\n.. _internals_uploadedfile:\n\nThe UploadedFile class\n======================\n\nWhen parsing multipart form data with ``files=True``, file uploads are returned as ``UploadedFile`` objects with the following properties and methods:\n\n``uploaded_file.name`` - string\n    The form field name.\n\n``uploaded_file.filename`` - string\n    The original filename provided by the client. Note: This is sanitized to remove path components for security.\n\n``uploaded_file.content_type`` - string or None\n    The MIME type of the uploaded file, if provided by the client.\n\n``uploaded_file.size`` - integer\n    The size of the uploaded file in bytes.\n\n``await uploaded_file.read(size=-1)`` - bytes\n    Read and return up to ``size`` bytes from the file. If ``size`` is -1 (default), read the entire file.\n\n``await uploaded_file.seek(offset, whence=0)`` - integer\n    Seek to the given position in the file. Returns the new position.\n\n``await uploaded_file.close()``\n    Close the underlying file. This is called automatically when the object is garbage collected.\n\nFiles smaller than 1MB are stored in memory. Larger files are automatically spilled to temporary files on disk and cleaned up when the request completes.\n\nExample:\n\n.. code-block:: python\n\n    form = await request.form(files=True)\n    uploaded = form[\"document\"]\n\n    # Check file metadata\n    print(f\"Filename: {uploaded.filename}\")\n    print(f\"Content-Type: {uploaded.content_type}\")\n    print(f\"Size: {uploaded.size} bytes\")\n\n    # Read file content\n    content = await uploaded.read()\n\n    # Or read in chunks\n    await uploaded.seek(0)\n    while chunk := await uploaded.read(8192):\n        process_chunk(chunk)\n\n.. _internals_response:\n\nResponse class\n==============\n\nThe ``Response`` class can be returned from view functions that have been registered using the :ref:`plugin_register_routes` hook.\n\nThe ``Response()`` constructor takes the following arguments:\n\n``body`` - string\n    The body of the response.\n\n``status`` - integer (optional)\n    The HTTP status - defaults to 200.\n\n``headers`` - dictionary (optional)\n    A dictionary of extra HTTP headers, e.g. ``{\"x-hello\": \"world\"}``.\n\n``content_type`` - string (optional)\n    The content-type for the response. Defaults to ``text/plain``.\n\nFor example:\n\n.. code-block:: python\n\n    from datasette.utils.asgi import Response\n\n    response = Response(\n        \"<xml>This is XML</xml>\",\n        content_type=\"application/xml; charset=utf-8\",\n    )\n\nThe quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)`` or ``Response.redirect(...)`` helper methods:\n\n.. code-block:: python\n\n    from datasette.utils.asgi import Response\n\n    html_response = Response.html(\"This is HTML\")\n    json_response = Response.json({\"this_is\": \"json\"})\n    text_response = Response.text(\n        \"This will become utf-8 encoded text\"\n    )\n    # Redirects are served as 302, unless you pass status=301:\n    redirect_response = Response.redirect(\n        \"https://latest.datasette.io/\"\n    )\n\nEach of these responses will use the correct corresponding content-type - ``text/html; charset=utf-8``, ``application/json; charset=utf-8`` or ``text/plain; charset=utf-8`` respectively.\n\nEach of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.\n\n.. _internals_response_asgi_send:\n\nReturning a response with .asgi_send(send)\n------------------------------------------\n\nIn most cases you will return ``Response`` objects from your own view functions. You can also use a ``Response`` instance to respond at a lower level via ASGI, for example if you are writing code that uses the :ref:`plugin_asgi_wrapper` hook.\n\nCreate a ``Response`` object and then use ``await response.asgi_send(send)``, passing the ASGI ``send`` function. For example:\n\n.. code-block:: python\n\n    async def require_authorization(scope, receive, send):\n        response = Response.text(\n            \"401 Authorization Required\",\n            headers={\n                \"www-authenticate\": 'Basic realm=\"Datasette\", charset=\"UTF-8\"'\n            },\n            status=401,\n        )\n        await response.asgi_send(send)\n\n.. _internals_response_set_cookie:\n\nSetting cookies with response.set_cookie()\n------------------------------------------\n\nTo set cookies on the response, use the ``response.set_cookie(...)`` method. The method signature looks like this:\n\n.. code-block:: python\n\n    def set_cookie(\n        self,\n        key,\n        value=\"\",\n        max_age=None,\n        expires=None,\n        path=\"/\",\n        domain=None,\n        secure=False,\n        httponly=False,\n        samesite=\"lax\",\n    ): ...\n\nYou can use this with :ref:`datasette.sign() <datasette_sign>` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie <authentication_ds_actor>` for use with Datasette :ref:`authentication <authentication>`:\n\n.. code-block:: python\n\n    response = Response.redirect(\"/\")\n    response.set_cookie(\n        \"ds_actor\",\n        datasette.sign({\"a\": {\"id\": \"cleopaws\"}}, \"actor\"),\n    )\n    return response\n\n.. _internals_datasette:\n\nDatasette class\n===============\n\nThis object is an instance of the ``Datasette`` class, passed to many plugin hooks as an argument called ``datasette``.\n\nYou can create your own instance of this - for example to help write tests for a plugin - like so:\n\n.. code-block:: python\n\n    from datasette.app import Datasette\n\n    # With no arguments a single in-memory database will be attached\n    datasette = Datasette()\n\n    # The files= argument can load files from disk\n    datasette = Datasette(files=[\"/path/to/my-database.db\"])\n\n    # Pass metadata as a JSON dictionary like this\n    datasette = Datasette(\n        files=[\"/path/to/my-database.db\"],\n        metadata={\n            \"databases\": {\n                \"my-database\": {\n                    \"description\": \"This is my database\"\n                }\n            }\n        },\n    )\n\nConstructor parameters include:\n\n- ``files=[...]`` - a list of database files to open\n- ``immutables=[...]`` - a list of database files to open in immutable mode\n- ``metadata={...}`` - a dictionary of :ref:`metadata`\n- ``config_dir=...`` - the :ref:`configuration directory <config_dir>` to use, stored in ``datasette.config_dir``\n\n.. _datasette_databases:\n\n.databases\n----------\n\nProperty exposing a ``collections.OrderedDict`` of databases currently connected to Datasette.\n\nThe dictionary keys are the name of the database that is used in the URL - e.g. ``/fixtures`` would have a key of ``\"fixtures\"``. The values are :ref:`internals_database` instances.\n\nAll databases are listed, irrespective of user permissions.\n\n.. _datasette_actions:\n\n.actions\n--------\n\nProperty exposing a dictionary of actions that have been registered using the :ref:`plugin_register_actions` plugin hook.\n\nThe dictionary keys are the action names - e.g. ``view-instance`` - and the values are ``Action()`` objects describing the permission.\n\n.. _datasette_plugin_config:\n\n.plugin_config(plugin_name, database=None, table=None)\n------------------------------------------------------\n\n``plugin_name`` - string\n    The name of the plugin to look up configuration for. Usually this is something similar to ``datasette-cluster-map``.\n\n``database`` - None or string\n    The database the user is interacting with.\n\n``table`` - None or string\n    The table the user is interacting with.\n\nThis method lets you read plugin configuration values that were set in  ``datasette.yaml``. See :ref:`writing_plugins_configuration` for full details of how this method should be used.\n\nThe return value will be the value from the configuration file - usually a dictionary.\n\nIf the plugin is not configured the return value will be ``None``.\n\n.. _datasette_render_template:\n\nawait .render_template(template, context=None, request=None)\n------------------------------------------------------------\n\n``template`` - string, list of strings or jinja2.Template\n    The template file to be rendered, e.g. ``my_plugin.html``. Datasette will search for this file first in the ``--template-dir=`` location, if it was specified - then in the plugin's bundled templates and finally in Datasette's set of default templates.\n\n    If this is a list of template file names then the first one that exists will be loaded and rendered.\n\n    If this is a Jinja `Template object <https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Template>`__ it will be used directly.\n\n``context`` - None or a Python dictionary\n    The context variables to pass to the template.\n\n``request`` - request object or None\n    If you pass a Datasette request object here it will be made available to the template.\n\nRenders a `Jinja template <https://jinja.palletsprojects.com/en/2.11.x/>`__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins.\n\n.. _datasette_actors_from_ids:\n\nawait .actors_from_ids(actor_ids)\n---------------------------------\n\n``actor_ids`` - list of strings or integers\n    A list of actor IDs to look up.\n\nReturns a dictionary, where the keys are the IDs passed to it and the values are the corresponding actor dictionaries.\n\nThis method is mainly designed to be used with plugins. See the :ref:`plugin_hook_actors_from_ids` documentation for details.\n\nIf no plugins that implement that hook are installed, the default return value looks like this:\n\n.. code-block:: json\n\n    {\n        \"1\": {\"id\": \"1\"},\n        \"2\": {\"id\": \"2\"}\n    }\n\n.. _datasette_allowed:\n\nawait .allowed(\\*, action, resource, actor=None)\n------------------------------------------------\n\n``action`` - string\n    The name of the action that is being permission checked.\n\n``resource`` - Resource object\n    A Resource object representing the database, table, or other resource. Must be an instance of a Resource class such as ``TableResource``, ``DatabaseResource``, ``QueryResource``, or ``InstanceResource``.\n\n``actor`` - dictionary, optional\n    The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.\n\nThis method checks if the given actor has permission to perform the given action on the given resource. All parameters must be passed as keyword arguments.\n\nExample usage:\n\n.. code-block:: python\n\n    from datasette.resources import (\n        TableResource,\n        DatabaseResource,\n    )\n\n    # Check if actor can view a specific table\n    can_view = await datasette.allowed(\n        action=\"view-table\",\n        resource=TableResource(\n            database=\"fixtures\", table=\"facetable\"\n        ),\n        actor=request.actor,\n    )\n\n    # Check if actor can execute SQL on a database\n    can_execute = await datasette.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"fixtures\"),\n        actor=request.actor,\n    )\n\nThe method returns ``True`` if the permission is granted, ``False`` if denied.\n\n.. _datasette_allowed_resources:\n\nawait .allowed_resources(action, actor=None, \\*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)\n------------------------------------------------------------------------------------------------------------------------------------\n\nReturns a ``PaginatedResources`` object containing resources that the actor can access for the specified action, with support for keyset pagination.\n\n``action`` - string\n    The action name (e.g., \"view-table\", \"view-database\")\n\n``actor`` - dictionary, optional\n    The authenticated actor. Defaults to ``None`` for unauthenticated requests.\n\n``parent`` - string, optional\n    Optional parent filter (e.g., database name) to limit results\n\n``include_is_private`` - boolean, optional\n    If True, adds a ``.private`` attribute to each Resource indicating whether anonymous users can access it\n\n``include_reasons`` - boolean, optional\n    If True, adds a ``.reasons`` attribute with a list of strings describing why access was granted (useful for debugging)\n\n``limit`` - integer, optional\n    Maximum number of results to return per page (1-1000, default 100)\n\n``next`` - string, optional\n    Keyset token from a previous page for pagination\n\nThe method returns a ``PaginatedResources`` object (from ``datasette.utils``) with the following attributes:\n\n``resources`` - list\n    List of ``Resource`` objects for the current page\n\n``next`` - string or None\n    Token for the next page, or ``None`` if no more results exist\n\nExample usage:\n\n.. code-block:: python\n\n    # Get first page of tables\n    page = await datasette.allowed_resources(\n        \"view-table\",\n        actor=request.actor,\n        parent=\"fixtures\",\n        limit=50,\n    )\n\n    for table in page.resources:\n        print(table.parent, table.child)\n        if hasattr(table, \"private\"):\n            print(f\"  Private: {table.private}\")\n\n    # Get next page if available\n    if page.next:\n        next_page = await datasette.allowed_resources(\n            \"view-table\", actor=request.actor, next=page.next\n        )\n\n    # Iterate through all results automatically\n    page = await datasette.allowed_resources(\n        \"view-table\", actor=request.actor\n    )\n    async for table in page.all():\n        print(table.parent, table.child)\n\n    # With reasons for debugging\n    page = await datasette.allowed_resources(\n        \"view-table\", actor=request.actor, include_reasons=True\n    )\n    for table in page.resources:\n        print(f\"{table.child}: {table.reasons}\")\n\nThe ``page.all()`` async generator automatically handles pagination, fetching additional pages and yielding all resources one at a time.\n\nThis method uses :ref:`datasette_allowed_resources_sql` under the hood and is an efficient way to list the databases, tables or other resources that an actor can access for a specific action.\n\n.. _datasette_allowed_resources_sql:\n\nawait .allowed_resources_sql(\\*, action, actor=None, parent=None, include_is_private=False)\n-------------------------------------------------------------------------------------------\n\nBuilds the SQL query that Datasette uses to determine which resources an actor may access for a specific action. Returns a ``(sql: str, params: dict)`` namedtuple that can be executed against the internal ``catalog_*`` database tables. ``parent`` can be used to limit results to a specific database, and ``include_is_private`` adds a column indicating whether anonymous users would be denied access to that resource.\n\nPlugins that need to execute custom analysis over the raw allow/deny rules can use this helper to run the same query that powers the ``/-/allowed`` debugging interface.\n\nThe SQL query built by this method will return the following columns:\n\n- ``parent``: The parent resource identifier (or NULL)\n- ``child``: The child resource identifier (or NULL)\n- ``reason``: The reason from the rule that granted access\n- ``is_private``: (if ``include_is_private``) 1 if anonymous users cannot access, 0 otherwise\n\n.. _datasette_ensure_permission:\n\nawait .ensure_permission(action, resource=None, actor=None)\n-----------------------------------------------------------\n\n``action`` - string\n    The action to check. See :ref:`actions` for a list of available actions.\n\n``resource`` - Resource object (optional)\n    The resource to check the permission against. Must be an instance of ``InstanceResource``, ``DatabaseResource``, or ``TableResource`` from the ``datasette.resources`` module. If omitted, defaults to ``InstanceResource()`` for instance-level permissions.\n\n``actor`` - dictionary (optional)\n    The authenticated actor. This is usually ``request.actor``.\n\nThis is a convenience wrapper around :ref:`datasette_allowed` that raises a ``datasette.Forbidden`` exception if the permission check fails. Use this when you want to enforce a permission check and halt execution if the actor is not authorized.\n\nExample:\n\n.. code-block:: python\n\n    from datasette.resources import TableResource\n\n    # Will raise Forbidden if actor cannot view the table\n    await datasette.ensure_permission(\n        action=\"view-table\",\n        resource=TableResource(\n            database=\"fixtures\", table=\"cities\"\n        ),\n        actor=request.actor,\n    )\n\n    # For instance-level actions, resource can be omitted:\n    await datasette.ensure_permission(\n        action=\"permissions-debug\", actor=request.actor\n    )\n\n.. _datasette_check_visibility:\n\nawait .check_visibility(actor, action, resource=None)\n-----------------------------------------------------\n\n``actor`` - dictionary\n    The authenticated actor. This is usually ``request.actor``.\n\n``action`` - string\n    The name of the action that is being permission checked.\n\n``resource`` - Resource object, optional\n    The resource being checked, as a Resource object such as ``DatabaseResource(database=...)``, ``TableResource(database=..., table=...)``, or ``QueryResource(database=..., query=...)``. Only some permissions apply to a resource.\n\nThis convenience method can be used to answer the question \"should this item be considered private, in that it is visible to me but it is not visible to anonymous users?\"\n\nIt returns a tuple of two booleans, ``(visible, private)``. ``visible`` indicates if the actor can see this resource. ``private`` will be ``True`` if an anonymous user would not be able to view the resource.\n\nThis example checks if the user can access a specific table, and sets ``private`` so that a padlock icon can later be displayed:\n\n.. code-block:: python\n\n    from datasette.resources import TableResource\n\n    visible, private = await datasette.check_visibility(\n        request.actor,\n        action=\"view-table\",\n        resource=TableResource(database=database, table=table),\n    )\n\n.. _datasette_create_token:\n\nawait .create_token(actor_id, expires_after=None, restrictions=None, handler=None)\n----------------------------------------------------------------------------------\n\n``actor_id`` - string\n    The ID of the actor to create a token for.\n\n``expires_after`` - int, optional\n    The number of seconds after which the token should expire.\n\n``restrictions`` - :ref:`TokenRestrictions <TokenRestrictions>`, optional\n    A :ref:`TokenRestrictions <TokenRestrictions>` object limiting which actions the token can perform.\n\n``handler`` - string, optional\n    The name of a specific token handler to use. If omitted, the first registered handler is used. See :ref:`plugin_hook_register_token_handler`.\n\nThis is an ``async`` method that returns an :ref:`API token <CreateTokenView>` string which can be used to authenticate requests to the Datasette API. The default ``SignedTokenHandler`` returns tokens of the format ``dstok_...``.\n\nAll tokens must have an ``actor_id`` string indicating the ID of the actor which the token will act on behalf of.\n\nTokens default to lasting forever, but can be set to expire after a given number of seconds using the ``expires_after`` argument. The following code creates a token for ``user1`` that will expire after an hour:\n\n.. code-block:: python\n\n    token = await datasette.create_token(\n        actor_id=\"user1\",\n        expires_after=3600,\n    )\n\n.. _TokenRestrictions:\n\nTokenRestrictions\n~~~~~~~~~~~~~~~~~\n\nThe ``TokenRestrictions`` class uses a builder pattern to specify which actions a token is allowed to perform. Import it from ``datasette.tokens``:\n\n.. code-block:: python\n\n    from datasette.tokens import TokenRestrictions\n\n    restrictions = (\n        TokenRestrictions()\n        .allow_all(\"view-instance\")\n        .allow_all(\"view-table\")\n        .allow_database(\"docs\", \"view-query\")\n        .allow_resource(\"docs\", \"attachments\", \"insert-row\")\n        .allow_resource(\"docs\", \"attachments\", \"update-row\")\n    )\n\nThe builder methods are:\n\n- ``allow_all(action)`` - allow an action across all databases and resources\n- ``allow_database(database, action)`` - allow an action on a specific database\n- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query <canned_queries>`) within a database\n\nEach method returns the ``TokenRestrictions`` instance so calls can be chained.\n\nThe following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database:\n\n.. code-block:: python\n\n    token = await datasette.create_token(\n        actor_id=\"user1\",\n        restrictions=(\n            TokenRestrictions()\n            .allow_all(\"view-instance\")\n            .allow_all(\"view-table\")\n            .allow_database(\"docs\", \"view-query\")\n            .allow_resource(\"docs\", \"attachments\", \"insert-row\")\n            .allow_resource(\"docs\", \"attachments\", \"update-row\")\n        ),\n    )\n\n.. _datasette_verify_token:\n\nawait .verify_token(token)\n--------------------------\n\n``token`` - string\n    The token string to verify.\n\nThis is an ``async`` method that verifies an API token by trying each registered token handler in order. Returns an actor dictionary from the first handler that recognizes the token, or ``None`` if no handler accepts it.\n\n.. code-block:: python\n\n    actor = await datasette.verify_token(token)\n    if actor:\n        # Token was valid\n        print(actor[\"id\"])\n\n.. _datasette_get_database:\n\n.get_database(name)\n-------------------\n\n``name`` - string, optional\n    The name of the database - optional.\n\nReturns the specified database object. Raises a ``KeyError`` if the database does not exist. Call this method without an argument to return the first connected database.\n\n.. _get_internal_database:\n\n.get_internal_database()\n------------------------\n\nReturns a database object for reading and writing to the private :ref:`internal database <internals_internal>`.\n\n.. _datasette_get_set_metadata:\n\nGetting and setting metadata\n----------------------------\n\nMetadata about the instance, databases, tables and columns is stored in tables in :ref:`internals_internal`. The following methods are the supported API for plugins to read and update that stored metadata.\n\n.. _datasette_get_instance_metadata:\n\nawait .get_instance_metadata(self)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nReturns metadata keys and values for the entire Datasette instance as a dictionary.\nInternally queries the ``metadata_instance`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_get_database_metadata:\n\nawait .get_database_metadata(self, database_name)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The name of the database to query.\n\nReturns metadata keys and values for the specified database as a dictionary.\nInternally queries the ``metadata_databases`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_get_resource_metadata:\n\nawait .get_resource_metadata(self, database_name, resource_name)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The name of the database to query.\n``resource_name`` - string\n    The name of the resource (table, view, or canned query) inside ``database_name`` to query.\n\nReturns metadata keys and values for the specified \"resource\" as a dictionary.\nA \"resource\" in this context can be a table, view, or canned query.\nInternally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_get_column_metadata:\n\nawait .get_column_metadata(self, database_name, resource_name, column_name)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The name of the database to query.\n``resource_name`` - string\n    The name of the resource (table, view, or canned query) inside ``database_name`` to query.\n``column_name`` - string\n    The name of the column inside ``resource_name`` to query.\n\n\nReturns metadata keys and values for the specified column, resource, and table as a dictionary.\nInternally queries the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_set_instance_metadata:\n\nawait .set_instance_metadata(self, key, value)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``key`` - string\n    The metadata entry key to insert (ex ``title``, ``description``, etc.)\n``value`` - string\n    The value of the metadata entry to insert.\n\nAdds a new metadata entry for the entire Datasette instance.\nAny previous instance-level metadata entry with the same ``key`` will be overwritten.\nInternally upserts the value into the  the ``metadata_instance`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_set_database_metadata:\n\nawait .set_database_metadata(self, database_name, key, value)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The database the metadata entry belongs to.\n``key`` - string\n    The metadata entry key to insert (ex ``title``, ``description``, etc.)\n``value`` - string\n    The value of the metadata entry to insert.\n\nAdds a new metadata entry for the specified database.\nAny previous database-level metadata entry with the same ``key`` will be overwritten.\nInternally upserts the value into the  the ``metadata_databases`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_set_resource_metadata:\n\nawait .set_resource_metadata(self, database_name, resource_name, key, value)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The database the metadata entry belongs to.\n``resource_name`` - string\n    The resource (table, view, or canned query) the metadata entry belongs to.\n``key`` - string\n    The metadata entry key to insert (ex ``title``, ``description``, etc.)\n``value`` - string\n    The value of the metadata entry to insert.\n\nAdds a new metadata entry for the specified \"resource\".\nAny previous resource-level metadata entry with the same ``key`` will be overwritten.\nInternally upserts the value into the  the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_set_column_metadata:\n\nawait .set_column_metadata(self, database_name, resource_name, column_name, key, value)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database_name`` - string\n    The database the metadata entry belongs to.\n``resource_name`` - string\n    The resource (table, view, or canned query) the metadata entry belongs to.\n``column-name`` - string\n    The column the metadata entry belongs to.\n``key`` - string\n    The metadata entry key to insert (ex ``title``, ``description``, etc.)\n``value`` - string\n    The value of the metadata entry to insert.\n\nAdds a new metadata entry for the specified column.\nAny previous column-level metadata entry with the same ``key`` will be overwritten.\nInternally upserts the value into the  the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.\n\n.. _datasette_column_types:\n\nColumn types\n------------\n\nColumn types are stored in the ``column_types`` table in the :ref:`internal database <internals_internal>`. The following methods provide the API for reading and modifying column type assignments.\n\n.. _datasette_get_column_type:\n\nawait .get_column_type(database, resource, column)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database`` - string\n    The name of the database.\n``resource`` - string\n    The name of the table or view.\n``column`` - string\n    The name of the column.\n\nReturns a ``ColumnType`` subclass instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.\n\n.. code-block:: python\n\n    ct = await datasette.get_column_type(\n        \"mydb\", \"mytable\", \"email_col\"\n    )\n    if ct:\n        print(ct.name)  # \"email\"\n        print(ct.config)  # None or {...}\n\n.. _datasette_get_column_types:\n\nawait .get_column_types(database, resource)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database`` - string\n    The name of the database.\n``resource`` - string\n    The name of the table or view.\n\nReturns a dictionary mapping column names to ``ColumnType`` subclass instances (with ``.config`` populated) for all columns that have assigned types on the given resource.\n\n.. code-block:: python\n\n    ct_map = await datasette.get_column_types(\"mydb\", \"mytable\")\n    for col_name, ct in ct_map.items():\n        print(col_name, ct.name, ct.config)\n\n.. _datasette_set_column_type:\n\nawait .set_column_type(database, resource, column, column_type, config=None)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database`` - string\n    The name of the database.\n``resource`` - string\n    The name of the table or view.\n``column`` - string\n    The name of the column.\n``column_type`` - string\n    The column type name to assign, e.g. ``\"email\"``.\n``config`` - dict, optional\n    Optional configuration dict for the column type.\n\nAssigns a column type to a column. Overwrites any existing assignment for that column.\nRaises ``ValueError`` if the column type declares ``sqlite_types`` and the target column does not match one of those SQLite types.\n\n.. code-block:: python\n\n    await datasette.set_column_type(\n        \"mydb\",\n        \"mytable\",\n        \"location\",\n        \"point\",\n        config={\"srid\": 4326},\n    )\n\n.. _datasette_remove_column_type:\n\nawait .remove_column_type(database, resource, column)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``database`` - string\n    The name of the database.\n``resource`` - string\n    The name of the table or view.\n``column`` - string\n    The name of the column.\n\nRemoves the column type assignment for the specified column.\n\n.. code-block:: python\n\n    await datasette.remove_column_type(\n        \"mydb\", \"mytable\", \"location\"\n    )\n\n.. _datasette_add_database:\n\n.add_database(db, name=None, route=None)\n----------------------------------------\n\n``db`` - datasette.database.Database instance\n    The database to be attached.\n\n``name`` - string, optional\n    The name to be used for this database . If not specified Datasette will pick one based on the filename or memory name.\n\n``route`` - string, optional\n    This will be used in the URL path. If not specified, it will default to the same thing as the ``name``.\n\nThe ``datasette.add_database(db)`` method lets you add a new database to the current Datasette instance.\n\nThe ``db`` parameter should be an instance of the ``datasette.database.Database`` class. For example:\n\n.. code-block:: python\n\n    from datasette.database import Database\n\n    datasette.add_database(\n        Database(\n            datasette,\n            path=\"path/to/my-new-database.db\",\n        )\n    )\n\nThis will add a mutable database and serve it at ``/my-new-database``.\n\nUse ``is_mutable=False`` to add an immutable database.\n\n``.add_database()`` returns the Database instance, with its name set as the ``database.name`` attribute. Any time you are working with a newly added database you should use the return value of ``.add_database()``, for example:\n\n.. code-block:: python\n\n    db = datasette.add_database(\n        Database(datasette, memory_name=\"statistics\")\n    )\n    await db.execute_write(\n        \"CREATE TABLE foo(id integer primary key)\"\n    )\n\n.. _datasette_add_memory_database:\n\n.add_memory_database(memory_name, name=None, route=None)\n--------------------------------------------------------\n\nAdds a shared in-memory database with the specified name:\n\n.. code-block:: python\n\n    datasette.add_memory_database(\"statistics\")\n\nThis is a shortcut for the following:\n\n.. code-block:: python\n\n    from datasette.database import Database\n\n    datasette.add_database(\n        Database(datasette, memory_name=\"statistics\")\n    )\n\nUsing either of these patterns will result in the in-memory database being served at ``/statistics``.\n\nThe ``name`` and ``route`` parameters are optional and work the same way as they do for :ref:`datasette_add_database`.\n\n.. _datasette_remove_database:\n\n.remove_database(name)\n----------------------\n\n``name`` - string\n    The name of the database to be removed.\n\nThis removes a database that has been previously added. ``name=`` is the unique name of that database.\n\n.. _datasette_track_event:\n\nawait .track_event(event)\n-------------------------\n\n``event`` - ``Event``\n    An instance of a subclass of ``datasette.events.Event``.\n\nPlugins can call this to track events, using classes they have previously registered. See :ref:`plugin_event_tracking` for details.\n\nThe event will then be passed to all plugins that have registered to receive events using the :ref:`plugin_hook_track_event` hook.\n\nExample usage, assuming the plugin has previously registered the ``BanUserEvent`` class:\n\n.. code-block:: python\n\n    await datasette.track_event(\n        BanUserEvent(user={\"id\": 1, \"username\": \"cleverbot\"})\n    )\n\n.. _datasette_sign:\n\n.sign(value, namespace=\"default\")\n---------------------------------\n\n``value`` - any serializable type\n    The value to be signed.\n\n``namespace`` - string, optional\n    An alternative namespace, see the `itsdangerous salt documentation <https://itsdangerous.palletsprojects.com/en/1.1.x/serializer/#the-salt>`__.\n\nUtility method for signing values, such that you can safely pass data to and from an untrusted environment. This is a wrapper around the `itsdangerous <https://itsdangerous.palletsprojects.com/>`__ library.\n\nThis method returns a signed string, which can be decoded and verified using :ref:`datasette_unsign`.\n\n.. _datasette_unsign:\n\n.unsign(value, namespace=\"default\")\n-----------------------------------\n\n``signed`` - any serializable type\n    The signed string that was created using :ref:`datasette_sign`.\n\n``namespace`` - string, optional\n    The alternative namespace, if one was used.\n\nReturns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception.\n\n.. _datasette_add_message:\n\n.add_message(request, message, type=datasette.INFO)\n---------------------------------------------------\n\n``request`` - Request\n    The current Request object\n\n``message`` - string\n    The message string\n\n``type`` - constant, optional\n    The message type - ``datasette.INFO``, ``datasette.WARNING`` or ``datasette.ERROR``\n\nDatasette's flash messaging mechanism allows you to add a message that will be displayed to the user on the next page that they visit. Messages are persisted in a ``ds_messages`` cookie. This method adds a message to that cookie.\n\nYou can try out these messages (including the different visual styling of the three message types) using the ``/-/messages`` debugging tool.\n\n.. _datasette_absolute_url:\n\n.absolute_url(request, path)\n----------------------------\n\n``request`` - Request\n    The current Request object\n\n``path`` - string\n    A path, for example ``/dbname/table.json``\n\nReturns the absolute URL for the given path, including the protocol and host. For example:\n\n.. code-block:: python\n\n    absolute_url = datasette.absolute_url(\n        request, \"/dbname/table.json\"\n    )\n    # Would return \"http://localhost:8001/dbname/table.json\"\n\nThe current request object is used to determine the hostname and protocol that should be used for the returned URL. The :ref:`setting_force_https_urls` configuration setting is taken into account.\n\n.. _datasette_setting:\n\n.setting(key)\n-------------\n\n``key`` - string\n    The name of the setting, e.g. ``base_url``.\n\nReturns the configured value for the specified :ref:`setting <settings>`. This can be a string, boolean or integer depending on the requested setting.\n\nFor example:\n\n.. code-block:: python\n\n    downloads_are_allowed = datasette.setting(\"allow_download\")\n\n.. _datasette_resolve_database:\n\n.resolve_database(request)\n--------------------------\n\n``request`` - :ref:`internals_request`\n    A request object\n\nIf you are implementing your own custom views, you may need to resolve the database that the user is requesting based on a URL path. If the regular expression for your route declares a ``database`` named group, you can use this method to resolve the database object.\n\nThis returns a :ref:`Database <internals_database>` instance.\n\nIf the database cannot be found, it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception - which is a subclass of ``datasette.utils.asgi.NotFound`` with a ``.database_name`` attribute set to the name of the database that was requested.\n\n.. _datasette_resolve_table:\n\n.resolve_table(request)\n-----------------------\n\n``request`` - :ref:`internals_request`\n    A request object\n\nThis assumes that the regular expression for your route declares both a ``database`` and a ``table`` named group.\n\nIt returns a ``ResolvedTable`` named tuple instance with the following fields:\n\n``db`` - :ref:`Database <internals_database>`\n    The database object\n\n``table`` - string\n    The name of the table (or view)\n\n``is_view`` - boolean\n    ``True`` if this is a view, ``False`` if it is a table\n\nIf the database or table cannot be found it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception.\n\nIf the table does not exist it raises a ``datasette.utils.asgi.TableNotFound`` exception - a subclass of ``datasette.utils.asgi.NotFound`` with ``.database_name`` and ``.table`` attributes.\n\n.. _datasette_resolve_row:\n\n.resolve_row(request)\n---------------------\n\n``request`` - :ref:`internals_request`\n    A request object\n\nThis method assumes your route declares named groups for ``database``, ``table`` and ``pks``.\n\nIt returns a ``ResolvedRow`` named tuple instance with the following fields:\n\n``db`` - :ref:`Database <internals_database>`\n    The database object\n\n``table`` - string\n    The name of the table\n\n``sql`` - string\n    SQL snippet that can be used in a ``WHERE`` clause to select the row\n\n``params`` - dict\n    Parameters that should be passed to the SQL query\n\n``pks`` - list\n    List of primary key column names\n\n``pk_values`` - list\n    List of primary key values decoded from the URL\n\n``row`` - ``sqlite3.Row``\n    The row itself\n\nIf the database or table cannot be found it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception.\n\nIf the table does not exist it raises a ``datasette.utils.asgi.TableNotFound`` exception.\n\nIf the row cannot be found it raises a ``datasette.utils.asgi.RowNotFound`` exception. This has ``.database_name``, ``.table`` and ``.pk_values`` attributes, extracted from the request path.\n\n.. _internals_datasette_client:\n\ndatasette.client\n----------------\n\nPlugins 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.\n\nThe ``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/>`__.\n\nIt offers the following methods:\n\n``await datasette.client.get(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal GET request against that path.\n\n``await datasette.client.post(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal POST request. Use ``data={\"name\": \"value\"}`` to pass form parameters.\n\n``await datasette.client.options(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal OPTIONS request.\n\n``await datasette.client.head(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal HEAD request.\n\n``await datasette.client.put(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal PUT request.\n\n``await datasette.client.patch(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal PATCH request.\n\n``await datasette.client.delete(path, **kwargs)`` - returns HTTPX Response\n    Execute an internal DELETE request.\n\n``await datasette.client.request(method, path, **kwargs)`` - returns HTTPX Response\n    Execute an internal request with the given HTTP method against that path.\n\nThese methods can be used with :ref:`internals_datasette_urls` - for example:\n\n.. code-block:: python\n\n    table_json = (\n        await datasette.client.get(\n            datasette.urls.table(\n                \"fixtures\", \"facetable\", format=\"json\"\n            )\n        )\n    ).json()\n\n``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.\n\nFor 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/>`__.\n\nBypassing permission checks\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nAll ``datasette.client`` methods accept an optional ``skip_permission_checks=True`` parameter. When set, all permission checks will be bypassed for that request, allowing access to any resource regardless of the configured permissions.\n\nThis is useful for plugins and internal operations that need to access all resources without being subject to permission restrictions.\n\nExample usage:\n\n.. code-block:: python\n\n    # Regular request - respects permissions\n    response = await datasette.client.get(\n        \"/private-db/secret-table.json\"\n    )\n    # May return 403 Forbidden if access is denied\n\n    # With skip_permission_checks - bypasses all permission checks\n    response = await datasette.client.get(\n        \"/private-db/secret-table.json\",\n        skip_permission_checks=True,\n    )\n    # Will return 200 OK and the data, regardless of permissions\n\nThis parameter works with all HTTP methods (``get``, ``post``, ``put``, ``patch``, ``delete``, ``options``, ``head``) and the generic ``request`` method.\n\n.. warning::\n\n    Use ``skip_permission_checks=True`` with caution. It completely bypasses Datasette's permission system and should only be used in trusted plugin code or internal operations where you need guaranteed access to resources.\n\n.. _internals_datasette_is_client:\n\nDetecting internal client requests\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette.in_client()`` - returns bool\n    Returns ``True`` if the current code is executing within a ``datasette.client`` request, ``False`` otherwise.\n\nThis method is useful for plugins that need to behave differently when called through ``datasette.client`` versus when handling external HTTP requests.\n\nExample usage:\n\n.. code-block:: python\n\n    async def fetch_documents(datasette):\n        if not datasette.in_client():\n            return Response.text(\n                \"Only available via internal client requests\",\n                status=403,\n            )\n        ...\n\nNote that ``datasette.in_client()`` is independent of ``skip_permission_checks``. A request made through ``datasette.client`` will always have ``in_client()`` return ``True``, regardless of whether ``skip_permission_checks`` is set.\n\n.. _internals_datasette_urls:\n\ndatasette.urls\n--------------\n\nThe ``datasette.urls`` object contains methods for building URLs to pages within Datasette. Plugins should use this to link to pages, since these methods take into account any :ref:`setting_base_url` configuration setting that might be in effect.\n\n``datasette.urls.instance(format=None)``\n    Returns the URL to the Datasette instance root page. This is usually ``\"/\"``.\n\n``datasette.urls.path(path, format=None)``\n    Takes a path and returns the full path, taking ``base_url`` into account.\n\n    For example, ``datasette.urls.path(\"-/logout\")`` will return the path to the logout page, which will be ``\"/-/logout\"`` by default or ``/prefix-path/-/logout`` if ``base_url`` is set to ``/prefix-path/``\n\n``datasette.urls.logout()``\n    Returns the URL to the logout page, usually ``\"/-/logout\"``\n\n``datasette.urls.static(path)``\n    Returns the URL of one of Datasette's default static assets, for example ``\"/-/static/app.css\"``\n\n``datasette.urls.static_plugins(plugin_name, path)``\n    Returns the URL of one of the static assets belonging to a plugin.\n\n    ``datasette.urls.static_plugins(\"datasette_cluster_map\", \"datasette-cluster-map.js\")`` would return ``\"/-/static-plugins/datasette_cluster_map/datasette-cluster-map.js\"``\n\n``datasette.urls.static(path)``\n    Returns the URL of one of Datasette's default static assets, for example ``\"/-/static/app.css\"``\n\n``datasette.urls.database(database_name, format=None)``\n    Returns the URL to a database page, for example ``\"/fixtures\"``\n\n``datasette.urls.table(database_name, table_name, format=None)``\n    Returns the URL to a table page, for example ``\"/fixtures/facetable\"``\n\n``datasette.urls.query(database_name, query_name, format=None)``\n    Returns the URL to a query page, for example ``\"/fixtures/pragma_cache_size\"``\n\nThese functions can be accessed via the ``{{ urls }}`` object in Datasette templates, for example:\n\n.. code-block:: jinja\n\n    <a href=\"{{ urls.instance() }}\">Homepage</a>\n    <a href=\"{{ urls.database(\"fixtures\") }}\">Fixtures database</a>\n    <a href=\"{{ urls.table(\"fixtures\", \"facetable\") }}\">facetable table</a>\n    <a href=\"{{ urls.query(\"fixtures\", \"pragma_cache_size\") }}\">pragma_cache_size query</a>\n\nUse the ``format=\"json\"`` (or ``\"csv\"`` or other formats supported by plugins) arguments to get back URLs to the JSON representation. This is the path with ``.json`` added on the end.\n\nThese 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.\n\n.. _internals_permission_classes:\n\nPermission classes and utilities\n================================\n\n.. _internals_permission_sql:\n\nPermissionSQL class\n-------------------\n\nThe ``PermissionSQL`` class is used by plugins to contribute SQL-based permission rules through the :ref:`plugin_hook_permission_resources_sql` hook. This enables efficient permission checking across multiple resources by leveraging SQLite's query engine.\n\n.. code-block:: python\n\n    from datasette.permissions import PermissionSQL\n\n\n    @dataclass\n    class PermissionSQL:\n        source: str  # Plugin name for auditing\n        sql: str  # SQL query returning permission rules\n        params: Dict[str, Any]  # Parameters for the SQL query\n\n**Attributes:**\n\n``source`` - string\n    An identifier for the source of these permission rules, typically the plugin name. This is used for debugging and auditing.\n\n``sql`` - string\n    A SQL query that returns permission rules. The query must return rows with the following columns:\n\n    - ``parent`` (TEXT or NULL) - The parent resource identifier (e.g., database name)\n    - ``child`` (TEXT or NULL) - The child resource identifier (e.g., table name)\n    - ``allow`` (INTEGER) - 1 for allow, 0 for deny\n    - ``reason`` (TEXT) - A human-readable explanation of why this permission was granted or denied\n\n``params`` - dictionary\n    A dictionary of parameters to bind into the SQL query. Parameter names should not include the ``:`` prefix.\n\n.. _permission_sql_parameters:\n\nAvailable SQL parameters\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nWhen writing SQL for ``PermissionSQL``, the following parameters are automatically available:\n\n``:actor`` - JSON string or NULL\n    The full actor dictionary serialized as JSON. Use SQLite's ``json_extract()`` function to access fields:\n\n    .. code-block:: sql\n\n        json_extract(:actor, '$.role') = 'admin'\n        json_extract(:actor, '$.team') = 'engineering'\n\n``:actor_id`` - string or NULL\n    The actor's ``id`` field, for simple equality comparisons:\n\n    .. code-block:: sql\n\n        :actor_id = 'alice'\n\n``:action`` - string\n    The action being checked (e.g., ``\"view-table\"``, ``\"insert-row\"``, ``\"execute-sql\"``).\n\n**Example usage:**\n\nHere's an example plugin that grants view-table permissions to users with an \"analyst\" role for tables in the \"analytics\" database:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if action != \"view-table\":\n            return None\n\n        return PermissionSQL(\n            source=\"my_analytics_plugin\",\n            sql=\"\"\"\n                SELECT 'analytics' AS parent,\n                       NULL AS child,\n                       1 AS allow,\n                       'Analysts can view analytics database' AS reason\n                WHERE json_extract(:actor, '$.role') = 'analyst'\n                  AND :action = 'view-table'\n            \"\"\",\n            params={},\n        )\n\nA more complex example that uses custom parameters:\n\n.. code-block:: python\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if not actor:\n            return None\n\n        user_teams = actor.get(\"teams\", [])\n\n        return PermissionSQL(\n            source=\"team_permissions_plugin\",\n            sql=\"\"\"\n                SELECT\n                    team_database AS parent,\n                    team_table AS child,\n                    1 AS allow,\n                    'User is member of team: ' || team_name AS reason\n                FROM team_permissions\n                WHERE user_id = :user_id\n                  AND :action IN ('view-table', 'insert-row', 'update-row')\n            \"\"\",\n            params={\"user_id\": actor.get(\"id\")},\n        )\n\n**Permission resolution rules:**\n\nWhen multiple ``PermissionSQL`` objects return conflicting rules for the same resource, Datasette applies the following precedence:\n\n1. **Specificity**: Child-level rules (with both ``parent`` and ``child``) override parent-level rules (with only ``parent``), which override root-level rules (with neither ``parent`` nor ``child``)\n2. **Deny over allow**: At the same specificity level, deny (``allow=0``) takes precedence over allow (``allow=1``)\n3. **Implicit deny**: If no rules match a resource, access is denied by default\n\n.. _internals_database:\n\nDatabase class\n==============\n\nInstances of the ``Database`` class can be used to execute queries against attached SQLite databases, and to run introspection against their schemas.\n\n.. _database_constructor:\n\nDatabase(ds, path=None, is_mutable=True, is_memory=False, memory_name=None)\n---------------------------------------------------------------------------\n\nThe ``Database()`` constructor can be used by plugins, in conjunction with :ref:`datasette_add_database`, to create and register new databases.\n\nThe arguments are as follows:\n\n``ds`` - :ref:`internals_datasette` (required)\n    The Datasette instance you are attaching this database to.\n\n``path`` - string\n    Path to a SQLite database file on disk.\n\n``is_mutable`` - boolean\n    Set this to ``False`` to cause Datasette to open the file in immutable mode.\n\n``is_memory`` - boolean\n    Use this to create non-shared memory connections.\n\n``memory_name`` - string or ``None``\n    Use this to create a named in-memory database. Unlike regular memory databases these can be accessed by multiple threads and will persist an changes made to them for the lifetime of the Datasette server process.\n\nThe first argument is the ``datasette`` instance you are attaching to, the second is a ``path=``, then ``is_mutable`` and ``is_memory`` are both optional arguments.\n\n.. _database_hash:\n\ndb.hash\n-------\n\nIf the database was opened in immutable mode, this property returns the 64 character SHA-256 hash of the database contents as a string. Otherwise it returns ``None``.\n\n.. _database_execute:\n\nawait db.execute(sql, ...)\n--------------------------\n\nExecutes a SQL query against the database and returns the resulting rows (see :ref:`database_results`).\n\n``sql`` - string (required)\n    The SQL query to execute. This can include ``?`` or ``:named`` parameters.\n\n``params`` - list or dict\n    A list or dictionary of values to use for the parameters. List for ``?``, dictionary for ``:named``.\n\n``truncate`` - boolean\n    Should the rows returned by the query be truncated at the maximum page size? Defaults to ``True``, set this to ``False`` to disable truncation.\n\n``custom_time_limit`` - integer ms\n    A custom time limit for this query. This can be set to a lower value than the Datasette configured default. If a query takes longer than this it will be terminated early and raise a ``dataette.database.QueryInterrupted`` exception.\n\n``page_size`` - integer\n    Set a custom page size for truncation, over-riding the configured Datasette default.\n\n``log_sql_errors`` - boolean\n    Should any SQL errors be logged to the console in addition to being raised as an error? Defaults to ``True``.\n\n.. _database_results:\n\nResults\n-------\n\nThe ``db.execute()`` method returns a single ``Results`` object. This can be used to access the rows returned by the query.\n\nIterating over a ``Results`` object will yield SQLite `Row objects <https://docs.python.org/3/library/sqlite3.html#row-objects>`__. Each of these can be treated as a tuple or can be accessed using ``row[\"column\"]`` syntax:\n\n.. code-block:: python\n\n    info = []\n    results = await db.execute(\"select name from sqlite_master\")\n    for row in results:\n        info.append(row[\"name\"])\n\nThe ``Results`` object also has the following properties and methods:\n\n``.truncated`` - boolean\n    Indicates if this query was truncated - if it returned more results than the specified ``page_size``. If this is true then the results object will only provide access to the first ``page_size`` rows in the query result. You can disable truncation by passing ``truncate=False`` to the ``db.query()`` method.\n\n``.columns`` - list of strings\n    A list of column names returned by the query.\n\n``.rows`` - list of ``sqlite3.Row``\n    This property provides direct access to the list of rows returned by the database. You can access specific rows by index using ``results.rows[0]``.\n\n``.dicts()`` - list of ``dict``\n    This method returns a list of Python dictionaries, one for each row.\n\n``.first()`` - row or None\n    Returns the first row in the results, or ``None`` if no rows were returned.\n\n``.single_value()``\n    Returns the value of the first column of the first row of results - but only if the query returned a single row with a single column. Raises a ``datasette.database.MultipleValues`` exception otherwise.\n\n``.__len__()``\n    Calling ``len(results)`` returns the (truncated) number of returned results.\n\n.. _database_execute_fn:\n\nawait db.execute_fn(fn)\n-----------------------\n\nExecutes a given callback function against a read-only database connection running in a thread. The function will be passed a SQLite connection, and the return value from the function will be returned by the ``await``.\n\nExample usage:\n\n.. code-block:: python\n\n    def get_version(conn):\n        return conn.execute(\n            \"select sqlite_version()\"\n        ).fetchall()[0][0]\n\n\n    version = await db.execute_fn(get_version)\n\n.. _database_execute_write:\n\nawait db.execute_write(sql, params=None, block=True)\n----------------------------------------------------\n\nSQLite 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.\n\nThis method can be used to queue up a non-SELECT SQL query to be executed against a single write connection to the database.\n\nYou can pass additional SQL parameters as a tuple or dictionary.\n\nThe method will block until the operation is completed, and the return value will be the return from calling ``conn.execute(...)`` using the underlying ``sqlite3`` Python library.\n\nIf you pass ``block=False`` this behavior changes to \"fire and forget\" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.\n\nEach call to ``execute_write()`` will be executed inside a transaction.\n\n.. _database_execute_write_script:\n\nawait db.execute_write_script(sql, block=True)\n----------------------------------------------\n\nLike ``execute_write()`` but can be used to send multiple SQL statements in a single string separated by semicolons, using the ``sqlite3`` `conn.executescript() <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.executescript>`__ method.\n\nEach call to ``execute_write_script()`` will be executed inside a transaction.\n\n.. _database_execute_write_many:\n\nawait db.execute_write_many(sql, params_seq, block=True)\n--------------------------------------------------------\n\nLike ``execute_write()`` but uses the ``sqlite3`` `conn.executemany() <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.executemany>`__ method. This will efficiently execute the same SQL statement against each of the parameters in the ``params_seq`` iterator, for example:\n\n.. code-block:: python\n\n    await db.execute_write_many(\n        \"insert into characters (id, name) values (?, ?)\",\n        [(1, \"Melanie\"), (2, \"Selma\"), (2, \"Viktor\")],\n    )\n\nEach call to ``execute_write_many()`` will be executed inside a transaction.\n\n.. _database_execute_write_fn:\n\nawait db.execute_write_fn(fn, block=True, transaction=True)\n-----------------------------------------------------------\n\nThis method works like ``.execute_write()``, but instead of a SQL statement you give it a callable Python function. Your function will be queued up and then called when the write connection is available, passing that connection as the argument to the function.\n\nThe 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.\n\n.. warning::\n\n    ``fn`` needs to be a regular function, not an ``async def`` function.\n\nFor example:\n\n.. code-block:: python\n\n    def delete_and_return_count(conn):\n        conn.execute(\"delete from some_table where id > 5\")\n        return conn.execute(\n            \"select count(*) from some_table\"\n        ).fetchone()[0]\n\n\n    try:\n        num_rows_left = await database.execute_write_fn(\n            delete_and_return_count\n        )\n    except Exception as e:\n        print(\"An error occurred:\", e)\n\nThe value returned from ``await database.execute_write_fn(...)`` will be the return value from your function.\n\nIf your function raises an exception that exception will be propagated up to the ``await`` line.\n\nBy 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.\n\nIf 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.\n\n.. _database_execute_isolated_fn:\n\nawait db.execute_isolated_fn(fn)\n--------------------------------\n\nThis method works is similar to :ref:`execute_write_fn() <database_execute_write_fn>` but executes the provided function in an entirely isolated SQLite connection, which is opened, used and then closed again in a single call to this method.\n\nThe :ref:`prepare_connection() <plugin_hook_prepare_connection>` plugin hook is not executed against this connection.\n\nThis allows plugins to execute database operations that might conflict with how database connections are usually configured. For example, running a ``VACUUM`` operation while bypassing any restrictions placed by the `datasette-sqlite-authorizer <https://github.com/datasette/datasette-sqlite-authorizer>`__ plugin.\n\nPlugins can also use this method to load potentially dangerous SQLite extensions, use them to perform an operation and then have them safely unloaded at the end of the call, without risk of exposing them to other connections.\n\nFunctions run using ``execute_isolated_fn()`` share the same queue as ``execute_write_fn()``, which guarantees that no writes can be executed at the same time as the isolated function is executing.\n\nThe return value of the function will be returned by this method. Any exceptions raised by the function will be raised out of the ``await`` line as well.\n\n.. _database_close:\n\ndb.close()\n----------\n\nCloses all of the open connections to file-backed databases. This is mainly intended to be used by large test suites, to avoid hitting limits on the number of open files.\n\n.. _internals_database_introspection:\n\nDatabase introspection\n----------------------\n\nThe ``Database`` class also provides properties and methods for introspecting the database.\n\n``db.name`` - string\n    The name of the database - usually the filename without the ``.db`` prefix.\n\n``db.size`` - integer\n    The size of the database file in bytes. 0 for ``:memory:`` databases.\n\n``db.mtime_ns`` - integer or None\n    The last modification time of the database file in nanoseconds since the epoch. ``None`` for ``:memory:`` databases.\n\n``db.is_mutable`` - boolean\n    Is this database mutable, and allowed to accept writes?\n\n``db.is_memory`` - boolean\n    Is this database an in-memory database?\n\n``await db.attached_databases()`` - list of named tuples\n    Returns a list of additional databases that have been connected to this database using the SQLite ATTACH command. Each named tuple has fields ``seq``, ``name`` and ``file``.\n\n``await db.table_exists(table)`` - boolean\n    Check if a table called ``table`` exists.\n\n``await db.view_exists(view)`` - boolean\n    Check if a view called ``view`` exists.\n\n``await db.table_names()`` - list of strings\n    List of names of tables in the database.\n\n``await db.view_names()`` - list of strings\n    List of names of views in the database.\n\n``await db.table_columns(table)`` - list of strings\n    Names of columns in a specific table.\n\n``await db.table_column_details(table)`` - list of named tuples\n    Full details of the columns in a specific table. Each column is represented by a ``Column`` named tuple with fields ``cid`` (integer representing the column position), ``name`` (string), ``type`` (string, e.g. ``REAL`` or ``VARCHAR(30)``), ``notnull`` (integer 1 or 0), ``default_value`` (string or None), ``is_pk`` (integer 1 or 0).\n\n``await db.primary_keys(table)`` - list of strings\n    Names of the columns that are part of the primary key for this table.\n\n``await db.fts_table(table)`` - string or None\n    The name of the FTS table associated with this table, if one exists.\n\n``await db.label_column_for_table(table)`` - string or None\n    The label column that is associated with this table - either automatically detected or using the ``\"label_column\"`` key in configuration, see :ref:`table_configuration_label_column`.\n\n``await db.foreign_keys_for_table(table)`` - list of dictionaries\n    Details of columns in this table which are foreign keys to other tables. A list of dictionaries where each dictionary is shaped like this: ``{\"column\": string, \"other_table\": string, \"other_column\": string}``.\n\n``await db.hidden_table_names()`` - list of strings\n    List of tables which Datasette \"hides\" by default - usually these are tables associated with SQLite's full-text search feature, the SpatiaLite extension or tables hidden using the :ref:`table_configuration_hidden` feature.\n\n``await db.get_table_definition(table)`` - string\n    Returns the SQL definition for the table - the ``CREATE TABLE`` statement and any associated ``CREATE INDEX`` statements.\n\n``await db.get_view_definition(view)`` - string\n    Returns the SQL definition of the named view.\n\n``await db.get_all_foreign_keys()`` - dictionary\n    Dictionary representing both incoming and outgoing foreign keys for every table in this database. Each key is a table name that points to a dictionary with two keys, ``\"incoming\"`` and ``\"outgoing\"``, each of which is a list of dictionaries with keys ``\"column\"``, ``\"other_table\"`` and ``\"other_column\"``. For example:\n\n    .. code-block:: json\n\n        {\n          \"documents\": {\n            \"incoming\": [\n              {\n                \"other_table\": \"pages\",\n                \"column\": \"id\",\n                \"other_column\": \"document_id\"\n              }\n            ],\n            \"outgoing\": []\n          },\n          \"pages\": {\n            \"incoming\": [\n              {\n                \"other_table\": \"organization_pages\",\n                \"column\": \"id\",\n                \"other_column\": \"page_id\"\n              }\n            ],\n            \"outgoing\": [\n              {\n                \"other_table\": \"documents\",\n                \"column\": \"document_id\",\n                \"other_column\": \"id\"\n              }\n            ]\n          },\n          \"organization\": {\n            \"incoming\": [\n              {\n                \"other_table\": \"organization_pages\",\n                \"column\": \"id\",\n                \"other_column\": \"organization_id\"\n              }\n            ],\n            \"outgoing\": []\n          },\n          \"organization_pages\": {\n            \"incoming\": [],\n            \"outgoing\": [\n              {\n                \"other_table\": \"pages\",\n                \"column\": \"page_id\",\n                \"other_column\": \"id\"\n              },\n              {\n                \"other_table\": \"organization\",\n                \"column\": \"organization_id\",\n                \"other_column\": \"id\"\n              }\n            ]\n          }\n        }\n\n.. _internals_csrf:\n\nCSRF protection\n===============\n\nDatasette uses `asgi-csrf <https://github.com/simonw/asgi-csrf>`__ to guard against CSRF attacks on form POST submissions. Users receive a ``ds_csrftoken`` cookie which is compared against the ``csrftoken`` form field (or ``x-csrftoken`` HTTP header) for every incoming request.\n\nIf your plugin implements a ``<form method=\"POST\">`` anywhere you will need to include that token. You can do so with the following template snippet:\n\n.. code-block:: html\n\n    <input type=\"hidden\" name=\"csrftoken\" value=\"{{ csrftoken() }}\">\n\nIf you are rendering templates using the :ref:`datasette_render_template` method the ``csrftoken()`` helper will only work if you provide the ``request=`` argument to that method. If you forget to do this you will see the following error::\n\n    form-urlencoded POST field did not match cookie\n\nYou can selectively disable CSRF protection using the :ref:`plugin_hook_skip_csrf` hook.\n\n.. _internals_internal:\n\nDatasette's internal database\n=============================\n\nDatasette 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.\n\nDatasette 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.\n\nMetadata is stored in tables ``metadata_instance``, ``metadata_databases``, ``metadata_resources`` and ``metadata_columns``. Plugins can interact with these tables via the :ref:`get_*_metadata() and set_*_metadata() methods <datasette_get_set_metadata>`.\n\nThe internal database is not exposed in the Datasette application by default, which means private data can safely be stored without worry of accidentally leaking information through the default Datasette interface and API. However, other plugins do have full read and write access to the internal database.\n\nPlugins can access this database by calling ``internal_db = datasette.get_internal_database()`` and then executing queries using the :ref:`Database API <internals_database>`.\n\nPlugin authors are asked to practice good etiquette when using the internal database, as all plugins use the same database to store data. For example:\n\n1. Use a unique prefix when creating tables, indices, and triggers in the internal database. If your plugin is called ``datasette-xyz``, then prefix names with ``datasette_xyz_*``.\n2. Avoid long-running write statements that may stall or block other plugins that are trying to write at the same time.\n3. Use temporary tables or shared in-memory attached databases when possible.\n4. Avoid implementing features that could expose private data stored in the internal database by other plugins.\n\n.. _internals_internal_schema:\n\nInternal database schema\n------------------------\n\nThe internal database schema is as follows:\n\n.. [[[cog\n    from metadata_doc import internal_schema\n    internal_schema(cog)\n.. ]]]\n\n.. code-block:: sql\n\n    CREATE TABLE catalog_databases (\n        database_name TEXT PRIMARY KEY,\n        path TEXT,\n        is_memory INTEGER,\n        schema_version INTEGER\n    );\n    CREATE TABLE catalog_tables (\n        database_name TEXT,\n        table_name TEXT,\n        rootpage INTEGER,\n        sql TEXT,\n        PRIMARY KEY (database_name, table_name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)\n    );\n    CREATE TABLE catalog_views (\n        database_name TEXT,\n        view_name TEXT,\n        rootpage INTEGER,\n        sql TEXT,\n        PRIMARY KEY (database_name, view_name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)\n    );\n    CREATE TABLE catalog_columns (\n        database_name TEXT,\n        table_name TEXT,\n        cid INTEGER,\n        name TEXT,\n        type TEXT,\n        \"notnull\" INTEGER,\n        default_value TEXT, -- renamed from dflt_value\n        is_pk INTEGER, -- renamed from pk\n        hidden INTEGER,\n        PRIMARY KEY (database_name, table_name, name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    CREATE TABLE catalog_indexes (\n        database_name TEXT,\n        table_name TEXT,\n        seq INTEGER,\n        name TEXT,\n        \"unique\" INTEGER,\n        origin TEXT,\n        partial INTEGER,\n        PRIMARY KEY (database_name, table_name, name),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    CREATE TABLE catalog_foreign_keys (\n        database_name TEXT,\n        table_name TEXT,\n        id INTEGER,\n        seq INTEGER,\n        \"table\" TEXT,\n        \"from\" TEXT,\n        \"to\" TEXT,\n        on_update TEXT,\n        on_delete TEXT,\n        match TEXT,\n        PRIMARY KEY (database_name, table_name, id, seq),\n        FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),\n        FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)\n    );\n    CREATE TABLE metadata_instance (\n        key text,\n        value text,\n        unique(key)\n    );\n    CREATE TABLE metadata_databases (\n        database_name text,\n        key text,\n        value text,\n        unique(database_name, key)\n    );\n    CREATE TABLE metadata_resources (\n        database_name text,\n        resource_name text,\n        key text,\n        value text,\n        unique(database_name, resource_name, key)\n    );\n    CREATE TABLE metadata_columns (\n        database_name text,\n        resource_name text,\n        column_name text,\n        key text,\n        value text,\n        unique(database_name, resource_name, column_name, key)\n    );\n    CREATE TABLE column_types (\n        database_name TEXT NOT NULL,\n        resource_name TEXT NOT NULL,\n        column_name TEXT NOT NULL,\n        column_type TEXT NOT NULL,\n        config TEXT,\n        PRIMARY KEY (database_name, resource_name, column_name)\n    );\n\n.. [[[end]]]\n\n.. _internals_utils:\n\nThe datasette.utils module\n==========================\n\nThe ``datasette.utils`` module contains various utility functions used by Datasette. As a general rule you should consider anything in this module to be unstable - functions and classes here could change without warning or be removed entirely between Datasette releases, without being mentioned in the release notes.\n\nThe exception to this rule is anything that is documented here. If you find a need for an undocumented utility function in your own work, consider `opening an issue <https://github.com/simonw/datasette/issues/new>`__ requesting that the function you are using be upgraded to documented and supported status.\n\n.. _internals_utils_parse_metadata:\n\nparse_metadata(content)\n-----------------------\n\nThis function accepts a string containing either JSON or YAML, expected to be of the format described in :ref:`metadata`. It returns a nested Python dictionary representing the parsed data from that string.\n\nIf the metadata cannot be parsed as either JSON or YAML the function will raise a ``utils.BadMetadataError`` exception.\n\n.. autofunction:: datasette.utils.parse_metadata\n\n.. _internals_utils_await_me_maybe:\n\nawait_me_maybe(value)\n---------------------\n\nUtility function for calling ``await`` on a return value if it is awaitable, otherwise returning the value. This is used by Datasette to support plugin hooks that can optionally return awaitable functions. Read more about this function in `The “await me maybe” pattern for Python asyncio <https://simonwillison.net/2020/Sep/2/await-me-maybe/>`__.\n\n.. autofunction:: datasette.utils.await_me_maybe\n\n.. _internals_utils_named_parameters:\n\nnamed_parameters(sql)\n---------------------\n\nDerive the list of ``:named`` parameters referenced in a SQL query.\n\n.. autofunction:: datasette.utils.named_parameters\n\n.. _internals_tilde_encoding:\n\nTilde encoding\n--------------\n\nDatasette uses a custom encoding scheme in some places, called **tilde encoding**. This is primarily used for table names and row primary keys, to avoid any confusion between ``/`` characters in those values and the Datasette URLs that reference them.\n\nTilde encoding uses the same algorithm as `URL percent-encoding <https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding>`__, but with the ``~`` tilde character used in place of ``%``.\n\nAny character other than ``ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz0123456789_-`` will be replaced by the numeric equivalent preceded by a tilde. For example:\n\n- ``/`` becomes ``~2F``\n- ``.`` becomes ``~2E``\n- ``%`` becomes ``~25``\n- ``~`` becomes ``~7E``\n- Space becomes ``+``\n- ``polls/2022.primary`` becomes ``polls~2F2022~2Eprimary``\n\nNote that the space character is a special case: it will be replaced with a ``+`` symbol.\n\n.. _internals_utils_tilde_encode:\n\n.. autofunction:: datasette.utils.tilde_encode\n\n.. _internals_utils_tilde_decode:\n\n.. autofunction:: datasette.utils.tilde_decode\n\n.. _internals_tracer:\n\ndatasette.tracer\n================\n\nRunning Datasette with ``--setting trace_debug 1`` enables trace debug output, which can then be viewed by adding ``?_trace=1`` to the query string for any page.\n\nYou can see an example of this at the bottom of `latest.datasette.io/fixtures/facetable?_trace=1 <https://latest.datasette.io/fixtures/facetable?_trace=1>`__. The JSON output shows full details of every SQL query that was executed to generate the page.\n\nThe `datasette-pretty-traces <https://datasette.io/plugins/datasette-pretty-traces>`__ plugin can be installed to provide a more readable display of this information. You can see `a demo of that here <https://latest-with-plugins.datasette.io/github/commits?_trace=1>`__.\n\nYou can add your own custom traces to the JSON output using the ``trace()`` context manager. This takes a string that identifies the type of trace being recorded, and records any keyword arguments as additional JSON keys on the resulting trace object.\n\nThe start and end time, duration and a traceback of where the trace was executed will be automatically attached to the JSON object.\n\nThis example uses trace to record the start, end and duration of any HTTP GET requests made using the function:\n\n.. code-block:: python\n\n    from datasette.tracer import trace\n    import httpx\n\n\n    async def fetch_url(url):\n        with trace(\"fetch-url\", url=url):\n            async with httpx.AsyncClient() as client:\n                return await client.get(url)\n\n.. _internals_tracer_trace_child_tasks:\n\nTracing child tasks\n-------------------\n\nIf your code uses a mechanism such as ``asyncio.gather()`` to execute code in additional tasks you may find that some of the traces are missing from the display.\n\nYou can use the ``trace_child_tasks()`` context manager to ensure these child tasks are correctly handled.\n\n.. code-block:: python\n\n    from datasette import tracer\n\n    with tracer.trace_child_tasks():\n        results = await asyncio.gather(\n            # ... async tasks here\n        )\n\nThis example uses the :ref:`register_routes() <plugin_register_routes>` plugin hook to add a page at ``/parallel-queries`` which executes two SQL queries in parallel using ``asyncio.gather()`` and returns their results.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette import tracer\n\n\n    @hookimpl\n    def register_routes():\n        async def parallel_queries(datasette):\n            db = datasette.get_database()\n            with tracer.trace_child_tasks():\n                one, two = await asyncio.gather(\n                    db.execute(\"select 1\"),\n                    db.execute(\"select 2\"),\n                )\n            return Response.json(\n                {\n                    \"one\": one.single_value(),\n                    \"two\": two.single_value(),\n                }\n            )\n\n        return [\n            (r\"/parallel-queries$\", parallel_queries),\n        ]\n\nNote that running parallel SQL queries in this way has `been known to cause problems in the past <https://github.com/simonw/datasette/issues/2189>`__, so treat this example with caution.\n\nAdding ``?_trace=1`` will show that the trace covers both of those child tasks.\n\n.. _internals_shortcuts:\n\nImport shortcuts\n================\n\nThe following commonly used symbols can be imported directly from the ``datasette`` module:\n\n.. code-block:: python\n\n    from datasette import Response\n    from datasette import Forbidden\n    from datasette import NotFound\n    from datasette import hookimpl\n    from datasette import actor_matches_allow\n"
  },
  {
    "path": "docs/introspection.rst",
    "content": ".. _introspection:\n\nIntrospection\n=============\n\nDatasette includes some pages and JSON API endpoints for introspecting the current instance. These can be used to understand some of the internals of Datasette and to see how a particular instance has been configured.\n\nEach of these pages can be viewed in your browser. Add ``.json`` to the URL to get back the contents as JSON.\n\n.. _JsonDataView_metadata:\n\n/-/metadata\n-----------\n\nShows the contents of the ``metadata.json`` file that was passed to ``datasette serve``, if any. `Metadata example <https://fivethirtyeight.datasettes.com/-/metadata>`_:\n\n.. code-block:: json\n\n    {\n        \"license\": \"CC Attribution 4.0 License\",\n        \"license_url\": \"http://creativecommons.org/licenses/by/4.0/\",\n        \"source\": \"fivethirtyeight/data on GitHub\",\n        \"source_url\": \"https://github.com/fivethirtyeight/data\",\n        \"title\": \"Five Thirty Eight\",\n        \"databases\": {\n\n        }\n    }\n\n.. _JsonDataView_versions:\n\n/-/versions\n-----------\n\nShows the version of Datasette, Python and SQLite. `Versions example <https://latest.datasette.io/-/versions>`_:\n\n.. code-block:: json\n\n    {\n        \"datasette\": {\n            \"version\": \"0.60\"\n        },\n        \"python\": {\n            \"full\": \"3.8.12 (default, Dec 21 2021, 10:45:09) \\n[GCC 10.2.1 20210110]\",\n            \"version\": \"3.8.12\"\n        },\n        \"sqlite\": {\n            \"extensions\": {\n                \"json1\": null\n            },\n            \"fts_versions\": [\n                \"FTS5\",\n                \"FTS4\",\n                \"FTS3\"\n            ],\n            \"compile_options\": [\n                \"COMPILER=gcc-6.3.0 20170516\",\n                \"ENABLE_FTS3\",\n                \"ENABLE_FTS4\",\n                \"ENABLE_FTS5\",\n                \"ENABLE_JSON1\",\n                \"ENABLE_RTREE\",\n                \"THREADSAFE=1\"\n            ],\n            \"version\": \"3.37.0\"\n        }\n    }\n\n.. _JsonDataView_plugins:\n\n/-/plugins\n----------\n\nShows a list of currently installed plugins and their versions. `Plugins example <https://san-francisco.datasettes.com/-/plugins>`_:\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette_cluster_map\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.10\",\n            \"hooks\": [\"extra_css_urls\", \"extra_js_urls\", \"extra_body_script\"]\n        }\n    ]\n\nAdd ``?all=1`` to include details of the default plugins baked into Datasette.\n\n.. _JsonDataView_settings:\n\n/-/settings\n-----------\n\nShows the :ref:`settings` for this instance of Datasette. `Settings example <https://fivethirtyeight.datasettes.com/-/settings>`_:\n\n.. code-block:: json\n\n    {\n        \"default_facet_size\": 30,\n        \"default_page_size\": 100,\n        \"facet_suggest_time_limit_ms\": 50,\n        \"facet_time_limit_ms\": 1000,\n        \"max_returned_rows\": 1000,\n        \"sql_time_limit_ms\": 1000\n    }\n\n.. _JsonDataView_config:\n\n/-/config\n---------\n\nShows the :ref:`configuration <configuration>` for this instance of Datasette. This is generally the contents of the :ref:`datasette.yaml or datasette.json <configuration_reference>` file, which can include plugin configuration as well. `Config example <https://latest.datasette.io/-/config>`_:\n\n.. code-block:: json\n\n    {\n        \"settings\": {\n            \"template_debug\": true,\n            \"trace_debug\": true,\n            \"force_https_urls\": true\n        }\n    }\n\nAny keys that include the one of the following substrings in their names will be returned as redacted ``***`` output, to help avoid accidentally leaking private configuration information: ``secret``, ``key``, ``password``, ``token``, ``hash``, ``dsn``.\n\n.. _JsonDataView_databases:\n\n/-/databases\n------------\n\nShows currently attached databases. `Databases example <https://latest.datasette.io/-/databases>`_:\n\n.. code-block:: json\n\n    [\n        {\n            \"hash\": null,\n            \"is_memory\": false,\n            \"is_mutable\": true,\n            \"name\": \"fixtures\",\n            \"path\": \"fixtures.db\",\n            \"size\": 225280\n        }\n    ]\n\n.. _TablesView:\n\n/-/tables\n---------\n\nReturns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.\n\nThe endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.\n\n`Tables example <https://latest.datasette.io/-/tables>`_:\n\n.. code-block:: json\n\n    {\n        \"matches\": [\n            {\n                \"name\": \"fixtures/facetable\",\n                \"url\": \"/fixtures/facetable\"\n            },\n            {\n                \"name\": \"fixtures/searchable\",\n                \"url\": \"/fixtures/searchable\"\n            }\n        ]\n    }\n\nSearch example with ``?q=facet`` returns only tables matching ``.*facet.*``:\n\n.. code-block:: json\n\n    {\n        \"matches\": [\n            {\n                \"name\": \"fixtures/facetable\",\n                \"url\": \"/fixtures/facetable\"\n            }\n        ]\n    }\n\nWhen multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.\n\n.. _JsonDataView_threads:\n\n/-/threads\n----------\n\nShows details of threads and ``asyncio`` tasks. `Threads example <https://latest.datasette.io/-/threads>`_:\n\n.. code-block:: json\n\n    {\n        \"num_threads\": 2,\n        \"threads\": [\n            {\n                \"daemon\": false,\n                \"ident\": 4759197120,\n                \"name\": \"MainThread\"\n            },\n            {\n                \"daemon\": true,\n                \"ident\": 123145319682048,\n                \"name\": \"Thread-1\"\n            },\n        ],\n        \"num_tasks\": 3,\n        \"tasks\": [\n            \"<Task pending coro=<RequestResponseCycle.run_asgi() running at uvicorn/protocols/http/httptools_impl.py:385> cb=[set.discard()]>\",\n            \"<Task pending coro=<Server.serve() running at uvicorn/main.py:361> wait_for=<Future pending cb=[<TaskWakeupMethWrapper object at 0x10365c3d0>()]> cb=[run_until_complete.<locals>.<lambda>()]>\",\n            \"<Task pending coro=<LifespanOn.main() running at uvicorn/lifespan/on.py:48> wait_for=<Future pending cb=[<TaskWakeupMethWrapper object at 0x10364f050>()]>>\"\n        ]\n    }\n\n.. _JsonDataView_actor:\n\n/-/actor\n--------\n\nShows the currently authenticated actor. Useful for debugging Datasette authentication plugins.\n\n.. code-block:: json\n\n    {\n        \"actor\": {\n            \"id\": 1,\n            \"username\": \"some-user\"\n        }\n    }\n\n\n.. _MessagesDebugView:\n\n/-/messages\n-----------\n\nThe debug tool at ``/-/messages`` can be used to set flash messages to try out that feature. See :ref:`datasette_add_message` for details of this feature.\n"
  },
  {
    "path": "docs/javascript_plugins.rst",
    "content": ".. _javascript_plugins:\n\nJavaScript plugins\n==================\n\nDatasette can run custom JavaScript in several different ways:\n\n- Datasette plugins written in Python can use the :ref:`extra_js_urls() <plugin_hook_extra_js_urls>` or :ref:`extra_body_script() <plugin_hook_extra_body_script>` plugin hooks to inject JavaScript into a page\n- Datasette instances with :ref:`custom templates <customization_custom_templates>` can include additional JavaScript in those templates\n- The ``extra_js_urls`` key in ``datasette.yaml`` :ref:`can be used to include extra JavaScript <configuration_reference_css_js>`\n\nThere are no limitations on what this JavaScript can do. It is executed directly by the browser, so it can manipulate the DOM, fetch additional data and do anything else that JavaScript is capable of.\n\n.. warning::\n    Custom JavaScript has security implications, especially for authenticated Datasette instances where the JavaScript might run in the context of the authenticated user. It's important to carefully review any JavaScript you run in your Datasette instance.\n\n.. _javascript_datasette_init:\n\nThe datasette_init event\n------------------------\n\nDatasette emits a custom event called ``datasette_init`` when the page is loaded. This event is dispatched on the ``document`` object, and includes a ``detail`` object with a reference to the :ref:`datasetteManager <javascript_datasette_manager>` object.\n\nYour JavaScript code can listen out for this event using ``document.addEventListener()`` like this:\n\n.. code-block:: javascript\n\n    document.addEventListener(\"datasette_init\", function (evt) {\n        const manager = evt.detail;\n        console.log(\"Datasette version:\", manager.VERSION);\n    });\n\n.. _javascript_datasette_manager:\n\ndatasetteManager\n----------------\n\nThe ``datasetteManager`` object \n\n``VERSION`` - string\n    The version of Datasette\n\n``plugins`` - ``Map()``\n    A Map of currently loaded plugin names to plugin implementations\n\n``registerPlugin(name, implementation)``\n    Call this to register a plugin, passing its name and implementation\n\n``selectors`` - object\n    An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`\n\n.. _javascript_plugin_objects:\n\nJavaScript plugin objects\n-------------------------\n\nJavaScript plugins are blocks of code that can be registered with Datasette using the ``registerPlugin()`` method on the :ref:`datasetteManager <javascript_datasette_manager>` object.\n\nThe ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:\n\n.. _javascript_plugins_makeAboveTablePanelConfigs:\n\nmakeAboveTablePanelConfigs()\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis method should return a JavaScript array of objects defining additional panels to be added to the top of the table page. Each object should have the following:\n\n``id`` - string\n    A unique string ID for the panel, for example ``map-panel``\n``label`` - string\n    A human-readable label for the panel\n``render(node)`` - function\n    A function that will be called with a DOM node to render the panel into\n\nThis example shows how a plugin might define a single panel:\n\n.. code-block:: javascript\n\n    document.addEventListener('datasette_init', function(ev) {\n      ev.detail.registerPlugin('panel-plugin', {\n        version: 0.1,\n        makeAboveTablePanelConfigs: () => {\n          return [\n            {\n              id: 'first-panel',\n              label: 'First panel',\n              render: node => {\n                node.innerHTML = '<h2>My custom panel</h2><p>This is a custom panel that I added using a JavaScript plugin</p>';\n              }\n            }\n          ]\n        }\n      });\n    });\n\nWhen a page with a table loads, all registered plugins that implement ``makeAboveTablePanelConfigs()`` will be called and panels they return will be added to the top of the table page.\n\n.. _javascript_plugins_makeColumnActions:\n\nmakeColumnActions(columnDetails)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis method, if present, will be called when Datasette is rendering the cog action menu icons that appear at the top of the table view. By default these include options like \"Sort ascending/descending\" and \"Facet by this\", but plugins can return additional actions to be included in this menu.\n\nThe method will be called with a ``columnDetails`` object with the following keys:\n\n``columnName`` - string\n    The name of the column\n``columnNotNull`` - boolean\n    True if the column is defined as NOT NULL\n``columnType`` - string\n    The SQLite data type of the column\n``isPk`` - boolean\n    True if the column is part of the primary key\n\nIt should return a JavaScript array of objects each with a ``label`` and ``onClick`` property:\n\n``label`` - string\n    The human-readable label for the action\n``onClick(evt)`` - function\n    A function that will be called when the action is clicked\n\nThe ``evt`` object passed to the ``onClick`` is the standard browser event object that triggered the click.\n\nThis example plugin adds two menu items - one to copy the column name to the clipboard and another that displays the column metadata in an ``alert()`` window:\n\n.. code-block:: javascript\n\n    document.addEventListener('datasette_init', function(ev) {\n      ev.detail.registerPlugin('column-name-plugin', {\n        version: 0.1,\n        makeColumnActions: (columnDetails) => {\n          return [\n            {\n              label: 'Copy column to clipboard',\n              onClick: async (evt) => {\n                await navigator.clipboard.writeText(columnDetails.columnName)\n              }\n            },\n            {\n              label: 'Alert column metadata',\n              onClick: () => alert(JSON.stringify(columnDetails, null, 2))\n            }\n          ];\n        }\n      });\n    });\n\n.. _javascript_datasette_manager_selectors:\n\nSelectors\n---------\n\nThese are available on the ``selectors`` property of the :ref:`javascript_datasette_manager` object.\n\n.. literalinclude:: ../datasette/static/datasette-manager.js\n   :language: javascript\n   :start-at: const DOM_SELECTORS = {\n   :end-at: };\n"
  },
  {
    "path": "docs/json_api.rst",
    "content": ".. _json_api:\n\nJSON API\n========\n\nDatasette provides a JSON API for your SQLite databases. Anything you can do\nthrough the Datasette user interface can also be accessed as JSON via the API.\n\nTo access the API for a page, either click on the ``.json`` link on that page or\nedit the URL and add a ``.json`` extension to it.\n\n.. _json_api_default:\n\nDefault representation\n----------------------\n\nThe default JSON representation of data from a SQLite table or custom query\nlooks like this:\n\n.. code-block:: json\n\n    {\n      \"ok\": true,\n      \"rows\": [\n        {\n          \"id\": 3,\n          \"name\": \"Detroit\"\n        },\n        {\n          \"id\": 2,\n          \"name\": \"Los Angeles\"\n        },\n        {\n          \"id\": 4,\n          \"name\": \"Memnonia\"\n        },\n        {\n          \"id\": 1,\n          \"name\": \"San Francisco\"\n        }\n      ],\n      \"truncated\": false\n    }\n\n``\"ok\"`` is always ``true`` if an error did not occur.\n\nThe ``\"rows\"`` key is a list of objects, each one representing a row. \n\nThe ``\"truncated\"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).\n\nFor table pages, an additional key ``\"next\"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.\n\n.. _json_api_shapes:\n\nDifferent shapes\n----------------\n\nThe ``_shape`` parameter can be used to access alternative formats for the\n``rows`` key which may be more convenient for your application. There are three\noptions:\n\n* ``?_shape=objects`` - ``\"rows\"`` is a list of JSON key/value objects - the default\n* ``?_shape=arrays`` - ``\"rows\"`` is a list of lists, where the order of values in each list matches the order of the columns\n* ``?_shape=array`` - a JSON array of objects - effectively just the ``\"rows\"`` key from the default representation\n* ``?_shape=array&_nl=on`` - a newline-separated list of JSON objects\n* ``?_shape=arrayfirst`` - a flat JSON array containing just the first value from each row\n* ``?_shape=object`` - a JSON object keyed using the primary keys of the rows\n\n``_shape=arrays`` looks like this:\n\n.. code-block:: json\n\n    {\n      \"ok\": true,\n      \"next\": null,\n      \"rows\": [\n        [3, \"Detroit\"],\n        [2, \"Los Angeles\"],\n        [4, \"Memnonia\"],\n        [1, \"San Francisco\"]\n      ]\n    }\n\n``_shape=array`` looks like this:\n\n.. code-block:: json\n\n    [\n      {\n        \"id\": 3,\n        \"name\": \"Detroit\"\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Los Angeles\"\n      },\n      {\n        \"id\": 4,\n        \"name\": \"Memnonia\"\n      },\n      {\n        \"id\": 1,\n        \"name\": \"San Francisco\"\n      }\n    ]\n\n``_shape=array&_nl=on`` looks like this::\n\n    {\"id\": 1, \"value\": \"Myoporum laetum :: Myoporum\"}\n    {\"id\": 2, \"value\": \"Metrosideros excelsa :: New Zealand Xmas Tree\"}\n    {\"id\": 3, \"value\": \"Pinus radiata :: Monterey Pine\"}\n\n``_shape=arrayfirst`` looks like this:\n\n.. code-block:: json\n\n    [1, 2, 3]\n\n``_shape=object`` looks like this:\n\n.. code-block:: json\n\n    {\n      \"1\": {\n        \"id\": 1,\n        \"value\": \"Myoporum laetum :: Myoporum\"\n      },\n      \"2\": {\n        \"id\": 2,\n        \"value\": \"Metrosideros excelsa :: New Zealand Xmas Tree\"\n      },\n      \"3\": {\n        \"id\": 3,\n        \"value\": \"Pinus radiata :: Monterey Pine\"\n      }\n    ]\n\nThe ``object`` shape is only available for queries against tables - custom SQL\nqueries and views do not have an obvious primary key so cannot be returned using\nthis format.\n\nThe ``object`` keys are always strings. If your table has a compound primary\nkey, the ``object`` keys will be a comma-separated string.\n\n.. _json_api_pagination:\n\nPagination\n----------\n\nThe default JSON representation includes a ``\"next_url\"`` key which can be used to access the next page of results. If that key is null or missing then it means you have reached the final page of results.\n\nOther representations include pagination information in the ``link`` HTTP header. That header will look something like this::\n\n    link: <https://latest.datasette.io/fixtures/sortable.json?_next=d%2Cv>; rel=\"next\"\n\nHere is an example Python function built using `requests <https://requests.readthedocs.io/>`__ that returns a list of all of the paginated items from one of these API endpoints:\n\n.. code-block:: python\n\n    def paginate(url):\n        items = []\n        while url:\n            response = requests.get(url)\n            try:\n                url = response.links.get(\"next\").get(\"url\")\n            except AttributeError:\n                url = None\n            items.extend(response.json())\n        return items\n\n.. _json_api_special:\n\nSpecial JSON arguments\n----------------------\n\nEvery Datasette endpoint that can return JSON also accepts the following\nquery string arguments:\n\n``?_shape=SHAPE``\n    The shape of the JSON to return, documented above.\n\n``?_nl=on``\n    When used with ``?_shape=array`` produces newline-delimited JSON objects.\n\n``?_json=COLUMN1&_json=COLUMN2``\n    If any of your SQLite columns contain JSON values, you can use one or more\n    ``_json=`` parameters to request that those columns be returned as regular\n    JSON. Without this argument those columns will be returned as JSON objects\n    that have been double-encoded into a JSON string value.\n\n    Compare `this query without the argument <https://fivethirtyeight.datasettes.com/fivethirtyeight.json?sql=select+%27{%22this+is%22%3A+%22a+json+object%22}%27+as+d&_shape=array>`_ to `this query using the argument <https://fivethirtyeight.datasettes.com/fivethirtyeight.json?sql=select+%27{%22this+is%22%3A+%22a+json+object%22}%27+as+d&_shape=array&_json=d>`_\n\n``?_json_infinity=on``\n    If your data contains infinity or -infinity values, Datasette will replace\n    them with None when returning them as JSON. If you pass ``_json_infinity=1``\n    Datasette will instead return them as ``Infinity`` or ``-Infinity`` which is\n    invalid JSON but can be processed by some custom JSON parsers.\n\n``?_timelimit=MS``\n    Sets a custom time limit for the query in ms. You can use this for optimistic\n    queries where you would like Datasette to give up if the query takes too\n    long, for example if you want to implement autocomplete search but only if\n    it can be executed in less than 10ms.\n\n``?_ttl=SECONDS``\n    For how many seconds should this response be cached by HTTP proxies? Use\n    ``?_ttl=0`` to disable HTTP caching entirely for this request.\n\n``?_trace=1``\n    Turns on tracing for this page: SQL queries executed during the request will\n    be gathered and included in the response, either in a new ``\"_traces\"`` key\n    for JSON responses or at the bottom of the page if the response is in HTML.\n\n    The structure of the data returned here should be considered highly unstable\n    and very likely to change.\n\n    Only available if the :ref:`setting_trace_debug` setting is enabled.\n\n.. _table_arguments:\n\nTable arguments\n---------------\n\nThe Datasette table view takes a number of special query string arguments.\n\nColumn filter arguments\n~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can filter the data returned by the table based on column values using a query string argument.\n\n``?column__exact=value`` or ``?_column=value``\n    Returns rows where the specified column exactly matches the value.\n\n``?column__not=value``\n    Returns rows where the column does not match the value.\n\n``?column__contains=value``\n    Rows where the string column contains the specified value (``column like \"%value%\"`` in SQL).\n\n``?column__notcontains=value``\n    Rows where the string column does not contain the specified value (``column not like \"%value%\"`` in SQL).\n\n``?column__endswith=value``\n    Rows where the string column ends with the specified value (``column like \"%value\"`` in SQL).\n\n``?column__startswith=value``\n    Rows where the string column starts with the specified value (``column like \"value%\"`` in SQL).\n\n``?column__gt=value``\n    Rows which are greater than the specified value.\n\n``?column__gte=value``\n    Rows which are greater than or equal to the specified value.\n\n``?column__lt=value``\n    Rows which are less than the specified value.\n\n``?column__lte=value``\n    Rows which are less than or equal to the specified value.\n\n``?column__like=value``\n    Match rows with a LIKE clause, case insensitive and with ``%`` as the wildcard character.\n\n``?column__notlike=value``\n    Match rows that do not match the provided LIKE clause.\n\n``?column__glob=value``\n    Similar to LIKE but uses Unix wildcard syntax and is case sensitive.\n\n``?column__in=value1,value2,value3``\n    Rows where column matches any of the provided values.\n\n    You can use a comma separated string, or you can use a JSON array.\n\n    The JSON array option is useful if one of your matching values itself contains a comma:\n\n    ``?column__in=[\"value\",\"value,with,commas\"]``\n\n``?column__notin=value1,value2,value3``\n    Rows where column does not match any of the provided values. The inverse of ``__in=``. Also supports JSON arrays.\n\n``?column__arraycontains=value``\n    Works against columns that contain JSON arrays - matches if any of the values in that array match the provided value.\n\n    This is only available if the ``json1`` SQLite extension is enabled.\n\n``?column__arraynotcontains=value``\n    Works against columns that contain JSON arrays - matches if none of the values in that array match the provided value.\n\n    This is only available if the ``json1`` SQLite extension is enabled.\n\n``?column__date=value``\n    Column is a datestamp occurring on the specified YYYY-MM-DD date, e.g. ``2018-01-02``.\n\n``?column__isnull=1``\n    Matches rows where the column is null.\n\n``?column__notnull=1``\n    Matches rows where the column is not null.\n\n``?column__isblank=1``\n    Matches rows where the column is blank, meaning null or the empty string.\n\n``?column__notblank=1``\n    Matches rows where the column is not blank.\n\n.. _json_api_table_arguments:\n\nSpecial table arguments\n~~~~~~~~~~~~~~~~~~~~~~~\n\n``?_col=COLUMN1&_col=COLUMN2``\n    List specific columns to display. These will be shown along with any primary keys.\n\n``?_nocol=COLUMN1&_nocol=COLUMN2``\n    List specific columns to hide - any column not listed will be displayed. Primary keys cannot be hidden.\n\n``?_labels=on/off``\n    Expand foreign key references for every possible column. See below.\n\n``?_label=COLUMN1&_label=COLUMN2``\n    Expand foreign key references for one or more specified columns.\n\n``?_size=1000`` or ``?_size=max``\n    Sets a custom page size. This cannot exceed the ``max_returned_rows`` limit\n    passed to ``datasette serve``. Use ``max`` to get ``max_returned_rows``.\n\n``?_sort=COLUMN``\n    Sorts the results by the specified column.\n\n``?_sort_desc=COLUMN``\n    Sorts the results by the specified column in descending order.\n\n``?_search=keywords``\n    For SQLite tables that have been configured for\n    `full-text search <https://www.sqlite.org/fts3.html>`_ executes a search\n    with the provided keywords.\n\n``?_search_COLUMN=keywords``\n    Like ``_search=`` but allows you to specify the column to be searched, as\n    opposed to searching all columns that have been indexed by FTS.\n\n``?_searchmode=raw``\n    With this option, queries passed to ``?_search=`` or ``?_search_COLUMN=`` will\n    not have special characters escaped. This means you can make use of the full\n    set of `advanced SQLite FTS syntax <https://www.sqlite.org/fts5.html#full_text_query_syntax>`__,\n    though this could potentially result in errors if the wrong syntax is used.\n\n``?_where=SQL-fragment``\n    If the :ref:`actions_execute_sql` permission is enabled, this parameter\n    can be used to pass one or more additional SQL fragments to be used in the\n    `WHERE` clause of the SQL used to query the table.\n\n    This is particularly useful if you are building a JavaScript application\n    that needs to do something creative but still wants the other conveniences\n    provided by the table view (such as faceting) and hence would like not to\n    have to construct a completely custom SQL query.\n\n    Some examples:\n\n    * `facetable?_where=_neighborhood like \"%c%\"&_where=_city_id=3 <https://latest.datasette.io/fixtures/facetable?_where=_neighborhood%20like%20%22%c%%22&_where=_city_id=3>`__\n    * `facetable?_where=_city_id in (select id from facet_cities where name != \"Detroit\") <https://latest.datasette.io/fixtures/facetable?_where=_city_id%20in%20(select%20id%20from%20facet_cities%20where%20name%20!=%20%22Detroit%22)>`__\n\n``?_through={json}``\n    This can be used to filter rows via a join against another table.\n\n    The JSON parameter must include three keys: ``table``, ``column`` and ``value``.\n\n    ``table`` must be a table that the current table is related to via a foreign key relationship.\n\n    ``column`` must be a column in that other table.\n\n    ``value`` is the value that you want to match against.\n\n    For example, to filter ``roadside_attractions`` to just show the attractions that have a characteristic of \"museum\", you would construct this JSON::\n\n        {\n            \"table\": \"roadside_attraction_characteristics\",\n            \"column\": \"characteristic_id\",\n            \"value\": \"1\"\n        }\n\n    As a URL, that looks like this:\n\n    ``?_through={%22table%22:%22roadside_attraction_characteristics%22,%22column%22:%22characteristic_id%22,%22value%22:%221%22}``\n\n    Here's `an example <https://latest.datasette.io/fixtures/roadside_attractions?_through={%22table%22:%22roadside_attraction_characteristics%22,%22column%22:%22characteristic_id%22,%22value%22:%221%22}>`__.\n\n``?_next=TOKEN``\n    Pagination by continuation token - pass the token that was returned in the\n    ``\"next\"`` property by the previous page.\n\n``?_facet=column``\n    Facet by column. Can be applied multiple times, see :ref:`facets`. Only works on the default JSON output, not on any of the custom shapes.\n\n``?_facet_size=100``\n    Increase the number of facet results returned for each facet. Use ``?_facet_size=max`` for the maximum available size, determined by :ref:`setting_max_returned_rows`.\n\n``?_nofacet=1``\n    Disable all facets and facet suggestions for this page, including any defined by :ref:`facets_metadata`.\n\n``?_nosuggest=1``\n    Disable facet suggestions for this page.\n\n``?_nocount=1``\n    Disable the ``select count(*)`` query used on this page - a count of ``None`` will be returned instead.\n\n.. _expand_foreign_keys:\n\nExpanding foreign key references\n--------------------------------\n\nDatasette can detect foreign key relationships and resolve those references into\nlabels. The HTML interface does this by default for every detected foreign key\ncolumn - you can turn that off using ``?_labels=off``.\n\nYou can request foreign keys be expanded in JSON using the ``_labels=on`` or\n``_label=COLUMN`` special query string parameters. Here's what an expanded row\nlooks like:\n\n.. code-block:: json\n\n    [\n        {\n            \"rowid\": 1,\n            \"TreeID\": 141565,\n            \"qLegalStatus\": {\n                \"value\": 1,\n                \"label\": \"Permitted Site\"\n            },\n            \"qSpecies\": {\n                \"value\": 1,\n                \"label\": \"Myoporum laetum :: Myoporum\"\n            },\n            \"qAddress\": \"501X Baker St\",\n            \"SiteOrder\": 1\n        }\n    ]\n\nThe column in the foreign key table that is used for the label can be specified\nin ``datasette.yaml`` - see :ref:`table_configuration_label_column`.\n\n.. _json_api_discover_alternate:\n\nDiscovering the JSON for a page\n-------------------------------\n\nMost of the HTML pages served by Datasette provide a mechanism for discovering their JSON equivalents using the HTML ``link`` mechanism.\n\nYou can find this near the top of the source code of those pages, looking like this:\n\n.. code-block:: html\n\n    <link rel=\"alternate\"\n      type=\"application/json+datasette\"\n      href=\"https://latest.datasette.io/fixtures/sortable.json\">\n\nThe JSON URL is also made available in a ``Link`` HTTP header for the page::\n\n    Link: <https://latest.datasette.io/fixtures/sortable.json>; rel=\"alternate\"; type=\"application/json+datasette\"\n\n.. _json_api_cors:\n\nEnabling CORS\n-------------\n\nIf you start Datasette with the ``--cors`` option, each JSON endpoint will be\nserved with the following additional HTTP headers:\n\n.. [[[cog\n    from datasette.utils import add_cors_headers\n    import textwrap\n    headers = {}\n    add_cors_headers(headers)\n    output = \"\\n\".join(\"{}: {}\".format(k, v) for k, v in headers.items())\n    cog.out(\"\\n::\\n\\n\")\n    cog.out(textwrap.indent(output, '    '))\n    cog.out(\"\\n\\n\")\n.. ]]]\n\n::\n\n    Access-Control-Allow-Origin: *\n    Access-Control-Allow-Headers: Authorization, Content-Type\n    Access-Control-Expose-Headers: Link\n    Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS\n    Access-Control-Max-Age: 3600\n\n.. [[[end]]]\n\nThis allows JavaScript running on any domain to make cross-origin\nrequests to interact with the Datasette API.\n\nIf you start Datasette without the ``--cors`` option only JavaScript running on\nthe same domain as Datasette will be able to access the API.\n\nHere's how to serve ``data.db`` with CORS enabled::\n\n    datasette data.db --cors\n\n.. _json_api_write:\n\nThe JSON write API\n------------------\n\nDatasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.\n\n.. _TableInsertView:\n\nInserting rows\n~~~~~~~~~~~~~~\n\nThis requires the :ref:`actions_insert_row` permission.\n\nA single row can be inserted using the ``\"row\"`` key:\n\n::\n\n    POST /<database>/<table>/-/insert\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"row\": {\n            \"column1\": \"value1\",\n            \"column2\": \"value2\"\n        }\n    }\n\nIf successful, this will return a ``201`` status code and the newly inserted row, for example:\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"column1\": \"value1\",\n                \"column2\": \"value2\"\n            }\n        ]\n    }\n\nTo insert multiple rows at a time, use the same API method but send a list of dictionaries as the ``\"rows\"`` key:\n\n::\n\n    POST /<database>/<table>/-/insert\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"column1\": \"value1\",\n                \"column2\": \"value2\"\n            },\n            {\n                \"column1\": \"value3\",\n                \"column2\": \"value4\"\n            }\n        ]\n    }\n\nIf successful, this will return a ``201`` status code and a ``{\"ok\": true}`` response body.\n\nThe maximum number rows that can be submitted at once defaults to 100, but this can be changed using the :ref:`setting_max_insert_rows` setting.\n\nTo return the newly inserted rows, add the ``\"return\": true`` key to the request body:\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"column1\": \"value1\",\n                \"column2\": \"value2\"\n            },\n            {\n                \"column1\": \"value3\",\n                \"column2\": \"value4\"\n            }\n        ],\n        \"return\": true\n    }\n\nThis will return the same ``\"rows\"`` key as the single row example above. There is a small performance penalty for using this option.\n\nIf any of your rows have a primary key that is already in use, you will get an error and none of the rows will be inserted:\n\n.. code-block:: json\n\n    {\n        \"ok\": false,\n        \"errors\": [\n            \"UNIQUE constraint failed: new_table.id\"\n        ]\n    }\n\nPass ``\"ignore\": true`` to ignore these errors and insert the other rows:\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"column1\": \"value1\",\n                \"column2\": \"value2\"\n            },\n            {\n                \"id\": 2,\n                \"column1\": \"value3\",\n                \"column2\": \"value4\"\n            }\n        ],\n        \"ignore\": true\n    }\n\nOr you can pass ``\"replace\": true`` to replace any rows with conflicting primary keys with the new values. This requires the :ref:`actions_update_row` permission.\n\nPass ``\"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.\n\n.. _TableUpsertView:\n\nUpserting rows\n~~~~~~~~~~~~~~\n\nAn upsert is an insert or update operation. If a row with a matching primary key already exists it will be updated - otherwise a new row will be inserted.\n\nThe upsert API is mostly the same shape as the :ref:`insert API <TableInsertView>`. It requires both the :ref:`actions_insert_row` and :ref:`actions_update_row` permissions.\n\n::\n\n    POST /<database>/<table>/-/upsert\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"title\": \"Updated title for 1\",\n                \"description\": \"Updated description for 1\"\n            },\n            {\n                \"id\": 2,\n                \"description\": \"Updated description for 2\",\n            },\n            {\n                \"id\": 3,\n                \"title\": \"Item 3\",\n                \"description\": \"Description for 3\"\n            }\n        ]\n    }\n\nImagine a table with a primary key of ``id`` and which already has rows with ``id`` values of ``1`` and ``2``.\n\nThe above example will:\n\n- Update the row with ``id`` of ``1`` to set both ``title`` and ``description`` to the new values\n- Update the row with ``id`` of ``2`` to set ``title`` to the new value - ``description`` will be left unchanged\n- Insert a new row with ``id`` of ``3`` and both ``title`` and ``description`` set to the new values\n\nSimilar to ``/-/insert``, a ``row`` key with an object can be used instead of a ``rows`` array to upsert a single row.\n\nIf successful, this will return a ``200`` status code and a ``{\"ok\": true}`` response body.\n\nAdd ``\"return\": true`` to the request body to return full copies of the affected rows after they have been inserted or updated:\n\n.. code-block:: json\n\n    {\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"title\": \"Updated title for 1\",\n                \"description\": \"Updated description for 1\"\n            },\n            {\n                \"id\": 2,\n                \"description\": \"Updated description for 2\",\n            },\n            {\n                \"id\": 3,\n                \"title\": \"Item 3\",\n                \"description\": \"Description for 3\"\n            }\n        ],\n        \"return\": true\n    }\n\nThis will return the following:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"title\": \"Updated title for 1\",\n                \"description\": \"Updated description for 1\"\n            },\n            {\n                \"id\": 2,\n                \"title\": \"Item 2\",\n                \"description\": \"Updated description for 2\"\n            },\n            {\n                \"id\": 3,\n                \"title\": \"Item 3\",\n                \"description\": \"Description for 3\"\n            }\n        ]\n    }\n\nWhen using upsert you must provide the primary key column (or columns if the table has a compound primary key) for every row, or you will get a ``400`` error:\n\n.. code-block:: json\n\n    {\n        \"ok\": false,\n        \"errors\": [\n            \"Row 0 is missing primary key column(s): \\\"id\\\"\"\n        ]\n    }\n\nIf your table does not have an explicit primary key you should pass the SQLite ``rowid`` key instead.\n\nPass ``\"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.\n\n.. _RowUpdateView:\n\nUpdating a row\n~~~~~~~~~~~~~~\n\nTo update a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/update``. This requires the :ref:`actions_update_row` permission.\n\n::\n\n    POST /<database>/<table>/<row-pks>/-/update\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"update\": {\n            \"text_column\": \"New text string\",\n            \"integer_column\": 3,\n            \"float_column\": 3.14\n        }\n    }\n\n``<row-pks>`` here is the :ref:`tilde-encoded <internals_tilde_encoding>` primary key value of the row to update - or a comma-separated list of primary key values if the table has a composite primary key.\n\nYou only need to pass the columns you want to update. Any other columns will be left unchanged.\n\nIf successful, this will return a ``200`` status code and a ``{\"ok\": true}`` response body.\n\nAdd ``\"return\": true`` to the request body to return the updated row:\n\n.. code-block:: json\n\n    {\n        \"update\": {\n            \"title\": \"New title\"\n        },\n        \"return\": true\n    }\n\nThe returned JSON will look like this:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"row\": {\n            \"id\": 1,\n            \"title\": \"New title\",\n            \"other_column\": \"Will be present here too\"\n        }\n    }\n\nAny errors will return ``{\"errors\": [\"... descriptive message ...\"], \"ok\": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.\n\nPass ``\"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.\n\n.. _RowDeleteView:\n\nDeleting a row\n~~~~~~~~~~~~~~\n\nTo delete a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/delete``. This requires the :ref:`actions_delete_row` permission.\n\n::\n\n    POST /<database>/<table>/<row-pks>/-/delete\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n``<row-pks>`` here is the :ref:`tilde-encoded <internals_tilde_encoding>` primary key value of the row to delete - or a comma-separated list of primary key values if the table has a composite primary key.\n\nIf successful, this will return a ``200`` status code and a ``{\"ok\": true}`` response body.\n\nAny errors will return ``{\"errors\": [\"... descriptive message ...\"], \"ok\": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.\n\n.. _TableCreateView:\n\nCreating a table\n~~~~~~~~~~~~~~~~\n\nTo create a table, make a ``POST`` to ``/<database>/-/create``. This requires the :ref:`actions_create_table` permission.\n\n::\n\n    POST /<database>/-/create\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"table\": \"name_of_new_table\",\n        \"columns\": [\n            {\n                \"name\": \"id\",\n                \"type\": \"integer\"\n            },\n            {\n                \"name\": \"title\",\n                \"type\": \"text\"\n            }\n        ],\n        \"pk\": \"id\"\n    }\n\nThe JSON here describes the table that will be created:\n\n* ``table`` is the name of the table to create. This field is required.\n* ``columns`` is a list of columns to create. Each column is a dictionary with ``name`` and ``type`` keys.\n\n  - ``name`` is the name of the column. This is required.\n  - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``.\n\n* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column.\n\n  If the primary key is an integer column, it will be configured to automatically increment for each new record.\n\n  If you set this to ``id`` without including an ``id`` column in the list of ``columns``, Datasette will create an auto-incrementing integer ID column for you.\n\n* ``pks`` can be used instead of ``pk`` to create a compound primary key. It should be a JSON list of column names to use in that primary key.\n* ``ignore`` can be set to ``true`` to ignore existing rows by primary key if the table already exists.\n* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission.\n* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.\n\nIf the table is successfully created this will return a ``201`` status code and the following response:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"database\": \"data\",\n        \"table\": \"name_of_new_table\",\n        \"table_url\": \"http://127.0.0.1:8001/data/name_of_new_table\",\n        \"table_api_url\": \"http://127.0.0.1:8001/data/name_of_new_table.json\",\n        \"schema\": \"CREATE TABLE [name_of_new_table] (\\n   [id] INTEGER PRIMARY KEY,\\n   [title] TEXT\\n)\"\n    }\n\n.. _TableCreateView_example:\n\nCreating a table from example data\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nInstead of specifying ``columns`` directly you can instead pass a single example ``row`` or a list of ``rows``.\nDatasette will create a table with a schema that matches those rows and insert them for you:\n\n::\n\n    POST /<database>/-/create\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"table\": \"creatures\",\n        \"rows\": [\n            {\n                \"id\": 1,\n                \"name\": \"Tarantula\"\n            },\n            {\n                \"id\": 2,\n                \"name\": \"Kākāpō\"\n            }\n        ],\n        \"pk\": \"id\"\n    }\n\nDoing this requires both the :ref:`actions_create_table` and :ref:`actions_insert_row` permissions.\n\nThe ``201`` response here will be similar to the ``columns`` form, but will also include the number of rows that were inserted as ``row_count``:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"database\": \"data\",\n        \"table\": \"creatures\",\n        \"table_url\": \"http://127.0.0.1:8001/data/creatures\",\n        \"table_api_url\": \"http://127.0.0.1:8001/data/creatures.json\",\n        \"schema\": \"CREATE TABLE [creatures] (\\n   [id] INTEGER PRIMARY KEY,\\n   [name] TEXT\\n)\",\n        \"row_count\": 2\n    }\n\nYou can call the create endpoint multiple times for the same table provided you are specifying the table using the ``rows`` or ``row`` option. New rows will be inserted into the table each time. This means you can use this API if you are unsure if the relevant table has been created yet.\n\nIf you pass a row to the create endpoint with a primary key that already exists you will get an error that looks like this:\n\n.. code-block:: json\n\n    {\n        \"ok\": false,\n        \"errors\": [\n            \"UNIQUE constraint failed: creatures.id\"\n        ]\n    }\n\nYou can avoid this error by passing the same ``\"ignore\": true`` or ``\"replace\": true`` options to the create endpoint as you can to the :ref:`insert endpoint <TableInsertView>`.\n\nTo use the ``\"replace\": true`` option you will also need the :ref:`actions_update_row` permission.\n\nPass ``\"alter\": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission.\n\n.. _TableSetColumnTypeView:\n\nSetting a column type\n~~~~~~~~~~~~~~~~~~~~~\n\nTo set a column type for a table column, make a ``POST`` to ``/<database>/<table>/-/set-column-type``. This requires the :ref:`actions_set_column_type` permission.\n\n::\n\n    POST /<database>/<table>/-/set-column-type\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\n.. code-block:: json\n\n    {\n        \"column\": \"title\",\n        \"column_type\": {\n            \"type\": \"email\"\n        }\n    }\n\nThis will return a ``200`` response like this:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"database\": \"data\",\n        \"table\": \"posts\",\n        \"column\": \"title\",\n        \"column_type\": {\n            \"type\": \"email\",\n            \"config\": null\n        }\n    }\n\nTo provide column type configuration, include a ``config`` object:\n\n.. code-block:: json\n\n    {\n        \"column\": \"title\",\n        \"column_type\": {\n            \"type\": \"url\",\n            \"config\": {\n                \"max_length\": 200\n            }\n        }\n    }\n\nTo clear an existing column type assignment, set ``column_type`` to ``null``:\n\n.. code-block:: json\n\n    {\n        \"column\": \"title\",\n        \"column_type\": null\n    }\n\nThis API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones.\n\nAny errors will return ``{\"errors\": [\"... descriptive message ...\"], \"ok\": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.\n\n.. _TableDropView:\n\nDropping tables\n~~~~~~~~~~~~~~~\n\nTo drop a table, make a ``POST`` to ``/<database>/<table>/-/drop``. This requires the :ref:`actions_drop_table` permission.\n\n::\n\n    POST /<database>/<table>/-/drop\n    Content-Type: application/json\n    Authorization: Bearer dstok_<rest-of-token>\n\nWithout a POST body this will return a status ``200`` with a note about how many rows will be deleted:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"database\": \"<database>\",\n        \"table\": \"<table>\",\n        \"row_count\": 5,\n        \"message\": \"Pass \\\"confirm\\\": true to confirm\"\n    }\n\nIf you pass the following POST body:\n\n.. code-block:: json\n\n    {\n        \"confirm\": true\n    }\n\nThen the table will be dropped and a status ``200`` response of ``{\"ok\": true}`` will be returned.\n\nAny errors will return ``{\"errors\": [\"... descriptive message ...\"], \"ok\": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.\n"
  },
  {
    "path": "docs/metadata.rst",
    "content": ".. _metadata:\n\nMetadata\n========\n\nData loves metadata. Any time you run Datasette you can optionally include a\nYAML or JSON file with metadata about your databases and tables. Datasette will then\ndisplay that information in the web UI.\n\nRun Datasette like this::\n\n    datasette database1.db database2.db --metadata metadata.yaml\n\nYour ``metadata.yaml`` file can look something like this:\n\n\n.. [[[cog\n    from metadata_doc import metadata_example\n    metadata_example(cog, {\n        \"title\": \"Custom title for your index page\",\n        \"description\": \"Some description text can go here\",\n        \"license\": \"ODbL\",\n        \"license_url\": \"https://opendatacommons.org/licenses/odbl/\",\n        \"source\": \"Original Data Source\",\n        \"source_url\": \"http://example.com/\"\n    })\n.. ]]]\n\n.. tab:: metadata.yaml\n\n    .. code-block:: yaml\n\n        title: Custom title for your index page\n        description: Some description text can go here\n        license: ODbL\n        license_url: https://opendatacommons.org/licenses/odbl/\n        source: Original Data Source\n        source_url: http://example.com/\n\n\n.. tab:: metadata.json\n\n    .. code-block:: json\n\n        {\n          \"title\": \"Custom title for your index page\",\n          \"description\": \"Some description text can go here\",\n          \"license\": \"ODbL\",\n          \"license_url\": \"https://opendatacommons.org/licenses/odbl/\",\n          \"source\": \"Original Data Source\",\n          \"source_url\": \"http://example.com/\"\n        }\n.. [[[end]]]\n\n\nChoosing YAML over JSON adds support for multi-line strings and comments.\n\nThe above metadata will be displayed on the index page of your Datasette-powered\nsite. The source and license information will also be included in the footer of\nevery page served by Datasette.\n\nAny special HTML characters in ``description`` will be escaped. If you want to\ninclude HTML in your description, you can use a ``description_html`` property\ninstead.\n\nPer-database and per-table metadata\n-----------------------------------\n\nMetadata at the top level of the file will be shown on the index page and in the\nfooter on every page of the site. The license and source is expected to apply to\nall of your data.\n\nYou can also provide metadata at the per-database or per-table level, like this:\n\n.. [[[cog\n    metadata_example(cog, {\n        \"databases\": {\n            \"database1\": {\n                \"source\": \"Alternative source\",\n                \"source_url\": \"http://example.com/\",\n                \"tables\": {\n                    \"example_table\": {\n                        \"description_html\": \"Custom <em>table</em> description\",\n                        \"license\": \"CC BY 3.0 US\",\n                        \"license_url\": \"https://creativecommons.org/licenses/by/3.0/us/\"\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: metadata.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          database1:\n            source: Alternative source\n            source_url: http://example.com/\n            tables:\n              example_table:\n                description_html: Custom <em>table</em> description\n                license: CC BY 3.0 US\n                license_url: https://creativecommons.org/licenses/by/3.0/us/\n\n\n.. tab:: metadata.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"database1\": {\n              \"source\": \"Alternative source\",\n              \"source_url\": \"http://example.com/\",\n              \"tables\": {\n                \"example_table\": {\n                  \"description_html\": \"Custom <em>table</em> description\",\n                  \"license\": \"CC BY 3.0 US\",\n                  \"license_url\": \"https://creativecommons.org/licenses/by/3.0/us/\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n\nEach of the top-level metadata fields can be used at the database and table level.\n\n.. _metadata_source_license_about:\n\nSource, license and about\n-------------------------\n\nThe three visible metadata fields you can apply to everything, specific databases or specific tables are source, license and about. All three are optional.\n\n**source** and **source_url** should be used to indicate where the underlying data came from.\n\n**license** and **license_url** should be used to indicate the license under which the data can be used.\n\n**about** and **about_url** can be used to link to further information about the project - an accompanying blog entry for example.\n\nFor each of these you can provide just the ``*_url`` field and Datasette will treat that as the default link label text and display the URL directly on the page.\n\n.. _metadata_column_descriptions:\n\nColumn descriptions\n-------------------\n\nYou can include descriptions for your columns by adding a ``\"columns\": {\"name-of-column\": \"description-of-column\"}`` block to your table metadata:\n\n.. [[[cog\n    metadata_example(cog, {\n        \"databases\": {\n            \"database1\": {\n                \"tables\": {\n                    \"example_table\": {\n                        \"columns\": {\n                            \"column1\": \"Description of column 1\",\n                            \"column2\": \"Description of column 2\"\n                        }\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: metadata.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          database1:\n            tables:\n              example_table:\n                columns:\n                  column1: Description of column 1\n                  column2: Description of column 2\n\n\n.. tab:: metadata.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"database1\": {\n              \"tables\": {\n                \"example_table\": {\n                  \"columns\": {\n                    \"column1\": \"Description of column 1\",\n                    \"column2\": \"Description of column 2\"\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThese will be displayed at the top of the table page, and will also show in the cog menu for each column.\n\nYou can see an example of how these look at `latest.datasette.io/fixtures/roadside_attractions <https://latest.datasette.io/fixtures/roadside_attractions>`__.\n\n.. _metadata_table_config:\n\nTable configuration\n-------------------\n\nDatasette supports a range of table-level configuration options including sort order, page size, facets, full-text search, column types, and more. These are now documented in the :ref:`table configuration <configuration_reference_table>` section of the configuration reference.\n\nFor backwards compatibility these options can be specified in either ``metadata.yaml`` or ``datasette.yaml``.\n\n.. _metadata_reference:\n\nMetadata reference\n------------------\n\n\nA full reference of every supported option in a ``metadata.json`` or ``metadata.yaml`` file.\n\n\nTop-level metadata\n~~~~~~~~~~~~~~~~~~\n\n\"Top-level\" metadata refers to fields that can be specified at the root level  of a metadata file. These attributes are meant to describe the entire Datasette instance.\n\nThe following are the full list of allowed top-level metadata fields:\n\n- ``title``\n- ``description``\n- ``description_html``\n- ``license``\n- ``license_url``\n- ``source``\n- ``source_url``\n\nDatabase-level metadata\n~~~~~~~~~~~~~~~~~~~~~~~\n\n\"Database-level\" metadata refers to fields that can be specified for each database in a Datasette instance. These attributes should be listed under a database inside the `\"databases\"` field.\n\nThe following are the full list of allowed database-level metadata fields:\n\n- ``source``\n- ``source_url``\n- ``license``\n- ``license_url``\n- ``about``\n- ``about_url``\n\nTable-level metadata\n~~~~~~~~~~~~~~~~~~~~\n\n\"Table-level\" metadata refers to fields that can be specified for each table in a Datasette instance. These attributes should be listed under a specific table using the `\"tables\"` field.\n\nThe following metadata fields are supported at the table level:\n\n- ``source``\n- ``source_url``\n- ``license``\n- ``license_url``\n- ``about``\n- ``about_url``\n- ``columns`` (see :ref:`metadata_column_descriptions`)\n\nAdditionally, tables support a number of configuration options (``sort``, ``sort_desc``, ``size``, ``sortable_columns``, ``label_column``, ``hidden``, ``facets``, ``facet_size``, ``fts_table``, ``fts_pk``, ``searchmode``, ``column_types``). See :ref:`table configuration <configuration_reference_table>` for full details.\n"
  },
  {
    "path": "docs/metadata_doc.py",
    "content": "import json\nimport textwrap\nfrom yaml import safe_dump\nfrom ruamel.yaml import YAML\n\n\ndef metadata_example(cog, data=None, yaml=None):\n    assert data or yaml, \"Must provide data= or yaml=\"\n    assert not (data and yaml), \"Cannot use data= and yaml=\"\n    output_yaml = None\n    if yaml:\n        # dedent it first\n        yaml = textwrap.dedent(yaml).strip()\n        data = YAML().load(yaml)\n        output_yaml = yaml\n    else:\n        output_yaml = safe_dump(data, sort_keys=False)\n    cog.out(\"\\n.. tab:: metadata.yaml\\n\\n\")\n    cog.out(\"    .. code-block:: yaml\\n\\n\")\n    cog.out(textwrap.indent(output_yaml, \"        \"))\n    cog.out(\"\\n\\n.. tab:: metadata.json\\n\\n\")\n    cog.out(\"    .. code-block:: json\\n\\n\")\n    cog.out(textwrap.indent(json.dumps(data, indent=2), \"        \"))\n    cog.out(\"\\n\")\n\n\ndef config_example(\n    cog, input, yaml_title=\"datasette.yaml\", json_title=\"datasette.json\"\n):\n    if type(input) is str:\n        data = YAML().load(input)\n        output_yaml = input\n    else:\n        data = input\n        output_yaml = safe_dump(input, sort_keys=False)\n    cog.out(\"\\n.. tab:: {}\\n\\n\".format(yaml_title))\n    cog.out(\"    .. code-block:: yaml\\n\\n\")\n    cog.out(textwrap.indent(output_yaml, \"        \"))\n    cog.out(\"\\n\\n.. tab:: {}\\n\\n\".format(json_title))\n    cog.out(\"    .. code-block:: json\\n\\n\")\n    cog.out(textwrap.indent(json.dumps(data, indent=2), \"        \"))\n    cog.out(\"\\n\")\n\n\ndef internal_schema(cog):\n    import asyncio\n    from datasette.app import Datasette\n    from sqlite_utils import Database\n\n    ds = Datasette()\n    db = ds.get_internal_database()\n\n    def get_schema(conn):\n        return Database(conn).schema\n\n    async def inner():\n        await ds.invoke_startup()\n        await ds._refresh_schemas()\n        return await db.execute_fn(get_schema)\n\n    schema = asyncio.run(inner())\n    cog.out(\"\\n.. code-block:: sql\")\n    cog.out(\"\\n\\n\")\n    cog.out(textwrap.indent(schema, \"    \"))\n    cog.out(\"\\n\\n\")\n"
  },
  {
    "path": "docs/pages.rst",
    "content": ".. _pages:\n\n=========================\n Pages and API endpoints\n=========================\n\nThe Datasette web application offers a number of different pages that can be accessed to explore the data in question, each of which is accompanied by an equivalent JSON API.\n\n.. _IndexView:\n\nTop-level index\n===============\n\nThe root page of any Datasette installation is an index page that lists all of the currently attached databases. Some examples:\n\n* `fivethirtyeight.datasettes.com <https://fivethirtyeight.datasettes.com/>`_\n* `register-of-members-interests.datasettes.com <https://register-of-members-interests.datasettes.com/>`_\n\nAdd ``/.json`` to the end of the URL for the JSON version of the underlying data:\n\n* `fivethirtyeight.datasettes.com/.json <https://fivethirtyeight.datasettes.com/.json>`_\n* `register-of-members-interests.datasettes.com/.json <https://register-of-members-interests.datasettes.com/.json>`_\n\nThe index page can also be accessed at ``/-/``, useful for if the default index page has been replaced using an :ref:`index.html custom template <customization_custom_templates>`. The ``/-/`` page will always render the default Datasette ``index.html`` template.\n\n.. _DatabaseView:\n\nDatabase\n========\n\nEach database has a page listing the tables, views and canned queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.\n\nExamples:\n\n* `fivethirtyeight.datasettes.com/fivethirtyeight <https://fivethirtyeight.datasettes.com/fivethirtyeight>`_\n* `datasette.io/global-power-plants <https://datasette.io/global-power-plants>`_\n\nThe JSON version of this page provides programmatic access to the underlying data:\n\n* `fivethirtyeight.datasettes.com/fivethirtyeight.json <https://fivethirtyeight.datasettes.com/fivethirtyeight.json>`_\n* `datasette.io/global-power-plants.json <https://datasette.io/global-power-plants.json>`_\n\n.. _DatabaseView_hidden:\n\nHidden tables\n-------------\n\nSome tables listed on the database page are treated as hidden. Hidden tables are not completely invisible - they can be accessed through the \"hidden tables\" link at the bottom of the page. They are hidden because they represent low-level implementation details which are generally not useful to end-users of Datasette.\n\nThe following tables are hidden by default:\n\n- Any table with a name that starts with an underscore - this is a Datasette convention to help plugins easily hide their own internal tables.\n- Tables that have been configured as ``\"hidden\": true`` using :ref:`table_configuration_hidden`.\n- ``*_fts`` tables that implement SQLite full-text search indexes.\n- Tables relating to the inner workings of the SpatiaLite SQLite extension.\n- ``sqlite_stat`` tables used to store statistics used by the query optimizer.\n\n.. _QueryView:\n\nQueries\n=======\n\nThe ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.\n\nThis means you can link directly to a query by constructing the following URL:\n\n``/database-name/-/query?sql=SELECT+*+FROM+table_name``\n\nEach configured :ref:`canned query <canned_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.\n\nIn both cases adding a ``.json`` extension to the URL will return the results as JSON.\n\n.. _TableView:\n\nTable\n=====\n\nThe table page is the heart of Datasette: it allows users to interactively explore the contents of a database table, including sorting, filtering, :ref:`full_text_search` and applying :ref:`facets`.\n\nThe HTML interface is worth spending some time exploring. As with other pages, you can return the JSON data by appending ``.json`` to the URL path, before any `?` query string arguments.\n\nThe query string arguments are described in more detail here: :ref:`table_arguments`\n\nYou can also use the table page to interactively construct a SQL query - by applying different filters and a sort order for example - and then click the \"View and edit SQL\" link to see the SQL query that was used for the page and edit and re-submit it.\n\nSome examples:\n\n* `../items <https://register-of-members-interests.datasettes.com/regmem/items>`_ lists all of the line-items registered by UK MPs as potential conflicts of interest. It demonstrates Datasette's support for :ref:`full_text_search`.\n* `../antiquities-act%2Factions_under_antiquities_act <https://fivethirtyeight.datasettes.com/fivethirtyeight/antiquities-act%2Factions_under_antiquities_act>`_ is an interface for exploring the \"actions under the antiquities act\" data table published by FiveThirtyEight.\n* `../global-power-plants?country_long=United+Kingdom&primary_fuel=Gas <https://datasette.io/global-power-plants/global-power-plants?_facet=primary_fuel&_facet=owner&_facet=country_long&country_long__exact=United+Kingdom&primary_fuel=Gas>`_ is a filtered table page showing every Gas power plant in the United Kingdom. It includes some default facets (configured using `its metadata.json <https://datasette.io/-/metadata>`_) and uses the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`_ plugin to show a map of the results.\n\n.. _RowView:\n\nRow\n===\n\nEvery row in every Datasette table has its own URL. This means individual records can be linked to directly.\n\nTable cells with extremely long text contents are truncated on the table view according to the :ref:`setting_truncate_cells_html` setting. If a cell has been truncated the full length version of that cell will be available on the row page.\n\nRows which are the targets of foreign key references from other tables will show a link to a filtered search for all records that reference that row. Here's an example from the Registers of Members Interests database:\n\n`../people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001 <https://register-of-members-interests.datasettes.com/regmem/people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001>`_\n\nNote that this URL includes the encoded primary key of the record.\n\nHere's that same page as JSON:\n\n`../people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001.json <https://register-of-members-interests.datasettes.com/regmem/people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001.json>`_\n\n\n.. _pages_schemas:\n\nSchemas\n=======\n\nDatasette offers ``/-/schema`` endpoints to expose the SQL schema for databases and tables.\n\n.. _InstanceSchemaView:\n\nInstance schema\n---------------\n\nAccess ``/-/schema`` to see the complete schema for all attached databases in the Datasette instance.\n\nUse ``/-/schema.md`` to get the same information as Markdown.\n\nUse ``/-/schema.json`` to get the same information as JSON, which looks like this:\n\n.. code-block:: json\n\n    {\n      \"schemas\": [\n        {\n          \"database\": \"content\",\n          \"schema\": \"create table posts ...\"\n        }\n    }\n\n.. _DatabaseSchemaView:\n\nDatabase schema\n---------------\n\nUse ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``\"database\"`` and ``\"schema\"`` keys.\n\n.. _TableSchemaView:\n\nTable schema\n------------\n\nUse ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``\"database\"``, ``\"table\"``, and ``\"schema\"`` keys.\n"
  },
  {
    "path": "docs/performance.rst",
    "content": ".. _performance:\n\nPerformance and caching\n=======================\n\nDatasette runs on top of SQLite, and SQLite has excellent performance.  For small databases almost any query should return in just a few milliseconds, and larger databases (100s of MBs or even GBs of data) should perform extremely well provided your queries make sensible use of database indexes.\n\nThat said, there are a number of tricks you can use to improve Datasette's performance.\n\n.. _performance_immutable_mode:\n\nImmutable mode\n--------------\n\nIf you can be certain that a SQLite database file will not be changed by another process you can tell Datasette to open that file in *immutable mode*.\n\nDoing so will disable all locking and change detection, which can result in improved query performance.\n\nThis also enables further optimizations relating to HTTP caching, described below.\n\nTo open a file in immutable mode pass it to the datasette command using the ``-i`` option::\n\n    datasette -i data.db\n\nWhen you open a file in immutable mode like this Datasette will also calculate and cache the row counts for each table in that database when it first starts up, further improving performance.\n\n.. _performance_inspect:\n\nUsing \"datasette inspect\"\n-------------------------\n\nCounting the rows in a table can be a very expensive operation on larger databases. In immutable mode Datasette performs this count only once and caches the results, but this can still cause server startup time to increase by several seconds or more.\n\nIf you know that a database is never going to change you can precalculate the table row counts once and store then in a JSON file, then use that file when you later start the server.\n\nTo create a JSON file containing the calculated row counts for a database, use the following::\n\n    datasette inspect data.db --inspect-file=counts.json\n\nThen later you can start Datasette against the ``counts.json`` file and use it to skip the row counting step and speed up server startup::\n\n    datasette -i data.db --inspect-file=counts.json\n\nYou need to use the ``-i`` immutable mode against the database file here or the counts from the JSON file will be ignored.\n\nYou will rarely need to use this optimization in every-day use, but several of the ``datasette publish`` commands described in :ref:`publishing` use this optimization for better performance when deploying a database file to a hosting provider.\n\nHTTP caching\n------------\n\nIf your database is immutable and guaranteed not to change, you can gain major performance improvements from Datasette by enabling HTTP caching.\n\nThis can work at two different levels. First, it can tell browsers to cache the results of queries and serve future requests from the browser cache.\n\nMore significantly, it allows you to run Datasette behind a caching proxy such as `Varnish <https://varnish-cache.org/>`__ or use a cache provided by a hosted service such as `Fastly <https://www.fastly.com/>`__ or `Cloudflare <https://www.cloudflare.com/>`__. This can provide incredible speed-ups since a query only needs to be executed by Datasette the first time it is accessed - all subsequent hits can then be served by the cache.\n\nUsing a caching proxy in this way could enable a Datasette-backed visualization to serve thousands of hits a second while running Datasette itself on extremely inexpensive hosting.\n\nDatasette's integration with HTTP caches can be enabled using a combination of configuration options and query string arguments.\n\nThe :ref:`setting_default_cache_ttl` setting sets the default HTTP cache TTL for all Datasette pages. This is 5 seconds unless you change it - you can set it to 0 if you wish to disable HTTP caching entirely.\n\nYou can also change the cache timeout on a per-request basis using the ``?_ttl=10`` query string parameter. This can be useful when you are working with the Datasette JSON API - you may decide that a specific query can be cached for a longer time, or maybe you need to set ``?_ttl=0`` for some requests for example if you are running a SQL ``order by random()`` query.\n\n.. _performance_hashed_urls:\n\ndatasette-hashed-urls\n---------------------\n\nIf you open a database file in immutable mode using the ``-i`` option, you can be assured that the content of that database will not change for the lifetime of the Datasette server.\n\nThe `datasette-hashed-urls plugin <https://datasette.io/plugins/datasette-hashed-urls>`__ implements an optimization where your database is served with part of the SHA-256 hash of the database contents baked into the URL.\n\nA database at ``/fixtures`` will instead be served at ``/fixtures-aa7318b``, and a year-long cache expiry header will be returned with those pages.\n\nThis will then be cached by both browsers and caching proxies such as Cloudflare or Fastly, providing a potentially significant performance boost.\n\nTo install the plugin, run the following::\n\n    datasette install datasette-hashed-urls\n\n.. note::\n    Prior to Datasette 0.61 hashed URL mode was a core Datasette feature, enabled using the ``hash_urls`` setting. This implementation has now been removed in favor of the ``datasette-hashed-urls`` plugin.\n\n    Prior to Datasette 0.28 hashed URL mode was the default behaviour for Datasette, since all database files were assumed to be immutable and unchanging. From 0.28 onwards the default has been to treat database files as mutable unless explicitly configured otherwise.\n"
  },
  {
    "path": "docs/plugin_hooks.rst",
    "content": ".. _plugin_hooks:\n\nPlugin hooks\n============\n\nDatasette :ref:`plugins <plugins>` use *plugin hooks* to customize Datasette's behavior. These hooks are powered by the `pluggy <https://pluggy.readthedocs.io/>`__ plugin system.\n\nEach plugin can implement one or more hooks using the ``@hookimpl`` decorator against a function named that matches one of the hooks documented on this page.\n\nWhen you implement a plugin hook you can accept any or all of the parameters that are documented as being passed to that hook.\n\nFor example, you can implement the ``render_cell`` plugin hook like this even though the full documented hook signature is ``render_cell(row, value, column, table, pks, database, datasette, request)``:\n\n.. code-block:: python\n\n    @hookimpl\n    def render_cell(value, column):\n        if column == \"stars\":\n            return \"*\" * int(value)\n\n.. contents:: List of plugin hooks\n   :local:\n   :class: this-will-duplicate-information-and-it-is-still-useful-here\n\n.. _plugin_hook_prepare_connection:\n\nprepare_connection(conn, database, datasette)\n---------------------------------------------\n\n``conn`` - sqlite3 connection object\n    The connection that is being opened\n\n``database`` - string\n    The name of the database\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``\n\nThis hook is called when a new SQLite database connection is created. You can\nuse it to `register custom SQL functions <https://docs.python.org/2/library/sqlite3.html#sqlite3.Connection.create_function>`_,\naggregates and collations. For example:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import random\n\n\n    @hookimpl\n    def prepare_connection(conn):\n        conn.create_function(\n            \"random_integer\", 2, random.randint\n        )\n\nThis registers a SQL function called ``random_integer`` which takes two\narguments and can be called like this::\n\n    select random_integer(1, 10);\n\n``prepare_connection()`` hooks are not called for Datasette's :ref:`internal database <internals_internal>`.\n\nExamples: `datasette-jellyfish <https://datasette.io/plugins/datasette-jellyfish>`__, `datasette-jq <https://datasette.io/plugins/datasette-jq>`__, `datasette-haversine <https://datasette.io/plugins/datasette-haversine>`__, `datasette-rure <https://datasette.io/plugins/datasette-rure>`__\n\n.. _plugin_hook_write_wrapper:\n\nwrite_wrapper(datasette, database, request, transaction)\n--------------------------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``database`` - string\n    The name of the database being written to.\n\n``request`` - :ref:`internals_request` or ``None``\n    The HTTP request that triggered this write, if available.  This will be ``None`` for writes that do not originate from an HTTP request (e.g. writes triggered by plugins during startup).\n\n``transaction`` - bool\n    ``True`` if the write will be wrapped in a database transaction.\n\nReturn a generator function that accepts a ``conn`` argument (a SQLite connection object).  The generator should ``yield`` exactly once.  Code before the ``yield`` runs before the write function executes; code after the ``yield`` runs after it completes.\n\nThe result of the write function is sent back through the ``yield``, so you can capture it with ``result = yield``.\n\nIf the write function raises an exception, it is thrown into the generator so you can handle it with a ``try`` / ``except`` around the ``yield``.\n\nReturn ``None`` to skip wrapping for this particular write.\n\nThis example logs every write operation:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def write_wrapper(datasette, database, request):\n        def wrapper(conn):\n            print(f\"Before write to {database}\")\n            result = yield\n            print(f\"After write to {database}\")\n\n        return wrapper\n\nThis more advanced example uses the SQLite authorizer callback to block writes to a specific table for non-admin users:\n\n.. code-block:: python\n\n    import sqlite3\n    from datasette import hookimpl\n\n    WRITE_ACTIONS = (\n        sqlite3.SQLITE_INSERT,\n        sqlite3.SQLITE_UPDATE,\n        sqlite3.SQLITE_DELETE,\n    )\n\n\n    @hookimpl\n    def write_wrapper(datasette, database, request):\n        actor = None\n        if request:\n            actor = request.actor\n        if actor and actor.get(\"id\") == \"admin\":\n            return None\n\n        def wrapper(conn):\n            def authorizer(\n                action, arg1, arg2, db_name, trigger\n            ):\n                if (\n                    action in WRITE_ACTIONS\n                    and arg1 == \"protected_table\"\n                ):\n                    return sqlite3.SQLITE_DENY\n                return sqlite3.SQLITE_OK\n\n            conn.set_authorizer(authorizer)\n            try:\n                yield\n            finally:\n                conn.set_authorizer(None)\n\n        return wrapper\n\nThe ``conn`` object passed to the generator is the same connection that the write function will use.  Because the generator and the write function execute together in a single call on the write thread, any state you set on the connection (authorizers, pragmas, temporary tables) is visible to the write and can be cleaned up afterwards.\n\nWhen multiple plugins implement ``write_wrapper``, they are nested following pluggy's default calling convention.\n\n.. _plugin_hook_prepare_jinja2_environment:\n\nprepare_jinja2_environment(env, datasette)\n------------------------------------------\n\n``env`` - jinja2 Environment\n    The template environment that is being prepared\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``\n\nThis hook is called with the Jinja2 environment that is used to evaluate\nDatasette HTML templates. You can use it to do things like `register custom\ntemplate filters <http://jinja.pocoo.org/docs/2.10/api/#custom-filters>`_, for\nexample:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def prepare_jinja2_environment(env):\n        env.filters[\"uppercase\"] = lambda u: u.upper()\n\nYou can now use this filter in your custom templates like so::\n\n    Table name: {{ table|uppercase }}\n\nThis function can return an awaitable function if it needs to run any async code.\n\nExamples: `datasette-edit-templates <https://datasette.io/plugins/datasette-edit-templates>`_\n\n.. _plugin_page_extras:\n\nPage extras\n-----------\n\nThese plugin hooks can be used to affect the way HTML pages for different Datasette interfaces are rendered.\n\n.. _plugin_hook_extra_template_vars:\n\nextra_template_vars(template, database, table, columns, view_name, request, datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nExtra template variables that should be made available in the rendered template context.\n\n``template`` - string\n    The template that is being rendered, e.g. ``database.html``\n\n``database`` - string or None\n    The name of the database, or ``None`` if the page does not correspond to a database (e.g. the root page)\n\n``table`` - string or None\n    The name of the table, or ``None`` if the page does not correct to a table\n\n``columns`` - list of strings or None\n    The names of the database columns that will be displayed on this page. ``None`` if the page does not contain a table.\n\n``view_name`` - string\n    The name of the view being displayed. (``index``, ``database``, ``table``, and ``row`` are the most important ones.)\n\n``request`` - :ref:`internals_request` or None\n    The current HTTP request. This can be ``None`` if the request object is not available.\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``\n\nThis hook can return one of three different types:\n\nDictionary\n    If you return a dictionary its keys and values will be merged into the template context.\n\nFunction that returns a dictionary\n    If you return a function it will be executed. If it returns a dictionary those values will will be merged into the template context.\n\nFunction that returns an awaitable function that returns a dictionary\n    You can also return a function which returns an awaitable function which returns a dictionary.\n\nDatasette 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.\n\nHere's an example plugin that adds a ``\"user_agent\"`` variable to the template context containing the current request's User-Agent header:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_template_vars(request):\n        return {\"user_agent\": request.headers.get(\"user-agent\")}\n\nThis example returns an awaitable function which adds a list of ``hidden_table_names`` to the context:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_template_vars(datasette, database):\n        async def hidden_table_names():\n            if database:\n                db = datasette.databases[database]\n                return {\n                    \"hidden_table_names\": await db.hidden_table_names()\n                }\n            else:\n                return {}\n\n        return hidden_table_names\n\nAnd here's an example which adds a ``sql_first(sql_query)`` function which executes a SQL statement and returns the first column of the first row of results:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_template_vars(datasette, database):\n        async def sql_first(sql, dbname=None):\n            dbname = (\n                dbname\n                or database\n                or next(iter(datasette.databases.keys()))\n            )\n            result = await datasette.execute(dbname, sql)\n            return result.rows[0][0]\n\n        return {\"sql_first\": sql_first}\n\nYou can then use the new function in a template like so::\n\n    SQLite version: {{ sql_first(\"select sqlite_version()\") }}\n\nExamples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-template-sql <https://datasette.io/plugins/datasette-template-sql>`_\n\n.. _plugin_hook_extra_css_urls:\n\nextra_css_urls(template, database, table, columns, view_name, request, datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`\n\nReturn a list of extra CSS URLs that should be included on the page. These can\ntake advantage of the CSS class hooks described in :ref:`customization`.\n\nThis can be a list of URLs:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def extra_css_urls():\n        return [\n            \"https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css\"\n        ]\n\nOr a list of dictionaries defining both a URL and an\n`SRI hash <https://www.srihash.org/>`_:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_css_urls():\n        return [\n            {\n                \"url\": \"https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css\",\n                \"sri\": \"sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4\",\n            }\n        ]\n\nThis function can also return an awaitable function, useful if it needs to run any async code:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_css_urls(datasette):\n        async def inner():\n            db = datasette.get_database()\n            results = await db.execute(\n                \"select url from css_files\"\n            )\n            return [r[0] for r in results]\n\n        return inner\n\nExamples: `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster-map>`_, `datasette-vega <https://datasette.io/plugins/datasette-vega>`_\n\n.. _plugin_hook_extra_js_urls:\n\nextra_js_urls(template, database, table, columns, view_name, request, datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`\n\nThis works in the same way as ``extra_css_urls()`` but for JavaScript. You can\nreturn a list of URLs, a list of dictionaries or an awaitable function that returns those things:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def extra_js_urls():\n        return [\n            {\n                \"url\": \"https://code.jquery.com/jquery-3.3.1.slim.min.js\",\n                \"sri\": \"sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo\",\n            }\n        ]\n\nYou can also return URLs to files from your plugin's ``static/`` directory, if\nyou have one:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_js_urls():\n        return [\"/-/static-plugins/your-plugin/app.js\"]\n\nNote that ``your-plugin`` here should be the hyphenated plugin name - the name that is displayed in the list on the ``/-/plugins`` debug page.\n\nIf your code uses `JavaScript modules <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules>`__ you should include the ``\"module\": True`` key. See :ref:`configuration_reference_css_js` for more details.\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_js_urls():\n        return [\n            {\n                \"url\": \"/-/static-plugins/your-plugin/app.js\",\n                \"module\": True,\n            }\n        ]\n\nExamples: `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster-map>`_, `datasette-vega <https://datasette.io/plugins/datasette-vega>`_\n\n.. _plugin_hook_extra_body_script:\n\nextra_body_script(template, database, table, columns, view_name, request, datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nExtra JavaScript to be added to a ``<script>`` block at the end of the ``<body>`` element on the page.\n\nThis takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`\n\nThe ``template``, ``database``, ``table`` and ``view_name`` options can be used to return different code depending on which template is being rendered and which database or table are being processed.\n\nThe ``datasette`` instance is provided primarily so that you can consult any plugin configuration options that may have been set, using the ``datasette.plugin_config(plugin_name)`` method documented above.\n\nThis function can return a string containing JavaScript, or a dictionary as described below, or a function or awaitable function that returns a string or dictionary.\n\nUse a dictionary if you want to specify that the code should be placed in a ``<script type=\"module\">...</script>`` element:\n\n.. code-block:: python\n\n    @hookimpl\n    def extra_body_script():\n        return {\n            \"module\": True,\n            \"script\": \"console.log('Your JavaScript goes here...')\",\n        }\n\nThis will add the following to the end of your page:\n\n.. code-block:: html\n\n    <script type=\"module\">console.log('Your JavaScript goes here...')</script>\n\nExample: `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster-map>`_\n\n.. _plugin_hook_publish_subcommand:\n\npublish_subcommand(publish)\n---------------------------\n\n``publish`` - Click publish command group\n    The Click command group for the ``datasette publish`` subcommand\n\nThis hook allows you to create new providers for the ``datasette publish``\ncommand. Datasette uses this hook internally to implement the default ``cloudrun``\nand ``heroku`` subcommands, so you can read\n`their source <https://github.com/simonw/datasette/tree/main/datasette/publish>`_\nto see examples of this hook in action.\n\nLet's say you want to build a plugin that adds a ``datasette publish my_hosting_provider --api_key=xxx mydatabase.db`` publish command. Your implementation would start like this:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.publish.common import (\n        add_common_publish_arguments_and_options,\n    )\n    import click\n\n\n    @hookimpl\n    def publish_subcommand(publish):\n        @publish.command()\n        @add_common_publish_arguments_and_options\n        @click.option(\n            \"-k\",\n            \"--api_key\",\n            help=\"API key for talking to my hosting provider\",\n        )\n        def my_hosting_provider(\n            files,\n            metadata,\n            extra_options,\n            branch,\n            template_dir,\n            plugins_dir,\n            static,\n            install,\n            plugin_secret,\n            version_note,\n            secret,\n            title,\n            license,\n            license_url,\n            source,\n            source_url,\n            about,\n            about_url,\n            api_key,\n        ): ...\n\nExamples: `datasette-publish-fly <https://datasette.io/plugins/datasette-publish-fly>`_, `datasette-publish-vercel <https://datasette.io/plugins/datasette-publish-vercel>`_\n\n.. _plugin_hook_render_cell:\n\nrender_cell(row, value, column, table, pks, database, datasette, request, column_type)\n--------------------------------------------------------------------------------------\n\nLets you customize the display of values within table cells in the HTML table view.\n\n``row`` - ``sqlite.Row``\n    The SQLite row object that the value being rendered is part of\n\n``value`` - string, integer, float, bytes or None\n    The value that was loaded from the database\n\n``column`` - string\n    The name of the column being rendered\n\n``table`` - string or None\n    The name of the table - or ``None`` if this is a custom SQL query\n\n``pks`` - list of strings\n    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 ``[]``.\n\n``database`` - string\n    The name of the database\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``request`` - :ref:`internals_request`\n    The current request object\n\n``column_type`` - :ref:`ColumnType <datasette_column_types>` subclass instance or None\n    The :ref:`ColumnType <datasette_column_types>` subclass instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.\n\nIf a column has a :ref:`column type <datasette_column_types>` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.\n\nIf your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.\n\nIf the hook returns a string, that string will be rendered in the table cell.\n\nIf you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object.\n\nYou can also return an awaitable function which returns a value.\n\nDatasette will loop through all available ``render_cell`` hooks and display the value returned by the first one that does not return ``None``.\n\nHere is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format::\n\n    {\"href\": \"https://www.example.com/\", \"label\": \"Name\"}\n\nIf the value matches that pattern, the plugin returns an HTML link element:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import markupsafe\n    import json\n\n\n    @hookimpl\n    def render_cell(value):\n        # Render {\"href\": \"...\", \"label\": \"...\"} as link\n        if not isinstance(value, str):\n            return None\n        stripped = value.strip()\n        if not (\n            stripped.startswith(\"{\") and stripped.endswith(\"}\")\n        ):\n            return None\n        try:\n            data = json.loads(value)\n        except ValueError:\n            return None\n        if not isinstance(data, dict):\n            return None\n        if set(data.keys()) != {\"href\", \"label\"}:\n            return None\n        href = data[\"href\"]\n        if not (\n            href.startswith(\"/\")\n            or href.startswith(\"http://\")\n            or href.startswith(\"https://\")\n        ):\n            return None\n        return markupsafe.Markup(\n            '<a href=\"{href}\">{label}</a>'.format(\n                href=markupsafe.escape(data[\"href\"]),\n                label=markupsafe.escape(data[\"label\"] or \"\")\n                or \"&nbsp;\",\n            )\n        )\n\nExamples: `datasette-render-binary <https://datasette.io/plugins/datasette-render-binary>`_, `datasette-render-markdown <https://datasette.io/plugins/datasette-render-markdown>`__, `datasette-json-html <https://datasette.io/plugins/datasette-json-html>`__\n\n.. _plugin_register_output_renderer:\n\nregister_output_renderer(datasette)\n-----------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``\n\nRegisters a new output renderer, to output data in a custom format. The hook function should return a dictionary, or a list of dictionaries, of the following shape:\n\n.. code-block:: python\n\n    @hookimpl\n    def register_output_renderer(datasette):\n        return {\n            \"extension\": \"test\",\n            \"render\": render_demo,\n            \"can_render\": can_render_demo,  # Optional\n        }\n\nThis will register ``render_demo`` to be called when paths with the extension ``.test`` (for example ``/database.test``, ``/database/table.test``, or ``/database/table/row.test``) are requested.\n\n``render_demo`` is a Python function. It can be a regular function or an ``async def render_demo()`` awaitable function, depending on if it needs to make any asynchronous calls.\n\n``can_render_demo`` is a Python function (or ``async def`` function) which accepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influence if a link to this output format is displayed in the user interface. If you omit the ``\"can_render\"`` key from the dictionary every query will be treated as being supported by the plugin.\n\nWhen a request is received, the ``\"render\"`` callback function is called with zero or more of the following arguments. Datasette will inspect your callback function and pass arguments that match its function signature.\n\n``datasette`` - :ref:`internals_datasette`\n    For accessing plugin configuration and executing queries.\n\n``columns`` - list of strings\n    The names of the columns returned by this query.\n\n``rows`` - list of ``sqlite3.Row`` objects\n    The rows returned by the query.\n\n``sql`` - string\n    The SQL query that was executed.\n\n``query_name`` - string or None\n    If this was the execution of a :ref:`canned query <canned_queries>`, the name of that query.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string or None\n    The table or view, if one is being rendered.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``error`` - string or None\n    If an error occurred this string will contain the error message.\n\n``truncated`` - bool or None\n    If the query response was truncated - for example a SQL query returning more than 1,000 results where pagination is not available - this will be ``True``.\n\n``view_name`` - string\n    The name of the current view being called. ``index``, ``database``, ``table``, and ``row`` are the most important ones.\n\nThe callback function can return ``None``, if it is unable to render the data, or a :ref:`internals_response` that will be returned to the caller.\n\nIt can also return a dictionary with the following keys. This format is **deprecated** as-of Datasette 0.49 and will be removed by Datasette 1.0.\n\n``body`` - string or bytes, optional\n    The response body, default empty\n\n``content_type`` - string, optional\n    The Content-Type header, default ``text/plain``\n\n``status_code`` - integer, optional\n    The HTTP status code, default 200\n\n``headers`` - dictionary, optional\n    Extra HTTP headers to be returned in the response.\n\nAn example of an output renderer callback function:\n\n.. code-block:: python\n\n    def render_demo():\n        return Response.text(\"Hello World\")\n\nHere is a more complex example:\n\n.. code-block:: python\n\n    async def render_demo(datasette, columns, rows):\n        db = datasette.get_database()\n        result = await db.execute(\"select sqlite_version()\")\n        first_row = \" | \".join(columns)\n        lines = [first_row]\n        lines.append(\"=\" * len(first_row))\n        for row in rows:\n            lines.append(\" | \".join(row))\n        return Response(\n            \"\\n\".join(lines),\n            content_type=\"text/plain; charset=utf-8\",\n            headers={\"x-sqlite-version\": result.first()[0]},\n        )\n\nAnd here is an example ``can_render`` function which returns ``True`` only if the query results contain the columns ``atom_id``, ``atom_title`` and ``atom_updated``:\n\n.. code-block:: python\n\n    def can_render_demo(columns):\n        return {\n            \"atom_id\",\n            \"atom_title\",\n            \"atom_updated\",\n        }.issubset(columns)\n\nExamples: `datasette-atom <https://datasette.io/plugins/datasette-atom>`_, `datasette-ics <https://datasette.io/plugins/datasette-ics>`_, `datasette-geojson <https://datasette.io/plugins/datasette-geojson>`__, `datasette-copyable <https://datasette.io/plugins/datasette-copyable>`__\n\n.. _plugin_register_routes:\n\nregister_routes(datasette)\n--------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``\n\nRegister additional view functions to execute for specified URL routes.\n\nReturn a list of ``(regex, view_function)`` pairs, something like this:\n\n.. code-block:: python\n\n    from datasette import hookimpl, Response\n    import html\n\n\n    async def hello_from(request):\n        name = request.url_vars[\"name\"]\n        return Response.html(\n            \"Hello from {}\".format(html.escape(name))\n        )\n\n\n    @hookimpl\n    def register_routes():\n        return [(r\"^/hello-from/(?P<name>.*)$\", hello_from)]\n\nThe view functions can take a number of different optional arguments. The corresponding argument will be passed to your function depending on its named parameters - a form of dependency injection.\n\nThe optional view function arguments are as follows:\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``scope`` - dictionary\n    The incoming ASGI scope dictionary.\n\n``send`` - function\n    The ASGI send function.\n\n``receive`` - function\n    The ASGI receive function.\n\nThe view function can be a regular function or an ``async def`` function, depending on if it needs to use any ``await`` APIs.\n\nThe function can either return a :ref:`internals_response` or it can return nothing and instead respond directly to the request using the ASGI ``send`` function (for advanced uses only).\n\nIt can also raise the ``datasette.NotFound`` exception to return a 404 not found error, or the ``datasette.Forbidden`` exception for a 403 forbidden.\n\nSee :ref:`writing_plugins_designing_urls` for tips on designing the URL routes used by your plugin.\n\nExamples: `datasette-auth-github <https://datasette.io/plugins/datasette-auth-github>`__, `datasette-psutil <https://datasette.io/plugins/datasette-psutil>`__\n\n.. _plugin_hook_register_commands:\n\nregister_commands(cli)\n----------------------\n\n``cli`` - the root Datasette `Click command group <https://click.palletsprojects.com/en/latest/commands/#callback-invocation>`__\n    Use this to register additional CLI commands\n\nRegister additional CLI commands that can be run using ``datsette yourcommand ...``. This provides a mechanism by which plugins can add new CLI commands to Datasette.\n\nThis example registers a new ``datasette verify file1.db file2.db`` command that checks if the provided file paths are valid SQLite databases:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import click\n    import sqlite3\n\n\n    @hookimpl\n    def register_commands(cli):\n        @cli.command()\n        @click.argument(\n            \"files\", type=click.Path(exists=True), nargs=-1\n        )\n        def verify(files):\n            \"Verify that files can be opened by Datasette\"\n            for file in files:\n                conn = sqlite3.connect(str(file))\n                try:\n                    conn.execute(\"select * from sqlite_master\")\n                except sqlite3.DatabaseError:\n                    raise click.ClickException(\n                        \"Invalid database: {}\".format(file)\n                    )\n\nThe new command can then be executed like so::\n\n    datasette verify fixtures.db\n\nHelp text (from the docstring for the function plus any defined Click arguments or options) will become available using::\n\n    datasette verify --help\n\nPlugins can register multiple commands by making multiple calls to the ``@cli.command()`` decorator. Consult the `Click documentation <https://click.palletsprojects.com/>`__ for full details on how to build a CLI command, including how to define arguments and options.\n\nNote that ``register_commands()`` plugins cannot used with the :ref:`--plugins-dir mechanism <writing_plugins_one_off>` - they need to be installed into the same virtual environment as Datasette using ``pip install``. Provided it has a ``pyproject.toml`` file (see :ref:`writing_plugins_packaging`) you can run ``pip install`` directly against the directory in which you are developing your plugin like so::\n\n    pip install -e path/to/my/datasette-plugin\n\nExamples: `datasette-auth-passwords <https://datasette.io/plugins/datasette-auth-passwords>`__, `datasette-verify <https://datasette.io/plugins/datasette-verify>`__\n\n.. _plugin_register_facet_classes:\n\nregister_facet_classes()\n------------------------\n\nReturn a list of additional Facet subclasses to be registered.\n\n.. warning::\n    The design of this plugin hook is unstable and may change. See `issue 830 <https://github.com/simonw/datasette/issues/830>`__.\n\nEach Facet subclass implements a new type of facet operation. The class should look like this:\n\n.. code-block:: python\n\n    class SpecialFacet(Facet):\n        # This key must be unique across all facet classes:\n        type = \"special\"\n\n        async def suggest(self):\n            # Use self.sql and self.params to suggest some facets\n            suggested_facets = []\n            suggested_facets.append(\n                {\n                    \"name\": column,  # Or other unique name\n                    # Construct the URL that will enable this facet:\n                    \"toggle_url\": self.ds.absolute_url(\n                        self.request,\n                        path_with_added_args(\n                            self.request, {\"_facet\": column}\n                        ),\n                    ),\n                }\n            )\n            return suggested_facets\n\n        async def facet_results(self):\n            # This should execute the facet operation and return results, again\n            # using self.sql and self.params as the starting point\n            facet_results = []\n            facets_timed_out = []\n            facet_size = self.get_facet_size()\n            # Do some calculations here...\n            for column in columns_selected_for_facet:\n                try:\n                    facet_results_values = []\n                    # More calculations...\n                    facet_results_values.append(\n                        {\n                            \"value\": value,\n                            \"label\": label,\n                            \"count\": count,\n                            \"toggle_url\": self.ds.absolute_url(\n                                self.request, toggle_path\n                            ),\n                            \"selected\": selected,\n                        }\n                    )\n                    facet_results.append(\n                        {\n                            \"name\": column,\n                            \"results\": facet_results_values,\n                            \"truncated\": len(facet_rows_results)\n                            > facet_size,\n                        }\n                    )\n                except QueryInterrupted:\n                    facets_timed_out.append(column)\n\n            return facet_results, facets_timed_out\n\nSee `datasette/facets.py <https://github.com/simonw/datasette/blob/main/datasette/facets.py>`__ for examples of how these classes can work.\n\nThe plugin hook can then be used to register the new facet class like this:\n\n.. code-block:: python\n\n    @hookimpl\n    def register_facet_classes():\n        return [SpecialFacet]\n\n.. _plugin_register_actions:\n\nregister_actions(datasette)\n---------------------------\n\nIf your plugin needs to register actions that can be checked with Datasette's new resource-based permission system, return a list of those actions from this hook.\n\nActions define what operations can be performed on resources (like viewing a table, executing SQL, or custom plugin actions).\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import Action, Resource\n\n\n    class DocumentCollectionResource(Resource):\n        \"\"\"A collection of documents.\"\"\"\n\n        name = \"document-collection\"\n        parent_class = None\n\n        def __init__(self, collection: str):\n            super().__init__(parent=collection, child=None)\n\n        @classmethod\n        async def resources_sql(\n            cls, datasette, actor=None\n        ) -> str:\n            return \"\"\"\n                SELECT collection_name AS parent, NULL AS child\n                FROM document_collections\n            \"\"\"\n\n\n    class DocumentResource(Resource):\n        \"\"\"A document in a collection.\"\"\"\n\n        name = \"document\"\n        parent_class = DocumentCollectionResource\n\n        def __init__(self, collection: str, document: str):\n            super().__init__(parent=collection, child=document)\n\n        @classmethod\n        async def resources_sql(\n            cls, datasette, actor=None\n        ) -> str:\n            return \"\"\"\n                SELECT collection_name AS parent, document_id AS child\n                FROM documents\n            \"\"\"\n\n\n    @hookimpl\n    def register_actions(datasette):\n        return [\n            Action(\n                name=\"list-documents\",\n                abbr=\"ld\",\n                description=\"List documents in a collection\",\n                resource_class=DocumentCollectionResource,\n            ),\n            Action(\n                name=\"view-document\",\n                abbr=\"vdoc\",\n                description=\"View document\",\n                resource_class=DocumentResource,\n            ),\n            Action(\n                name=\"edit-document\",\n                abbr=\"edoc\",\n                description=\"Edit document\",\n                resource_class=DocumentResource,\n            ),\n        ]\n\nThe fields of the ``Action`` dataclass are as follows:\n\n``name`` - string\n    The name of the action, e.g. ``view-document``. This should be unique across all plugins.\n\n``abbr`` - string or None\n    An abbreviation of the action, e.g. ``vdoc``. This is optional. Since this needs to be unique across all installed plugins it's best to choose carefully or omit it entirely (same as setting it to ``None``.)\n\n``description`` - string or None\n    A human-readable description of what the action allows you to do.\n\n``resource_class`` - type[Resource] or None\n    The Resource subclass that defines what kind of resource this action applies to. Omit this (or set to ``None``) for global actions that apply only at the instance level with no associated resources (like ``debug-menu`` or ``permissions-debug``). Your Resource subclass must:\n\n    - Define a ``name`` class attribute (e.g., ``\"document\"``)\n    - Define a ``parent_class`` class attribute (``None`` for top-level resources like databases, or the parent ``Resource`` subclass for child resources)\n    - Implement an async ``resources_sql(cls, datasette, actor=None)`` classmethod that returns SQL returning all resources as ``(parent, child)`` columns\n    - Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)``\n\n.. _plugin_resources_sql:\n\nThe ``resources_sql(datasette, actor)`` method\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe ``resources_sql()`` classmethod returns a SQL query that lists all resources of that type that exist in the system. It can be async because Datasette calls it with ``await``, and it receives the current ``datasette`` instance plus an optional ``actor`` argument.\n\nThis query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to:\n\n1. Get all resources of this type from your data catalog\n2. Combine it with permission rules from the ``permission_resources_sql`` hook\n3. Use SQL joins and filtering to determine which resources the actor can access\n4. Return only the permitted resources\n\nThe SQL query **must** return exactly two columns:\n\n- ``parent`` - The parent identifier (e.g., database name, collection name), or ``NULL`` for top-level resources\n- ``child`` - The child identifier (e.g., table name, document ID), or ``NULL`` for parent-only resources\n\nFor example, if you're building a document management plugin with collections and documents stored in a ``documents`` table, your ``resources_sql()`` might look like:\n\n.. code-block:: python\n\n    @classmethod\n    async def resources_sql(cls, datasette, actor=None) -> str:\n        return \"\"\"\n            SELECT collection_name AS parent, document_id AS child\n            FROM documents\n        \"\"\"\n\nThis tells Datasette \"here's how to find all documents in the system - look in the documents table and get the collection name and document ID for each one.\"\n\nThe permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python.\n\n.. _plugin_register_column_types:\n\nregister_column_types(datasette)\n--------------------------------\n\nReturn a list of :ref:`ColumnType <datasette_column_types>` **subclasses** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.column_types import ColumnType, SQLiteType\n    import markupsafe\n\n\n    class ColorColumnType(ColumnType):\n        name = \"color\"\n        description = \"CSS color value\"\n        sqlite_types = (SQLiteType.TEXT,)\n\n        async def render_cell(\n            self,\n            value,\n            column,\n            table,\n            database,\n            datasette,\n            request,\n        ):\n            if value:\n                return markupsafe.Markup(\n                    '<span style=\"background-color: {color}\">'\n                    \"{color}</span>\"\n                ).format(color=markupsafe.escape(value))\n            return None\n\n        async def validate(self, value, datasette):\n            if value and not value.startswith(\"#\"):\n                return \"Color must start with #\"\n            return None\n\n        async def transform_value(self, value, datasette):\n            # Normalize to uppercase\n            if isinstance(value, str):\n                return value.upper()\n            return value\n\n\n    @hookimpl\n    def register_column_types(datasette):\n        return [ColorColumnType]\n\nEach ``ColumnType`` subclass must define the following class attributes:\n\n``name`` - string\n    Unique identifier for the column type, e.g. ``\"color\"``. Must be unique across all plugins.\n\n``description`` - string\n    Human-readable label, e.g. ``\"CSS color value\"``.\n\n``sqlite_types`` - tuple of ``SQLiteType`` values, optional\n    Restrict assignments of this column type to columns with matching SQLite types, e.g. ``(SQLiteType.TEXT,)``. If omitted, the column type can be assigned to any column.\n\nAnd the following methods, all optional:\n\n``render_cell(self, value, column, table, database, datasette, request)``\n    Return an HTML string to render this cell value, or ``None`` to fall through to the default ``render_cell`` plugin hook chain. When a column type provides rendering, it takes priority over the ``render_cell`` plugin hook.\n\n``validate(self, value, datasette)``\n    Validate a value before it is written via the insert, update, or upsert API endpoints. Return ``None`` if valid, or a string error message if invalid. Null values and empty strings skip validation.\n\n``transform_value(self, value, datasette)``\n    Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.\n\nPer-column configuration is available via ``self.config`` in all methods. When a column type is looked up for a specific column (via :ref:`get_column_type <datasette_get_column_type>` or :ref:`get_column_types <datasette_get_column_types>`), the returned instance has ``config`` set to the parsed JSON config dict for that column assignment, or ``None`` if no config was provided.\n\nColumn types are assigned to columns via the :ref:`column_types <table_configuration_column_types>` table configuration option:\n\n.. code-block:: yaml\n\n    databases:\n      mydb:\n        tables:\n          mytable:\n            column_types:\n              bg_color: color\n              highlight:\n                type: color\n                config:\n                  format: rgb\n\nDatasette includes three built-in column types: ``url``, ``email``, and ``json``.\n\n.. _plugin_asgi_wrapper:\n\nasgi_wrapper(datasette)\n-----------------------\n\nReturn an `ASGI <https://asgi.readthedocs.io/>`__ middleware wrapper function that will be applied to the Datasette ASGI application.\n\nThis 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.\n\nYou 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/>`__.\n\nThis example plugin adds a ``x-databases`` HTTP header listing the currently attached databases:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from functools import wraps\n\n\n    @hookimpl\n    def asgi_wrapper(datasette):\n        def wrap_with_databases_header(app):\n            @wraps(app)\n            async def add_x_databases_header(\n                scope, receive, send\n            ):\n                async def wrapped_send(event):\n                    if event[\"type\"] == \"http.response.start\":\n                        original_headers = (\n                            event.get(\"headers\") or []\n                        )\n                        event = {\n                            \"type\": event[\"type\"],\n                            \"status\": event[\"status\"],\n                            \"headers\": original_headers\n                            + [\n                                [\n                                    b\"x-databases\",\n                                    \", \".join(\n                                        datasette.databases.keys()\n                                    ).encode(\"utf-8\"),\n                                ]\n                            ],\n                        }\n                    await send(event)\n\n                await app(scope, receive, wrapped_send)\n\n            return add_x_databases_header\n\n        return wrap_with_databases_header\n\nExamples: `datasette-cors <https://datasette.io/plugins/datasette-cors>`__, `datasette-pyinstrument <https://datasette.io/plugins/datasette-pyinstrument>`__, `datasette-total-page-time <https://datasette.io/plugins/datasette-total-page-time>`__\n\n.. _plugin_hook_startup:\n\nstartup(datasette)\n------------------\n\nThis hook fires when the Datasette application server first starts up.\n\nHere is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails:\n\n.. code-block:: python\n\n    from datasette.utils import StartupError\n\n\n    @hookimpl\n    def startup(datasette):\n        config = datasette.plugin_config(\"my-plugin\") or {}\n        if \"required-setting\" not in config:\n            raise StartupError(\n                \"my-plugin requires setting required-setting\"\n            )\n\nYou can also return an async function, which will be awaited on startup. Use this option if you need to execute any database queries, for example this function which creates the ``my_table`` database table if it does not yet exist:\n\n.. code-block:: python\n\n    @hookimpl\n    def startup(datasette):\n        async def inner():\n            db = datasette.get_database()\n            if \"my_table\" not in await db.table_names():\n                await db.execute_write(\"\"\"\n                    create table my_table (mycol text)\n                \"\"\")\n\n        return inner\n\nPotential use-cases:\n\n* Run some initialization code for the plugin\n* Create database tables that a plugin needs on startup\n* Validate the configuration for a plugin on startup, and raise an error if it is invalid\n* Raise a ``datasette.utils.StartupError(\"message\")`` exception to prevent Datasette from starting and display that message to the user.\n\n.. note::\n\n   If you are writing :ref:`unit tests <testing_plugins>` for a plugin that uses this hook and doesn't exercise Datasette by sending\n   any simulated requests through it you will need to explicitly call ``await ds.invoke_startup()`` in your tests. An example:\n\n   .. code-block:: python\n\n        @pytest.mark.asyncio\n        async def test_my_plugin():\n            ds = Datasette()\n            await ds.invoke_startup()\n            # Rest of test goes here\n\nExamples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__\n\n.. _plugin_hook_canned_queries:\n\ncanned_queries(datasette, database, actor)\n------------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``database`` - string\n    The name of the database.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\nUse this hook to return a dictionary of additional :ref:`canned query <canned_queries>` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query <canned_queries>` documentation.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def canned_queries(datasette, database):\n        if database == \"mydb\":\n            return {\n                \"my_query\": {\n                    \"sql\": \"select * from my_table where id > :min_id\"\n                }\n            }\n\nThe hook can alternatively return an awaitable function that returns a list. Here's an example that returns queries that have been stored in the ``saved_queries`` database table, if one exists:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def canned_queries(datasette, database):\n        async def inner():\n            db = datasette.get_database(database)\n            if await db.table_exists(\"saved_queries\"):\n                results = await db.execute(\n                    \"select name, sql from saved_queries\"\n                )\n                return {\n                    result[\"name\"]: {\"sql\": result[\"sql\"]}\n                    for result in results\n                }\n\n        return inner\n\nThe actor parameter can be used to include the currently authenticated actor in your decision. Here's an example that returns saved queries that were saved by that actor:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def canned_queries(datasette, database, actor):\n        async def inner():\n            db = datasette.get_database(database)\n            if actor is not None and await db.table_exists(\n                \"saved_queries\"\n            ):\n                results = await db.execute(\n                    \"select name, sql from saved_queries where actor_id = :id\",\n                    {\"id\": actor[\"id\"]},\n                )\n                return {\n                    result[\"name\"]: {\"sql\": result[\"sql\"]}\n                    for result in results\n                }\n\n        return inner\n\nExample: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__\n\n.. _plugin_hook_actor_from_request:\n\nactor_from_request(datasette, request)\n--------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\nThis is part of Datasette's :ref:`authentication and permissions system <authentication>`. The function should attempt to authenticate an actor (either a user or an API actor of some sort) based on information in the request.\n\nIf it cannot authenticate an actor, it should return ``None``, otherwise it should return a dictionary representing that actor. Once a plugin has returned an actor from this hook other plugins will be ignored.\n\nHere's an example that authenticates the actor based on an incoming API key:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import secrets\n\n    SECRET_KEY = \"this-is-a-secret\"\n\n\n    @hookimpl\n    def actor_from_request(datasette, request):\n        authorization = (\n            request.headers.get(\"authorization\") or \"\"\n        )\n        expected = \"Bearer {}\".format(SECRET_KEY)\n\n        if secrets.compare_digest(authorization, expected):\n            return {\"id\": \"bot\"}\n\nIf you install this in your plugins directory you can test it like this::\n\n    curl -H 'Authorization: Bearer this-is-a-secret' http://localhost:8003/-/actor.json\n\nInstead of returning a dictionary, this function can return an awaitable function which itself returns either ``None`` or a dictionary. This is useful for authentication functions that need to make a database query - for example:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def actor_from_request(datasette, request):\n        async def inner():\n            token = request.args.get(\"_token\")\n            if not token:\n                return None\n            # Look up ?_token=xxx in sessions table\n            result = await datasette.get_database().execute(\n                \"select count(*) from sessions where token = ?\",\n                [token],\n            )\n            if result.first()[0]:\n                return {\"token\": token}\n            else:\n                return None\n\n        return inner\n\nExamples: `datasette-auth-tokens <https://datasette.io/plugins/datasette-auth-tokens>`_, `datasette-auth-passwords <https://datasette.io/plugins/datasette-auth-passwords>`_\n\n.. _plugin_hook_actors_from_ids:\n\nactors_from_ids(datasette, actor_ids)\n-------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor_ids`` - list of strings or integers\n    The actor IDs to look up.\n\nThe hook must return a dictionary that maps the incoming actor IDs to their full dictionary representation.\n\nSome plugins that implement social features may store the ID of the :ref:`actor <authentication_actor>` that performed an action - added a comment, bookmarked a table or similar - and then need a way to resolve those IDs into display-friendly actor dictionaries later on.\n\nThe :ref:`await datasette.actors_from_ids(actor_ids) <datasette_actors_from_ids>` internal method can be used to look up actors from their IDs. It will dispatch to the first plugin that implements this hook.\n\nUnlike other plugin hooks, this only uses the first implementation of the hook to return a result. You can expect users to only have a single plugin installed that implements this hook.\n\nIf no plugin is installed, Datasette defaults to returning actors that are just ``{\"id\": actor_id}``.\n\nThe hook can return a dictionary or an awaitable function that then returns a dictionary.\n\nThis example implementation returns actors from a database table:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def actors_from_ids(datasette, actor_ids):\n        db = datasette.get_database(\"actors\")\n\n        async def inner():\n            sql = \"select id, name from actors where id in ({})\".format(\n                \", \".join(\"?\" for _ in actor_ids)\n            )\n            actors = {}\n            for row in (await db.execute(sql, actor_ids)).rows:\n                actor = dict(row)\n                actors[actor[\"id\"]] = actor\n            return actors\n\n        return inner\n\nThe returned dictionary from this example looks like this:\n\n.. code-block:: json\n\n    {\n        \"1\": {\"id\": \"1\", \"name\": \"Tony\"},\n        \"2\": {\"id\": \"2\", \"name\": \"Tina\"},\n    }\n\nThese IDs could be integers or strings, depending on how the actors used by the Datasette instance are configured.\n\nExample: `datasette-remote-actors <https://github.com/datasette/datasette-remote-actors>`_\n\n.. _plugin_hook_jinja2_environment_from_request:\n\njinja2_environment_from_request(datasette, request, env)\n--------------------------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    A Datasette instance.\n\n``request`` - :ref:`internals_request` or ``None``\n    The current HTTP request, if one is available.\n\n``env`` - ``Environment``\n    The Jinja2 environment that will be used to render the current page.\n\nThis hook can be used to return a customized `Jinja environment <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment>`__ based on the incoming request.\n\nIf you want to run a single Datasette instance that serves different content for different domains, you can do so like this:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from jinja2 import ChoiceLoader, FileSystemLoader\n\n\n    @hookimpl\n    def jinja2_environment_from_request(request, env):\n        if request and request.host == \"www.niche-museums.com\":\n            return env.overlay(\n                loader=ChoiceLoader(\n                    [\n                        FileSystemLoader(\n                            \"/mnt/niche-museums/templates\"\n                        ),\n                        env.loader,\n                    ]\n                ),\n                enable_async=True,\n            )\n        return env\n\nThis uses the Jinja `overlay() method <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment.overlay>`__ to create a new environment identical to the default environment except for having a different template loader, which first looks in the ``/mnt/niche-museums/templates`` directory before falling back on the default loader.\n\n.. _plugin_hook_filters_from_request:\n\nfilters_from_request(request, database, table, datasette)\n---------------------------------------------------------\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string\n    The name of the table.\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\nThis hook runs on the :ref:`table <TableView>` page, and can influence the ``where`` clause of the SQL query used to populate that page, based on query string arguments on the incoming request.\n\nThe hook should return an instance of ``datasette.filters.FilterArguments`` which has one required and three optional arguments:\n\n.. code-block:: python\n\n    return FilterArguments(\n        where_clauses=[\"id > :max_id\"],\n        params={\"max_id\": 5},\n        human_descriptions=[\"max_id is greater than 5\"],\n        extra_context={},\n    )\n\nThe arguments to the ``FilterArguments`` class constructor are as follows:\n\n``where_clauses`` - list of strings, required\n    A list of SQL fragments that will be inserted into the SQL query, joined by the ``and`` operator. These can include ``:named`` parameters which will be populated using data in ``params``.\n``params`` - dictionary, optional\n    Additional keyword arguments to be used when the query is executed. These should match any ``:arguments`` in the where clauses.\n``human_descriptions`` - list of strings, optional\n    These strings will be included in the human-readable description at the top of the page and the page ``<title>``.\n``extra_context`` - dictionary, optional\n    Additional context variables that should be made available to the ``table.html`` template when it is rendered.\n\nThis example plugin causes 0 results to be returned if ``?_nothing=1`` is added to the URL:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.filters import FilterArguments\n\n\n    @hookimpl\n    def filters_from_request(self, request):\n        if request.args.get(\"_nothing\"):\n            return FilterArguments(\n                [\"1 = 0\"], human_descriptions=[\"NOTHING\"]\n            )\n\nExample: `datasette-leaflet-freedraw <https://datasette.io/plugins/datasette-leaflet-freedraw>`_\n\n.. _plugin_hook_permission_resources_sql:\n\npermission_resources_sql(datasette, actor, action)\n--------------------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    Access to the Datasette instance.\n\n``actor`` - dictionary or None\n    The current actor dictionary. ``None`` for anonymous requests.\n\n``action`` - string\n    The permission action being evaluated. Examples include ``\"view-table\"`` or ``\"insert-row\"``.\n\nReturn value\n    A :class:`datasette.permissions.PermissionSQL` object, ``None`` or an iterable of ``PermissionSQL`` objects.\n\nDatasette's action-based permission resolver calls this hook to gather SQL rows describing which\nresources an actor may access (``allow = 1``) or should be denied (``allow = 0``) for a specific action.\nEach SQL snippet should return ``parent``, ``child``, ``allow`` and ``reason`` columns.\n\n**Parameter naming convention:** Plugin parameters in ``PermissionSQL.params`` should use unique names\nto avoid conflicts with other plugins. The recommended convention is to prefix parameters with your\nplugin's source name (e.g., ``myplugin_user_id``). The system reserves these parameter names:\n``:actor``, ``:actor_id``, ``:action``, and ``:filter_parent``.\n\nYou can also use return ``PermissionSQL.allow(reason=\"reason goes here\")`` or ``PermissionSQL.deny(reason=\"reason goes here\")`` as shortcuts for simple root-level allow or deny rules. These will create SQL snippets that look like this:\n\n.. code-block:: sql\n\n    SELECT\n        NULL AS parent,\n        NULL AS child,\n        1 AS allow,\n        'reason goes here' AS reason\n\nOr ``0 AS allow`` for denies.\n\nPermission plugin examples\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThese snippets show how to use the new ``permission_resources_sql`` hook to\ncontribute rows to the action-based permission resolver. Each hook receives the\ncurrent actor dictionary (or ``None``) and must return ``None`` or an instance or list of\n``datasette.permissions.PermissionSQL`` (or a coroutine that resolves to that).\n\nAllow Alice to view a specific table\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nThis plugin grants the actor with ``id == \"alice\"`` permission to perform the\n``view-table`` action against the ``sales`` table inside the ``accounting`` database.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if action != \"view-table\":\n            return None\n        if not actor or actor.get(\"id\") != \"alice\":\n            return None\n\n        return PermissionSQL(\n            sql=\"\"\"\n                SELECT\n                    'accounting' AS parent,\n                    'sales' AS child,\n                    1 AS allow,\n                    'alice can view accounting/sales' AS reason\n            \"\"\",\n        )\n\nRestrict execute-sql to a database prefix\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nOnly allow ``execute-sql`` against databases whose name begins with\n``analytics_``. This shows how to use parameters that the permission resolver\nwill pass through to the SQL snippet.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if action != \"execute-sql\":\n            return None\n\n        return PermissionSQL(\n            sql=\"\"\"\n                SELECT\n                    parent,\n                    NULL AS child,\n                    1 AS allow,\n                    'execute-sql allowed for analytics_*' AS reason\n                FROM catalog_databases\n                WHERE database_name LIKE :analytics_prefix\n            \"\"\",\n            params={\n                \"analytics_prefix\": \"analytics_%\",\n            },\n        )\n\nRead permissions from a custom table\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nThis example stores grants in an internal table called ``permission_grants``\nwith columns ``(actor_id, action, parent, child, allow, reason)``.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if not actor:\n            return None\n\n        return PermissionSQL(\n            sql=\"\"\"\n                SELECT\n                    parent,\n                    child,\n                    allow,\n                    COALESCE(reason, 'permission_grants table') AS reason\n                FROM permission_grants\n                WHERE actor_id = :grants_actor_id\n                  AND action = :grants_action\n            \"\"\",\n            params={\n                \"grants_actor_id\": actor.get(\"id\"),\n                \"grants_action\": action,\n            },\n        )\n\nDefault deny with an exception\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nCombine a root-level deny with a specific table allow for trusted users.\nThe resolver will automatically apply the most specific rule.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n\n    TRUSTED = {\"alice\", \"bob\"}\n\n\n    @hookimpl\n    def permission_resources_sql(datasette, actor, action):\n        if action != \"view-table\":\n            return None\n\n        actor_id = (actor or {}).get(\"id\")\n\n        if actor_id not in TRUSTED:\n            return PermissionSQL(\n                sql=\"\"\"\n                    SELECT NULL AS parent, NULL AS child, 0 AS allow,\n                           'default deny view-table' AS reason\n                \"\"\",\n            )\n\n        return PermissionSQL(\n            sql=\"\"\"\n                SELECT NULL AS parent, NULL AS child, 0 AS allow,\n                       'default deny view-table' AS reason\n                UNION ALL\n                SELECT 'reports' AS parent, 'daily_metrics' AS child, 1 AS allow,\n                       'trusted user access' AS reason\n            \"\"\",\n            params={\"actor_id\": actor_id},\n        )\n\nThe ``UNION ALL`` ensures the deny rule is always present, while the second row\nadds the exception for trusted users.\n\n.. _plugin_hook_register_magic_parameters:\n\nregister_magic_parameters(datasette)\n------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`canned queries <canned_queries>`. This plugin hook allows additional magic parameters to be defined by plugins.\n\nMagic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function.\n\nTo register a new function, return it as a tuple of ``(string prefix, function)`` from this hook. The function you register should take two arguments: ``key`` and ``request``, where ``key`` is the ``rest_of_parameter`` portion of the parameter and ``request`` is the current :ref:`internals_request`.\n\nThis example registers two new magic parameters: ``:_request_http_version`` returning the HTTP version of the current request, and ``:_uuid_new`` which returns a new UUID. It also registers an ``:_asynclookup_key`` parameter, demonstrating that these functions can be asynchronous:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from uuid import uuid4\n\n\n    def uuid(key, request):\n        if key == \"new\":\n            return str(uuid4())\n        else:\n            raise KeyError\n\n\n    def request(key, request):\n        if key == \"http_version\":\n            return request.scope[\"http_version\"]\n        else:\n            raise KeyError\n\n\n    async def asynclookup(key, request):\n        return await do_something_async(key)\n\n\n    @hookimpl\n    def register_magic_parameters(datasette):\n        return [\n            (\"request\", request),\n            (\"uuid\", uuid),\n            (\"asynclookup\", asynclookup),\n        ]\n\n.. _plugin_hook_forbidden:\n\nforbidden(datasette, request, message)\n--------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to render templates or execute SQL queries.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``message`` - string\n    A message hinting at why the request was forbidden.\n\nPlugins can use this to customize how Datasette responds when a 403 Forbidden error occurs - usually because a page failed a permission check, see :ref:`authentication_permissions`.\n\nIf a plugin hook wishes to react to the error, it should return a :ref:`Response object <internals_response>`.\n\nThis example returns a redirect to a ``/-/login`` page:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from urllib.parse import urlencode\n\n\n    @hookimpl\n    def forbidden(request, message):\n        return Response.redirect(\n            \"/-/login?=\" + urlencode({\"message\": message})\n        )\n\nThe function can alternatively return an awaitable function if it needs to make any asynchronous method calls. This example renders a template:\n\n.. code-block:: python\n\n    from datasette import hookimpl, Response\n\n\n    @hookimpl\n    def forbidden(datasette):\n        async def inner():\n            return Response.html(\n                await datasette.render_template(\n                    \"render_message.html\", request=request\n                )\n            )\n\n        return inner\n\n.. _plugin_hook_handle_exception:\n\nhandle_exception(datasette, request, exception)\n-----------------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to render templates or execute SQL queries.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``exception`` - ``Exception``\n    The exception that was raised.\n\nThis hook is called any time an unexpected exception is raised. You can use it to record the exception.\n\nIf your handler returns a ``Response`` object it will be returned to the client in place of the default Datasette error page.\n\nThe handler can return a response directly, or it can return return an awaitable function that returns a response.\n\nThis example logs an error to `Sentry <https://sentry.io/>`__ and then renders a custom error page:\n\n.. code-block:: python\n\n    from datasette import hookimpl, Response\n    import sentry_sdk\n\n\n    @hookimpl\n    def handle_exception(datasette, exception):\n        sentry_sdk.capture_exception(exception)\n\n        async def inner():\n            return Response.html(\n                await datasette.render_template(\n                    \"custom_error.html\", request=request\n                )\n            )\n\n        return inner\n\nExample: `datasette-sentry <https://datasette.io/plugins/datasette-sentry>`_\n\n.. _plugin_hook_skip_csrf:\n\nskip_csrf(datasette, scope)\n---------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``scope`` - dictionary\n    The `ASGI scope <https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope>`__ for the incoming HTTP request.\n\nThis hook can be used to skip :ref:`internals_csrf` for a specific incoming request. For example, you might have a custom path at ``/submit-comment`` which is designed to accept comments from anywhere, whether or not the incoming request originated on the site and has an accompanying CSRF token.\n\nThis example will disable CSRF protection for that specific URL path:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def skip_csrf(scope):\n        return scope[\"path\"] == \"/submit-comment\"\n\nIf any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request.\n\n.. _plugin_hook_menu_links:\n\nmenu_links(datasette, actor, request)\n-------------------------------------\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``request`` - :ref:`internals_request` or None\n    The current HTTP request. This can be ``None`` if the request object is not available.\n\nThis hook allows additional items to be included in the menu displayed by Datasette's top right menu icon.\n\nThe hook should return a list of ``{\"href\": \"...\", \"label\": \"...\"}`` menu items. These will be added to the menu.\n\nIt can alternatively return an ``async def`` awaitable function which returns a list of menu items.\n\nThis example adds a new menu item but only if the signed in user is ``\"root\"``:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def menu_links(datasette, actor):\n        if actor and actor.get(\"id\") == \"root\":\n            return [\n                {\n                    \"href\": datasette.urls.path(\n                        \"/-/edit-schema\"\n                    ),\n                    \"label\": \"Edit schema\",\n                },\n            ]\n\nUsing :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`setting_base_url` setting into account.\n\nExamples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_\n\n.. _plugin_actions:\n\nAction hooks\n------------\n\nAction hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.\n\nEach of these hooks should return return a list of ``{\"href\": \"...\", \"label\": \"...\"}`` menu items, with optional ``\"description\": \"...\"`` keys describing each action in more detail.\n\nThey can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.\n\n.. _plugin_hook_table_actions:\n\ntable_actions(datasette, actor, database, table, request)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string\n    The name of the table.\n\n``request`` - :ref:`internals_request` or None\n    The current HTTP request. This can be ``None`` if the request object is not available.\n\nThis example adds a new table action if the signed in user is ``\"root\"``:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def table_actions(datasette, actor, database, table):\n        if actor and actor.get(\"id\") == \"root\":\n            return [\n                {\n                    \"href\": datasette.urls.path(\n                        \"/-/edit-schema/{}/{}\".format(\n                            database, table\n                        )\n                    ),\n                    \"label\": \"Edit schema for this table\",\n                    \"description\": \"Add, remove, rename or alter columns for this table.\",\n                }\n            ]\n\nExample: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_\n\n.. _plugin_hook_view_actions:\n\nview_actions(datasette, actor, database, view, request)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``database`` - string\n    The name of the database.\n\n``view`` - string\n    The name of the SQL view.\n\n``request`` - :ref:`internals_request` or None\n    The current HTTP request. This can be ``None`` if the request object is not available.\n\nLike :ref:`plugin_hook_table_actions` but for SQL views.\n\n.. _plugin_hook_query_actions:\n\nquery_actions(datasette, actor, database, query_name, request, sql, params)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``database`` - string\n    The name of the database.\n\n``query_name`` - string or None\n    The name of the canned query, or ``None`` if this is an arbitrary SQL query.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``sql`` - string\n    The SQL query being executed\n\n``params`` - dictionary\n    The parameters passed to the SQL query, if any.\n\nPopulates a \"Query actions\" menu on the canned query and arbitrary SQL query pages.\n\nThis example adds a new query action linking to a page for explaining a query:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import urllib\n\n\n    @hookimpl\n    def query_actions(datasette, database, query_name, sql):\n        # Don't explain an explain\n        if sql.lower().startswith(\"explain\"):\n            return\n        return [\n            {\n                \"href\": datasette.urls.database(database)\n                + \"?\"\n                + urllib.parse.urlencode(\n                    {\n                        \"sql\": \"explain \" + sql,\n                    }\n                ),\n                \"label\": \"Explain this query\",\n                \"description\": \"Get a summary of how SQLite executes the query\",\n            },\n        ]\n\nExample: `datasette-create-view <https://datasette.io/plugins/datasette-create-view>`_\n\n.. _plugin_hook_row_actions:\n\nrow_actions(datasette, actor, request, database, table, row)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``request`` - :ref:`internals_request` or None\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string\n    The name of the table.\n\n``row`` - ``sqlite.Row``\n    The SQLite row object being displayed on the page.\n\nReturn links for the \"Row actions\" menu shown at the top of the row page.\n\nThis example displays the row in JSON plus some additional debug information if the user is signed in:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def row_actions(datasette, database, table, actor, row):\n        if actor:\n            return [\n                {\n                    \"href\": datasette.urls.instance(),\n                    \"label\": f\"Row details for {actor['id']}\",\n                    \"description\": json.dumps(\n                        dict(row), default=repr\n                    ),\n                },\n            ]\n\nExample: `datasette-enrichments <https://datasette.io/plugins/datasette-enrichments>`_\n\n.. _plugin_hook_database_actions:\n\ndatabase_actions(datasette, actor, database, request)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``database`` - string\n    The name of the database.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\nPopulates an actions menu on the database page.\n\nThis example adds a new database action for creating a table, if the user has the ``edit-schema`` permission:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.resources import DatabaseResource\n\n\n    @hookimpl\n    def database_actions(datasette, actor, database):\n        async def inner():\n            if not await datasette.allowed(\n                actor,\n                \"edit-schema\",\n                resource=DatabaseResource(\"database\"),\n            ):\n                return []\n            return [\n                {\n                    \"href\": datasette.urls.path(\n                        \"/-/edit-schema/{}/-/create\".format(\n                            database\n                        )\n                    ),\n                    \"label\": \"Create a table\",\n                }\n            ]\n\n        return inner\n\nExample: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_, `datasette-edit-schema <https://datasette.io/plugins/datasette-edit-schema>`_\n\n.. _plugin_hook_homepage_actions:\n\nhomepage_actions(datasette, actor, request)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.\n\n``actor`` - dictionary or None\n    The currently authenticated :ref:`actor <authentication_actor>`.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\nPopulates an actions menu on the top-level index homepage of the Datasette instance.\n\nThis example adds a link an imagined tool for editing the homepage, only for signed in users:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def homepage_actions(datasette, actor):\n        if actor:\n            return [\n                {\n                    \"href\": datasette.urls.path(\n                        \"/-/customize-homepage\"\n                    ),\n                    \"label\": \"Customize homepage\",\n                }\n            ]\n\n.. _plugin_hook_slots:\n\nTemplate slots\n--------------\n\nThe following set of plugin hooks can be used to return extra HTML content that will be inserted into the corresponding page, directly below the ``<h1>`` heading.\n\nMultiple plugins can contribute content here. The order in which it is displayed can be controlled using Pluggy's `call time order options <https://pluggy.readthedocs.io/en/stable/#call-time-order>`__.\n\nEach of these plugin hooks can return either a string or an awaitable function that returns a string.\n\n.. _plugin_hook_top_homepage:\n\ntop_homepage(datasette, request)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\nReturns HTML to be displayed at the top of the Datasette homepage.\n\n.. _plugin_hook_top_database:\n\ntop_database(datasette, request, database)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\nReturns HTML to be displayed at the top of the database page.\n\n.. _plugin_hook_top_table:\n\ntop_table(datasette, request, database, table)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string\n    The name of the table.\n\nReturns HTML to be displayed at the top of the table page.\n\n.. _plugin_hook_top_row:\n\ntop_row(datasette, request, database, table, row)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``table`` - string\n    The name of the table.\n\n``row`` - ``sqlite.Row``\n    The SQLite row object being displayed.\n\nReturns HTML to be displayed at the top of the row page.\n\n.. _plugin_hook_top_query:\n\ntop_query(datasette, request, database, sql)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``sql`` - string\n    The SQL query.\n\nReturns HTML to be displayed at the top of the query results page.\n\n.. _plugin_hook_top_canned_query:\n\ntop_canned_query(datasette, request, database, query_name)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``request`` - :ref:`internals_request`\n    The current HTTP request.\n\n``database`` - string\n    The name of the database.\n\n``query_name`` - string\n    The name of the canned query.\n\nReturns HTML to be displayed at the top of the canned query page.\n\n.. _plugin_event_tracking:\n\nEvent tracking\n--------------\n\nDatasette includes an internal mechanism for tracking notable events. This can be used for analytics, but can also be used by plugins that want to listen out for when key events occur (such as a table being created) and take action in response.\n\nPlugins can register to receive events using the ``track_event`` plugin hook.\n\nThey can also define their own events for other plugins to receive using the :ref:`register_events() plugin hook <plugin_hook_register_events>`, combined with calls to the :ref:`datasette.track_event() internal method <datasette_track_event>`.\n\n.. _plugin_hook_track_event:\n\ntrack_event(datasette, event)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\n``event`` - ``Event``\n    Information about the event, represented as an instance of a subclass of the ``Event`` base class.\n\nThis hook will be called any time an event is tracked by code that calls the :ref:`datasette.track_event(...) <datasette_track_event>` internal method.\n\nThe ``event`` object will always have the following properties:\n\n- ``name``: a string representing the name of the event, for example ``logout`` or ``create-table``.\n- ``actor``: a dictionary representing the actor that triggered the event, or ``None`` if the event was not triggered by an actor.\n- ``created``: a ``datatime.datetime`` object in the ``timezone.utc`` timezone representing the time the event object was created.\n\nOther properties on the event will be available depending on the type of event. You can also access those as a dictionary using ``event.properties()``.\n\nThe events fired by Datasette core are :ref:`documented here <events>`.\n\nThis example plugin logs details of all events to standard error:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import json\n    import sys\n\n\n    @hookimpl\n    def track_event(event):\n        name = event.name\n        actor = event.actor\n        properties = event.properties()\n        msg = json.dumps(\n            {\n                \"name\": name,\n                \"actor\": actor,\n                \"properties\": properties,\n            }\n        )\n        print(msg, file=sys.stderr, flush=True)\n\nThe function can also return an async function which will be awaited. This is useful for writing to a database.\n\nThis example logs events to a ``datasette_events`` table in a database called ``events``. It uses the :ref:`plugin_hook_startup` hook to create that table if it does not exist.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import json\n\n\n    @hookimpl\n    def startup(datasette):\n        async def inner():\n            db = datasette.get_database(\"events\")\n            await db.execute_write(\"\"\"\n                create table if not exists datasette_events (\n                    id integer primary key,\n                    event_type text,\n                    created text,\n                    actor text,\n                    properties text\n                )\n            \"\"\")\n\n        return inner\n\n\n    @hookimpl\n    def track_event(datasette, event):\n        async def inner():\n            db = datasette.get_database(\"events\")\n            properties = event.properties()\n            await db.execute_write(\n                \"\"\"\n                insert into datasette_events (event_type, created, actor, properties)\n                values (?, strftime('%Y-%m-%d %H:%M:%S', 'now'), ?, ?)\n            \"\"\",\n                (\n                    event.name,\n                    json.dumps(event.actor),\n                    json.dumps(properties),\n                ),\n            )\n\n        return inner\n\nExample: `datasette-events-db <https://datasette.io/plugins/datasette-events-db>`_\n\n.. _plugin_hook_register_events:\n\nregister_events(datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\nThis hook should return a list of ``Event`` subclasses that represent custom events that the plugin might send to the :ref:`datasette.track_event() <datasette_track_event>` method.\n\nThis example registers event subclasses for ``ban-user`` and ``unban-user`` events:\n\n.. code-block:: python\n\n    from dataclasses import dataclass\n    from datasette import hookimpl, Event\n\n\n    @dataclass\n    class BanUserEvent(Event):\n        name = \"ban-user\"\n        user: dict\n\n\n    @dataclass\n    class UnbanUserEvent(Event):\n        name = \"unban-user\"\n        user: dict\n\n\n    @hookimpl\n    def register_events():\n        return [BanUserEvent, UnbanUserEvent]\n\nThe plugin can then call ``datasette.track_event(...)`` to send a ``ban-user`` event:\n\n.. code-block:: python\n\n    await datasette.track_event(\n        BanUserEvent(user={\"id\": 1, \"username\": \"cleverbot\"})\n    )\n\n.. _plugin_hook_register_token_handler:\n\nregister_token_handler(datasette)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n``datasette`` - :ref:`internals_datasette`\n    You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.\n\nReturn a ``TokenHandler`` instance to provide a custom token creation and verification backend. This hook can return a single ``TokenHandler`` or a list of them.\n\nThe default ``SignedTokenHandler`` uses itsdangerous signed tokens (``dstok_`` prefix). Plugins can provide alternative backends such as database-backed tokens that support revocation and auditing.\n\n.. code-block:: python\n\n    from datasette import hookimpl, TokenHandler\n\n\n    class DatabaseTokenHandler(TokenHandler):\n        name = \"database\"\n\n        async def create_token(\n            self,\n            datasette,\n            actor_id,\n            *,\n            expires_after=None,\n            restrictions=None\n        ):\n            # Store token in database and return token string\n            ...\n\n        async def verify_token(self, datasette, token):\n            # Look up token in database, return actor dict or None\n            ...\n\n\n    @hookimpl\n    def register_token_handler(datasette):\n        return DatabaseTokenHandler()\n\nThe ``create_token`` method receives a ``restrictions`` argument which will be a :ref:`TokenRestrictions <TokenRestrictions>` instance or ``None``.\n\nTokens can then be created and verified using :ref:`datasette.create_token() <datasette_create_token>` and ``datasette.verify_token()``, which delegate to the registered handlers. If no ``handler`` is specified, the first handler is used according to `pluggy call-time ordering <https://pluggy.readthedocs.io/en/stable/#call-time-order>`_. Use the ``handler`` parameter to select a specific backend by name:\n\n.. code-block:: python\n\n    # Uses first registered handler (default)\n    token = await datasette.create_token(\"user123\")\n\n    # Uses a specific handler by name\n    token = await datasette.create_token(\n        \"user123\", handler=\"database\"\n    )\n\n    # Verification tries all handlers\n    actor = await datasette.verify_token(token)\n\nIf no handlers are registered, ``create_token()`` raises ``RuntimeError``. If the requested ``handler`` name is not found, it raises ``ValueError``.\n"
  },
  {
    "path": "docs/plugins.rst",
    "content": ".. _plugins:\n\nPlugins\n=======\n\nDatasette's plugin system allows additional features to be implemented as Python\ncode (or front-end JavaScript) which can be wrapped up in a separate Python\npackage. The underlying mechanism uses `pluggy <https://pluggy.readthedocs.io/>`_.\n\nSee the `Datasette plugins directory <https://datasette.io/plugins>`__ for a list of existing plugins, or take a look at the\n`datasette-plugin <https://github.com/topics/datasette-plugin>`__ topic on GitHub.\n\nThings you can do with plugins include:\n\n* Add visualizations to Datasette, for example\n  `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`__ and\n  `datasette-vega <https://github.com/simonw/datasette-vega>`__.\n* Make new custom SQL functions available for use within Datasette, for example\n  `datasette-haversine <https://github.com/simonw/datasette-haversine>`__ and\n  `datasette-jellyfish <https://github.com/simonw/datasette-jellyfish>`__.\n* Define custom output formats with custom extensions, for example `datasette-atom <https://github.com/simonw/datasette-atom>`__ and\n  `datasette-ics <https://github.com/simonw/datasette-ics>`__.\n* Add template functions that can be called within your Jinja custom templates,\n  for example `datasette-render-markdown <https://github.com/simonw/datasette-render-markdown#markdown-in-templates>`__.\n* Customize how database values are rendered in the Datasette interface, for example\n  `datasette-render-binary <https://github.com/simonw/datasette-render-binary>`__ and\n  `datasette-pretty-json <https://github.com/simonw/datasette-pretty-json>`__.\n* Customize how Datasette's authentication and permissions systems work, for example `datasette-auth-passwords <https://github.com/simonw/datasette-auth-passwords>`__ and\n  `datasette-permissions-sql <https://github.com/simonw/datasette-permissions-sql>`__.\n\n.. _plugins_installing:\n\nInstalling plugins\n------------------\n\nIf a plugin has been packaged for distribution using setuptools you can use the plugin by installing it alongside Datasette in the same virtual environment or Docker container.\n\nYou can install plugins using the ``datasette install`` command::\n\n    datasette install datasette-vega\n\nYou can uninstall plugins with ``datasette uninstall``::\n\n    datasette uninstall datasette-vega\n\nYou can upgrade plugins with ``datasette install --upgrade`` or ``datasette install -U``::\n\n    datasette install -U datasette-vega\n\nThis command can also be used to upgrade Datasette itself to the latest released version::\n\n    datasette install -U datasette\n\nYou can install multiple plugins at once by listing them as lines in a ``requirements.txt`` file like this::\n\n    datasette-vega\n    datasette-cluster-map\n\nThen pass that file to ``datasette install -r``::\n\n    datasette install -r requirements.txt\n\nThe ``install`` and ``uninstall`` commands are thin wrappers around ``pip install`` and ``pip uninstall``, which ensure that they run ``pip`` in the same virtual environment as Datasette itself.\n\nOne-off plugins using --plugins-dir\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nYou can also define one-off per-project plugins by saving them as ``plugin_name.py`` functions in a ``plugins/`` folder and then passing that folder to ``datasette`` using the ``--plugins-dir`` option::\n\n    datasette mydb.db --plugins-dir=plugins/\n\nDeploying plugins using datasette publish\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe ``datasette publish`` and ``datasette package`` commands both take an optional ``--install`` argument. You can use this one or more times to tell Datasette to ``pip install`` specific plugins as part of the process::\n\n    datasette publish cloudrun mydb.db --install=datasette-vega\n\nYou can use the name of a package on PyPI or any of the other valid arguments to ``pip install`` such as a URL to a ``.zip`` file::\n\n    datasette publish cloudrun mydb.db \\\n        --install=https://url-to-my-package.zip\n\n\n.. _plugins_datasette_load_plugins:\n\nControlling which plugins are loaded\n------------------------------------\n\nDatasette defaults to loading every plugin that is installed in the same virtual environment as Datasette itself.\n\nYou can set the ``DATASETTE_LOAD_PLUGINS`` environment variable to a comma-separated list of plugin names to load a controlled subset of plugins instead.\n\nFor example, to load just the ``datasette-vega`` and ``datasette-cluster-map`` plugins, set ``DATASETTE_LOAD_PLUGINS`` to ``datasette-vega,datasette-cluster-map``:\n\n.. code-block:: bash\n\n    export DATASETTE_LOAD_PLUGINS='datasette-vega,datasette-cluster-map'\n    datasette mydb.db\n\nOr:\n\n.. code-block:: bash\n\n    DATASETTE_LOAD_PLUGINS='datasette-vega,datasette-cluster-map' \\\n      datasette mydb.db\n\nTo disable the loading of all additional plugins, set ``DATASETTE_LOAD_PLUGINS`` to an empty string:\n\n.. code-block:: bash\n\n    export DATASETTE_LOAD_PLUGINS=''\n    datasette mydb.db\n\nA quick way to test this setting is to use it with the ``datasette plugins`` command:\n\n.. code-block:: bash\n\n    DATASETTE_LOAD_PLUGINS='datasette-vega' datasette plugins\n\nThis should output the following:\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette-vega\",\n            \"static\": true,\n            \"templates\": false,\n            \"version\": \"0.6.2\",\n            \"hooks\": [\n                \"extra_css_urls\",\n                \"extra_js_urls\"\n            ]\n        }\n    ]\n\n.. _plugins_installed:\n\nSeeing what plugins are installed\n---------------------------------\n\nYou can see a list of installed plugins by navigating to the ``/-/plugins`` page of your Datasette instance - for example: https://fivethirtyeight.datasettes.com/-/plugins\n\nYou can also use the ``datasette plugins`` command::\n\n    datasette plugins\n\nWhich outputs:\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette_json_html\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": \"0.4.0\"\n        }\n    ]\n\n.. [[[cog\n    from datasette import cli\n    from click.testing import CliRunner\n    import textwrap, json\n    cog.out(\"\\n\")\n    result = CliRunner().invoke(cli.cli, [\"plugins\", \"--all\"])\n    # cog.out() with text containing newlines was unindenting for some reason\n    cog.outl(\"If you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette:\\n\")\n    cog.outl(\".. code-block:: json\\n\")\n    plugins = [p for p in json.loads(result.output) if p[\"name\"].startswith(\"datasette.\")]\n    indented = textwrap.indent(json.dumps(plugins, indent=4), \"    \")\n    for line in indented.split(\"\\n\"):\n        cog.outl(line)\n    cog.out(\"\\n\\n\")\n.. ]]]\n\nIf you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette:\n\n.. code-block:: json\n\n    [\n        {\n            \"name\": \"datasette.actor_auth_cookie\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"actor_from_request\"\n            ]\n        },\n        {\n            \"name\": \"datasette.blob_renderer\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_output_renderer\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_actions\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_actions\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_column_types\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_column_types\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_magic_parameters\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_magic_parameters\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_menu_links\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"menu_links\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_permissions\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"canned_queries\",\n                \"permission_resources_sql\",\n                \"skip_csrf\"\n            ]\n        },\n        {\n            \"name\": \"datasette.default_permissions.tokens\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"actor_from_request\",\n                \"register_token_handler\"\n            ]\n        },\n        {\n            \"name\": \"datasette.events\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_events\"\n            ]\n        },\n        {\n            \"name\": \"datasette.facets\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"register_facet_classes\"\n            ]\n        },\n        {\n            \"name\": \"datasette.filters\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"filters_from_request\"\n            ]\n        },\n        {\n            \"name\": \"datasette.forbidden\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"forbidden\"\n            ]\n        },\n        {\n            \"name\": \"datasette.handle_exception\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"handle_exception\"\n            ]\n        },\n        {\n            \"name\": \"datasette.publish.cloudrun\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"publish_subcommand\"\n            ]\n        },\n        {\n            \"name\": \"datasette.publish.heroku\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"publish_subcommand\"\n            ]\n        },\n        {\n            \"name\": \"datasette.sql_functions\",\n            \"static\": false,\n            \"templates\": false,\n            \"version\": null,\n            \"hooks\": [\n                \"prepare_connection\"\n            ]\n        }\n    ]\n\n\n.. [[[end]]]\n\nYou can add the ``--plugins-dir=`` option to include any plugins found in that directory.\n\nAdd ``--requirements`` to output a list of installed plugins that can then be installed in another Datasette instance using ``datasette install -r requirements.txt``::\n\n    datasette plugins --requirements\n\nThe output will look something like this::\n\n    datasette-codespaces==0.1.1\n    datasette-graphql==2.2\n    datasette-json-html==1.0.1\n    datasette-pretty-json==0.2.2\n    datasette-x-forwarded-host==0.1\n\nTo write that to a ``requirements.txt`` file, run this::\n\n    datasette plugins --requirements > requirements.txt\n\n.. _plugins_configuration:\n\nPlugin configuration\n--------------------\n\nPlugins can have their own configuration, embedded in a :ref:`configuration file <configuration>`. Configuration options for plugins live within a ``\"plugins\"`` key in that file, which can be included at the root, database or table level.\n\nHere is an example of some plugin configuration for a specific table:\n\n.. [[[cog\n    from metadata_doc import config_example\n    config_example(cog, {\n        \"databases\": {\n            \"sf-trees\": {\n                \"tables\": {\n                    \"Street_Tree_List\": {\n                        \"plugins\": {\n                            \"datasette-cluster-map\": {\n                                \"latitude_column\": \"lat\",\n                                \"longitude_column\": \"lng\"\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            tables:\n              Street_Tree_List:\n                plugins:\n                  datasette-cluster-map:\n                    latitude_column: lat\n                    longitude_column: lng\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"tables\": {\n                \"Street_Tree_List\": {\n                  \"plugins\": {\n                    \"datasette-cluster-map\": {\n                      \"latitude_column\": \"lat\",\n                      \"longitude_column\": \"lng\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThis tells the ``datasette-cluster-map`` column which latitude and longitude columns should be used for a table called ``Street_Tree_List`` inside a database file called ``sf-trees.db``.\n\n.. _plugins_configuration_secret:\n\nSecret configuration values\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nSome plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values.\n\n**As environment variables**. If your secret lives in an environment variable that is available to the Datasette process, you can indicate that the configuration value should be read from that environment variable like so:\n\n.. [[[cog\n    config_example(cog, {\n        \"plugins\": {\n            \"datasette-auth-github\": {\n                \"client_secret\": {\n                    \"$env\": \"GITHUB_CLIENT_SECRET\"\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        plugins:\n          datasette-auth-github:\n            client_secret:\n              $env: GITHUB_CLIENT_SECRET\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-auth-github\": {\n              \"client_secret\": {\n                \"$env\": \"GITHUB_CLIENT_SECRET\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n**As values in separate files**. Your secrets can also live in files on disk. To specify a secret should be read from a file, provide the full file path like this:\n\n.. [[[cog\n    config_example(cog, {\n        \"plugins\": {\n            \"datasette-auth-github\": {\n                \"client_secret\": {\n                    \"$file\": \"/secrets/client-secret\"\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        plugins:\n          datasette-auth-github:\n            client_secret:\n              $file: /secrets/client-secret\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-auth-github\": {\n              \"client_secret\": {\n                \"$file\": \"/secrets/client-secret\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nIf you are publishing your data using the :ref:`datasette publish <cli_publish>` family of commands, you can use the ``--plugin-secret`` option to set these secrets at publish time. For example, using Heroku you might run the following command::\n\n    datasette publish heroku my_database.db \\\n        --name my-heroku-app-demo \\\n        --install=datasette-auth-github \\\n        --plugin-secret datasette-auth-github client_id your_client_id \\\n        --plugin-secret datasette-auth-github client_secret your_client_secret\n\nThis will set the necessary environment variables and add the following to the deployed ``metadata.yaml``:\n\n.. [[[cog\n    config_example(cog, {\n        \"plugins\": {\n            \"datasette-auth-github\": {\n                \"client_id\": {\n                    \"$env\": \"DATASETTE_AUTH_GITHUB_CLIENT_ID\"\n                },\n                \"client_secret\": {\n                    \"$env\": \"DATASETTE_AUTH_GITHUB_CLIENT_SECRET\"\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        plugins:\n          datasette-auth-github:\n            client_id:\n              $env: DATASETTE_AUTH_GITHUB_CLIENT_ID\n            client_secret:\n              $env: DATASETTE_AUTH_GITHUB_CLIENT_SECRET\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-auth-github\": {\n              \"client_id\": {\n                \"$env\": \"DATASETTE_AUTH_GITHUB_CLIENT_ID\"\n              },\n              \"client_secret\": {\n                \"$env\": \"DATASETTE_AUTH_GITHUB_CLIENT_SECRET\"\n              }\n            }\n          }\n        }\n.. [[[end]]]\n"
  },
  {
    "path": "docs/publish.rst",
    "content": ".. _publishing:\n\n=================\n Publishing data\n=================\n\nDatasette includes tools for publishing and deploying your data to the internet. The ``datasette publish`` command will deploy a new Datasette instance containing your databases directly to a Heroku or Google Cloud hosting account. You can also use ``datasette package`` to create a Docker image that bundles your databases together with the datasette application that is used to serve them.\n\n.. _cli_publish:\n\ndatasette publish\n=================\n\nOnce you have created a SQLite database (e.g. using `csvs-to-sqlite <https://github.com/simonw/csvs-to-sqlite/>`_) you can deploy it to a hosting account using a single command.\n\nYou will need a hosting account with `Heroku <https://www.heroku.com/>`__ or `Google Cloud <https://cloud.google.com/>`__. Once you have created your account you will need to install and configure the ``heroku`` or ``gcloud`` command-line tools.\n\n.. _publish_cloud_run:\n\nPublishing to Google Cloud Run\n------------------------------\n\n`Google Cloud Run <https://cloud.google.com/run/>`__ allows you to publish data in a scale-to-zero environment, so your application will start running when the first request is received and will shut down again when traffic ceases. This means you only pay for time spent serving traffic.\n\n.. warning::\n    Cloud Run is a great option for inexpensively hosting small, low traffic projects - but costs can add up for projects that serve a lot of requests.\n\n    Be particularly careful if your project has tables with large numbers of rows. Search engine crawlers that index a page for every row could result in a high bill.\n\n    The `datasette-block-robots <https://datasette.io/plugins/datasette-block-robots>`__ plugin can be used to request search engine crawlers omit crawling your site, which can help avoid this issue.\n\nYou will first need to install and configure the Google Cloud CLI tools by following `these instructions <https://cloud.google.com/sdk/>`__.\n\nYou can then publish one or more SQLite database files to Google Cloud Run using the following command::\n\n    datasette publish cloudrun mydatabase.db --service=my-database\n\nA Cloud Run **service** is a single hosted application. The service name you specify will be used as part of the Cloud Run URL. If you deploy to a service name that you have used in the past your new deployment will replace the previous one.\n\nIf you omit the ``--service`` option you will be asked to pick a service name interactively during the deploy.\n\nYou may need to interact with prompts from the tool. Many of the prompts ask for values that can be `set as properties for the Google Cloud SDK <https://cloud.google.com/sdk/docs/properties>`_ if you want to avoid the prompts. \n\nFor example, the default region for the deployed instance can be set using the command::\n\n    gcloud config set run/region us-central1\n    \nYou should replace ``us-central1`` with your desired `region <https://cloud.google.com/about/locations>`_. Alternately, you can specify the region by setting the ``CLOUDSDK_RUN_REGION`` environment variable. \n\nOnce it has finished it will output a URL like this one::\n\n    Service [my-service] revision [my-service-00001] has been deployed\n    and is serving traffic at https://my-service-j7hipcg4aq-uc.a.run.app\n\nCloud Run provides a URL on the ``.run.app`` domain, but you can also point your own domain or subdomain at your Cloud Run service - see `mapping custom domains <https://cloud.google.com/run/docs/mapping-custom-domains>`__ in the Cloud Run documentation for details.\n\nSee :ref:`cli_help_publish_cloudrun___help` for the full list of options for this command.\n\n.. _publish_heroku:\n\nPublishing to Heroku\n--------------------\n\nTo publish your data using `Heroku <https://www.heroku.com/>`__, first create an account there and install and configure the `Heroku CLI tool <https://devcenter.heroku.com/articles/heroku-cli>`_.\n\nYou can publish one or more databases to Heroku using the following command::\n\n    datasette publish heroku mydatabase.db\n\nThis will output some details about the new deployment, including a URL like this one::\n\n    https://limitless-reef-88278.herokuapp.com/ deployed to Heroku\n\nYou can specify a custom app name by passing ``-n my-app-name`` to the publish command. This will also allow you to overwrite an existing app.\n\nRather than deploying directly you can use the ``--generate-dir`` option to output the files that would be deployed to a directory::\n\n    datasette publish heroku mydatabase.db --generate-dir=/tmp/deploy-this-to-heroku\n\nSee :ref:`cli_help_publish_heroku___help` for the full list of options for this command.\n\n.. _publish_vercel:\n\nPublishing to Vercel\n--------------------\n\n`Vercel <https://vercel.com/>`__  - previously known as Zeit Now - provides a layer over AWS Lambda to allow for quick, scale-to-zero deployment. You can deploy Datasette instances to Vercel using the `datasette-publish-vercel <https://github.com/simonw/datasette-publish-vercel>`__ plugin.\n\n::\n\n    pip install datasette-publish-vercel\n    datasette publish vercel mydatabase.db --project my-database-project\n\nNot every feature is supported: consult the `datasette-publish-vercel README <https://github.com/simonw/datasette-publish-vercel/blob/main/README.md>`__ for more details.\n\n.. _publish_fly:\n\nPublishing to Fly\n-----------------\n\n`Fly <https://fly.io/>`__ is a `competitively priced <https://fly.io/docs/pricing/>`__ Docker-compatible hosting platform that supports running applications in globally distributed data centers close to your end users. You can deploy Datasette instances to Fly using the `datasette-publish-fly <https://github.com/simonw/datasette-publish-fly>`__ plugin.\n\n::\n\n    pip install datasette-publish-fly\n    datasette publish fly mydatabase.db --app=\"my-app\"\n\nConsult the `datasette-publish-fly README <https://github.com/simonw/datasette-publish-fly/blob/main/README.md>`__ for more details.\n\n.. _publish_custom_metadata_and_plugins:\n\nCustom metadata and plugins\n---------------------------\n\n``datasette publish`` accepts a number of additional options which can be used to further customize your Datasette instance.\n\nYou can define your own :ref:`metadata` and deploy that with your instance like so::\n\n    datasette publish cloudrun --service=my-service mydatabase.db -m metadata.json\n\nIf you just want to set the title, license or source information you can do that directly using extra options to ``datasette publish``::\n\n    datasette publish cloudrun mydatabase.db --service=my-service \\\n        --title=\"Title of my database\" \\\n        --source=\"Where the data originated\" \\\n        --source_url=\"http://www.example.com/\"\n\nYou can also specify plugins you would like to install. For example, if you want to include the `datasette-vega <https://github.com/simonw/datasette-vega>`_ visualization plugin you can use the following::\n\n    datasette publish cloudrun mydatabase.db --service=my-service --install=datasette-vega\n\nIf a plugin has any :ref:`plugins_configuration_secret` you can use the ``--plugin-secret`` option to set those secrets at publish time. For example, using Heroku with `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ you might run the following command::\n\n    datasette publish heroku my_database.db \\\n        --name my-heroku-app-demo \\\n        --install=datasette-auth-github \\\n        --plugin-secret datasette-auth-github client_id your_client_id \\\n        --plugin-secret datasette-auth-github client_secret your_client_secret\n\n.. _cli_package:\n\ndatasette package\n=================\n\nIf you have docker installed (e.g. using `Docker for Mac <https://www.docker.com/docker-mac>`_) you can use the ``datasette package`` command to create a new Docker image in your local repository containing the datasette app bundled together with one or more SQLite databases::\n\n    datasette package mydatabase.db\n\nHere's example output for the package command::\n\n    datasette package parlgov.db --extra-options=\"--setting sql_time_limit_ms 2500\"\n    Sending build context to Docker daemon  4.459MB\n    Step 1/7 : FROM python:3.11.0-slim-bullseye\n     ---> 79e1dc9af1c1\n    Step 2/7 : COPY . /app\n     ---> Using cache\n     ---> cd4ec67de656\n    Step 3/7 : WORKDIR /app\n     ---> Using cache\n     ---> 139699e91621\n    Step 4/7 : RUN pip install datasette\n     ---> Using cache\n     ---> 340efa82bfd7\n    Step 5/7 : RUN datasette inspect parlgov.db --inspect-file inspect-data.json\n     ---> Using cache\n     ---> 5fddbe990314\n    Step 6/7 : EXPOSE 8001\n     ---> Using cache\n     ---> 8e83844b0fed\n    Step 7/7 : CMD datasette serve parlgov.db --port 8001 --inspect-file inspect-data.json --setting sql_time_limit_ms 2500\n     ---> Using cache\n     ---> 1bd380ea8af3\n    Successfully built 1bd380ea8af3\n\nYou can now run the resulting container like so::\n\n    docker run -p 8081:8001 1bd380ea8af3\n\nThis exposes port 8001 inside the container as port 8081 on your host machine, so you can access the application at ``http://localhost:8081/``\n\nYou can customize the port that is exposed by the container using the ``--port`` option::\n\n    datasette package mydatabase.db --port 8080\n\nA full list of options can be seen by running ``datasette package --help``:\n\nSee :ref:`cli_help_package___help` for the full list of options for this command.\n"
  },
  {
    "path": "docs/settings.rst",
    "content": ".. _settings:\n\nSettings\n========\n\nUsing \\-\\-setting\n-----------------\n\nDatasette supports a number of settings. These can be set using the ``--setting name value`` option to ``datasette serve``.\n\nYou can set multiple settings at once like this::\n\n    datasette mydatabase.db \\\n      --setting default_page_size 50 \\\n      --setting sql_time_limit_ms 3500 \\\n      --setting max_returned_rows 2000\n\nSettings can also be specified :ref:`in the database.yaml configuration file <configuration_reference_settings>`.\n\n.. _config_dir:\n\nConfiguration directory mode\n----------------------------\n\nNormally you configure Datasette using command-line options. For a Datasette instance with custom templates, custom plugins, a static directory and several databases this can get quite verbose::\n\n    datasette one.db two.db \\\n      --metadata=metadata.json \\\n      --template-dir=templates/ \\\n      --plugins-dir=plugins \\\n      --static css:css\n\nAs an alternative to this, you can run Datasette in *configuration directory* mode. Create a directory with the following structure::\n\n    # In a directory called my-app:\n    my-app/one.db\n    my-app/two.db\n    my-app/datasette.yaml\n    my-app/metadata.json\n    my-app/templates/index.html\n    my-app/plugins/my_plugin.py\n    my-app/static/my.css\n\nNow start Datasette by providing the path to that directory::\n\n    datasette my-app/\n\nDatasette will detect the files in that directory and automatically configure itself using them. It will serve all ``*.db`` files that it finds, will load ``metadata.json`` if it exists, and will load the ``templates``, ``plugins`` and ``static`` folders if they are present.\n\nThe files that can be included in this directory are as follows. All are optional.\n\n* ``*.db`` (or ``*.sqlite3`` or ``*.sqlite``) - SQLite database files that will be served by Datasette\n* ``datasette.yaml`` - :ref:`configuration` for the Datasette instance\n* ``metadata.json`` - :ref:`metadata` for those databases - ``metadata.yaml`` or ``metadata.yml`` can be used as well\n* ``inspect-data.json`` - the result of running ``datasette inspect *.db --inspect-file=inspect-data.json`` from the configuration directory - any database files listed here will be treated as immutable, so they should not be changed while Datasette is running\n* ``templates/`` - a directory containing :ref:`customization_custom_templates`\n* ``plugins/`` - a directory containing plugins, see :ref:`writing_plugins_one_off`\n* ``static/`` - a directory containing static files - these will be served from ``/static/filename.txt``, see :ref:`customization_static_files`\n\nSettings\n--------\n\nThe following options can be set using ``--setting name value``, or by storing them in the ``settings.json`` file for use with :ref:`config_dir`.\n\n.. _setting_default_allow_sql:\n\ndefault_allow_sql\n~~~~~~~~~~~~~~~~~\n\nShould users be able to execute arbitrary SQL queries by default?\n\nSetting this to ``off`` causes permission checks for :ref:`actions_execute_sql` to fail by default.\n\n::\n\n    datasette mydatabase.db --setting default_allow_sql off\n\nAnother way to achieve this is to add ``\"allow_sql\": false`` to your ``datasette.yaml`` file, as described in :ref:`authentication_permissions_execute_sql`. This setting offers a more convenient way to do this.\n\n.. _setting_default_page_size:\n\ndefault_page_size\n~~~~~~~~~~~~~~~~~\n\nThe 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::\n\n    datasette mydatabase.db --setting default_page_size 50\n\n.. _setting_sql_time_limit_ms:\n\nsql_time_limit_ms\n~~~~~~~~~~~~~~~~~\n\nBy 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.\n\nIf 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::\n\n    datasette mydatabase.db --setting sql_time_limit_ms 3500\n\nYou can optionally set a lower time limit for an individual query using the ``?_timelimit=100`` query string argument::\n\n    /my-database/my-table?qSpecies=44&_timelimit=100\n\nThis would set the time limit to 100ms for that specific query. This feature is useful if you are working with databases of unknown size and complexity - a query that might make perfect sense for a smaller table could take too long to execute on a table with millions of rows. By setting custom time limits you can execute queries \"optimistically\" - e.g. give me an exact count of rows matching this query but only if it takes less than 100ms to calculate.\n\n.. _setting_max_returned_rows:\n\nmax_returned_rows\n~~~~~~~~~~~~~~~~~\n\nDatasette 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.\n\nYou can increase or decrease this limit like so::\n\n    datasette mydatabase.db --setting max_returned_rows 2000\n\n.. _setting_max_insert_rows:\n\nmax_insert_rows\n~~~~~~~~~~~~~~~\n\nMaximum rows that can be inserted at a time using the bulk insert API, see :ref:`TableInsertView`. Defaults to 100.\n\nYou can increase or decrease this limit like so::\n\n    datasette mydatabase.db --setting max_insert_rows 1000\n\n.. _setting_num_sql_threads:\n\nnum_sql_threads\n~~~~~~~~~~~~~~~\n\nMaximum number of threads in the thread pool Datasette uses to execute SQLite queries. Defaults to 3.\n\n::\n\n    datasette mydatabase.db --setting num_sql_threads 10\n\nSetting this to 0 turns off threaded SQL queries entirely - useful for environments that do not support threading such as `Pyodide <https://pyodide.org/>`__.\n\n.. _setting_allow_facet:\n\nallow_facet\n~~~~~~~~~~~\n\nAllow users to specify columns they would like to facet on using the ``?_facet=COLNAME`` URL parameter to the table view.\n\nThis is enabled by default. If disabled, facets will still be displayed if they have been specifically enabled in ``metadata.json`` configuration for the table.\n\nHere's how to disable this feature::\n\n    datasette mydatabase.db --setting allow_facet off\n\n.. _setting_default_facet_size:\n\ndefault_facet_size\n~~~~~~~~~~~~~~~~~~\n\nThe default number of unique rows returned by :ref:`facets` is 30. You can customize it like this::\n\n    datasette mydatabase.db --setting default_facet_size 50\n\n.. _setting_facet_time_limit_ms:\n\nfacet_time_limit_ms\n~~~~~~~~~~~~~~~~~~~\n\nThis is the time limit Datasette allows for calculating a facet, which defaults to 200ms::\n\n    datasette mydatabase.db --setting facet_time_limit_ms 1000\n\n.. _setting_facet_suggest_time_limit_ms:\n\nfacet_suggest_time_limit_ms\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nWhen 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.\n\nYou can increase this time limit like so::\n\n    datasette mydatabase.db --setting facet_suggest_time_limit_ms 500\n\n.. _setting_suggest_facets:\n\nsuggest_facets\n~~~~~~~~~~~~~~\n\nShould Datasette calculate suggested facets? On by default, turn this off like so::\n\n    datasette mydatabase.db --setting suggest_facets off\n\n.. _setting_allow_download:\n\nallow_download\n~~~~~~~~~~~~~~\n\nShould 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::\n\n    datasette mydatabase.db --setting allow_download off\n\n.. _setting_allow_signed_tokens:\n\nallow_signed_tokens\n~~~~~~~~~~~~~~~~~~~\n\nShould users be able to create signed API tokens to access Datasette?\n\nThis is turned on by default. Use the following to turn it off::\n\n    datasette mydatabase.db --setting allow_signed_tokens off\n\nTurning this setting off will disable the ``/-/create-token`` page, :ref:`described here <CreateTokenView>`. It will also cause any incoming ``Authorization: Bearer dstok_...`` API tokens to be ignored.\n\n.. _setting_max_signed_tokens_ttl:\n\nmax_signed_tokens_ttl\n~~~~~~~~~~~~~~~~~~~~~\n\nMaximum allowed expiry time for signed API tokens created by users.\n\nDefaults to ``0`` which means no limit - tokens can be created that will never expire.\n\nSet this to a value in seconds to limit the maximum expiry time. For example, to set that limit to 24 hours you would use::\n\n    datasette mydatabase.db --setting max_signed_tokens_ttl 86400\n\nThis setting is enforced when incoming tokens are processed.\n\n.. _setting_default_cache_ttl:\n\ndefault_cache_ttl\n~~~~~~~~~~~~~~~~~\n\nDefault 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.\n\n::\n\n    datasette mydatabase.db --setting default_cache_ttl 60\n\n.. _setting_cache_size_kb:\n\ncache_size_kb\n~~~~~~~~~~~~~\n\nSets the amount of memory SQLite uses for its `per-connection cache <https://www.sqlite.org/pragma.html#pragma_cache_size>`_, in KB.\n\n::\n\n    datasette mydatabase.db --setting cache_size_kb 5000\n\n.. _setting_allow_csv_stream:\n\nallow_csv_stream\n~~~~~~~~~~~~~~~~\n\nEnables :ref:`the CSV export feature <csv_export>` where an entire table\n(potentially hundreds of thousands of rows) can be exported as a single CSV\nfile. This is turned on by default - you can turn it off like this:\n\n::\n\n    datasette mydatabase.db --setting allow_csv_stream off\n\n.. _setting_max_csv_mb:\n\nmax_csv_mb\n~~~~~~~~~~\n\nThe maximum size of CSV that can be exported, in megabytes. Defaults to 100MB.\nYou can disable the limit entirely by settings this to 0:\n\n::\n\n    datasette mydatabase.db --setting max_csv_mb 0\n\n.. _setting_truncate_cells_html:\n\ntruncate_cells_html\n~~~~~~~~~~~~~~~~~~~\n\nIn the HTML table view, truncate any strings that are longer than this value.\nThe full value will still be available in CSV, JSON and on the individual row\nHTML page. Set this to 0 to disable truncation.\n\n::\n\n    datasette mydatabase.db --setting truncate_cells_html 0\n\n.. _setting_force_https_urls:\n\nforce_https_urls\n~~~~~~~~~~~~~~~~\n\nForces self-referential URLs in the JSON output to always use the ``https://``\nprotocol. This is useful for cases where the application itself is hosted using\nHTTP but is served to the outside world via a proxy that enables HTTPS.\n\n::\n\n    datasette mydatabase.db --setting force_https_urls 1\n\n.. _setting_template_debug:\n\ntemplate_debug\n~~~~~~~~~~~~~~\n\nThis setting enables template context debug mode, which is useful to help understand what variables are available to custom templates when you are writing them.\n\nEnable it like this::\n\n    datasette mydatabase.db --setting template_debug 1\n\nNow you can add ``?_context=1`` or ``&_context=1`` to any Datasette page to see the context that was passed to that template.\n\nSome examples:\n\n* https://latest.datasette.io/?_context=1\n* https://latest.datasette.io/fixtures?_context=1\n* https://latest.datasette.io/fixtures/roadside_attractions?_context=1\n\n.. _setting_trace_debug:\n\ntrace_debug\n~~~~~~~~~~~\n\nThis 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.\n\nEnable it like this::\n\n    datasette mydatabase.db --setting trace_debug 1\n\nSome examples:\n\n* https://latest.datasette.io/?_trace=1\n* https://latest.datasette.io/fixtures/roadside_attractions?_trace=1\n\nSee :ref:`internals_tracer` for details on how to hook into this mechanism as a plugin author.\n\n.. _setting_base_url:\n\nbase_url\n~~~~~~~~\n\nIf you are running Datasette behind a proxy, it may be useful to change the root path used for the Datasette instance.\n\nFor 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.\n\nYou can do that like so::\n\n    datasette mydatabase.db --setting base_url /tools/datasette/\n\n.. _setting_secret:\n\nConfiguring the secret\n----------------------\n\nDatasette uses a secret string to sign secure values such as cookies.\n\nIf you do not provide a secret, Datasette will create one when it starts up. This secret will reset every time the Datasette server restarts though, so things like authentication cookies and :ref:`API tokens <CreateTokenView>` will not stay valid between restarts.\n\nYou can pass a secret to Datasette in two ways: with the ``--secret`` command-line option or by setting a ``DATASETTE_SECRET`` environment variable.\n\n::\n\n    datasette mydb.db --secret=SECRET_VALUE_HERE\n\nOr::\n\n    export DATASETTE_SECRET=SECRET_VALUE_HERE\n    datasette mydb.db\n\nOne way to generate a secure random secret is to use Python like this::\n\n    python3 -c 'import secrets; print(secrets.token_hex(32))'\n    cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52\n\nPlugin authors can make use of this signing mechanism in their plugins using the :ref:`datasette.sign() <datasette_sign>` and :ref:`datasette.unsign() <datasette_unsign>` methods.\n\n.. _setting_publish_secrets:\n\nUsing secrets with datasette publish\n------------------------------------\n\nThe :ref:`cli_publish` and :ref:`cli_package` commands both generate a secret for you automatically when Datasette is deployed.\n\nThis means that every time you deploy a new version of a Datasette project, a new secret will be generated. This will cause signed cookies to become invalid on every fresh deploy.\n\nYou can fix this by creating a secret that will be used for multiple deploys and passing it using the ``--secret`` option::\n\n    datasette publish cloudrun mydb.db --service=my-service --secret=cdb19e94283a20f9d42cca5\n"
  },
  {
    "path": "docs/spatialite.rst",
    "content": ".. _spatialite:\n\n============\n SpatiaLite\n============\n\nThe `SpatiaLite module <https://www.gaia-gis.it/fossil/libspatialite/index>`_ for SQLite adds features for handling geographic and spatial data. For an example of what you can do with it, see the tutorial `Building a location to time zone API with SpatiaLite <https://datasette.io/tutorials/spatialite>`__.\n\nTo use it with Datasette, you need to install the ``mod_spatialite`` dynamic library. This can then be loaded into Datasette using the ``--load-extension`` command-line option.\n\nDatasette can look for SpatiaLite in common installation locations if you run it like this::\n\n    datasette --load-extension=spatialite --setting default_allow_sql off\n\nIf SpatiaLite is in another location, use the full path to the extension instead::\n\n    datasette --setting default_allow_sql off \\\n      --load-extension=/usr/local/lib/mod_spatialite.dylib\n\n.. _spatialite_warning:\n\nWarning\n=======\n\n.. warning::\n    The SpatiaLite extension adds `a large number of additional SQL functions <https://www.gaia-gis.it/gaia-sins/spatialite-sql-5.0.1.html>`__, some of which are not be safe for untrusted users to execute: they may cause the Datasette server to crash.\n\n    You should not expose a SpatiaLite-enabled Datasette instance to the public internet without taking extra measures to secure it against potentially harmful SQL queries.\n\n    The following steps are recommended:\n\n    - Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option.\n    - Define :ref:`canned_queries` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.\n\n    The `Datasette SpatiaLite tutorial <https://datasette.io/tutorials/spatialite>`__ includes detailed instructions for running SpatiaLite safely using these techniques\n\n.. _spatialite_installation:\n\nInstallation\n============\n\nInstalling SpatiaLite on OS X\n-----------------------------\n\nThe easiest way to install SpatiaLite on OS X is to use `Homebrew <https://brew.sh/>`_.\n\n::\n\n    brew update\n    brew install spatialite-tools\n\nThis will install the ``spatialite`` command-line tool and the ``mod_spatialite`` dynamic library.\n\nYou can now run Datasette like so::\n\n    datasette --load-extension=spatialite\n\nInstalling SpatiaLite on Linux\n------------------------------\n\nSpatiaLite is packaged for most Linux distributions.\n\n::\n\n    apt install spatialite-bin libsqlite3-mod-spatialite\n\nDepending on your distribution, you should be able to run Datasette something like this::\n\n    datasette --load-extension=/usr/lib/x86_64-linux-gnu/mod_spatialite.so\n\nIf you are unsure of the location of the module, try running ``locate mod_spatialite`` and see what comes back.\n\nSpatial indexing latitude/longitude columns\n===========================================\n\nHere's a recipe for taking a table with existing latitude and longitude columns, adding a SpatiaLite POINT geometry column to that table, populating the new column and then populating a spatial index:\n\n.. code-block:: python\n\n    import sqlite3\n\n    conn = sqlite3.connect(\"museums.db\")\n    # Lead the spatialite extension:\n    conn.enable_load_extension(True)\n    conn.load_extension(\"/usr/local/lib/mod_spatialite.dylib\")\n    # Initialize spatial metadata for this database:\n    conn.execute(\"select InitSpatialMetadata(1)\")\n    # Add a geometry column called point_geom to our museums table:\n    conn.execute(\n        \"SELECT AddGeometryColumn('museums', 'point_geom', 4326, 'POINT', 2);\"\n    )\n    # Now update that geometry column with the lat/lon points\n    conn.execute(\"\"\"\n        UPDATE museums SET\n        point_geom = GeomFromText('POINT('||\"longitude\"||' '||\"latitude\"||')',4326);\n    \"\"\")\n    # Now add a spatial index to that column\n    conn.execute(\n        'select CreateSpatialIndex(\"museums\", \"point_geom\");'\n    )\n    # If you don't commit your changes will not be persisted:\n    conn.commit()\n    conn.close()\n\nMaking use of a spatial index\n=============================\n\nSpatiaLite spatial indexes are R*Trees. They allow you to run efficient bounding box queries using a sub-select, with a similar pattern to that used for :ref:`full_text_search_custom_sql`.\n\nIn the above example, the resulting index will be called ``idx_museums_point_geom``. This takes the form of a SQLite virtual table. You can inspect its contents using the following query:\n\n.. code-block:: sql\n\n    select * from idx_museums_point_geom limit 10;\n\nHere's a live example: `timezones-api.datasette.io/timezones/idx_timezones_Geometry <https://timezones-api.datasette.io/timezones/idx_timezones_Geometry>`_\n\n+--------+----------------------+----------------------+---------------------+---------------------+\n|  pkid  |  xmin                |  xmax                |  ymin               |  ymax               |\n+========+======================+======================+=====================+=====================+\n| 1      |  -8.601725578308105  |  -2.4930307865142822 |  4.162120819091797  |  10.74019718170166  |\n+--------+----------------------+----------------------+---------------------+---------------------+\n| 2      |  -3.2607860565185547 |  1.27329421043396    |  4.539252281188965  |  11.174856185913086 |\n+--------+----------------------+----------------------+---------------------+---------------------+\n| 3      |  32.997581481933594  |  47.98238754272461   |  3.3974475860595703 |  14.894054412841797 |\n+--------+----------------------+----------------------+---------------------+---------------------+\n| 4      |  -8.66890811920166   |  11.997337341308594  |  18.9681453704834   |  37.296207427978516 |\n+--------+----------------------+----------------------+---------------------+---------------------+\n| 5      |  36.43336486816406   |  43.300174713134766  |  12.354820251464844 |  18.070993423461914 |\n+--------+----------------------+----------------------+---------------------+---------------------+\n\nYou can now construct efficient bounding box queries that will make use of the index like this:\n\n.. code-block:: sql\n\n    select * from museums where museums.rowid in (\n        SELECT pkid FROM idx_museums_point_geom\n        -- left-hand-edge of point > left-hand-edge of bbox (minx)\n        where xmin > :bbox_minx\n        -- right-hand-edge of point < right-hand-edge of bbox (maxx)\n        and xmax < :bbox_maxx\n        -- bottom-edge of point > bottom-edge of bbox (miny)\n        and ymin > :bbox_miny\n        -- top-edge of point < top-edge of bbox (maxy)\n        and ymax < :bbox_maxy\n    );\n\nSpatial indexes can be created against polygon columns as well as point columns, in which case they will represent the minimum bounding rectangle of that polygon. This is useful for accelerating ``within`` queries, as seen in the Timezones API example.\n\nImporting shapefiles into SpatiaLite\n====================================\n\nThe `shapefile format <https://en.wikipedia.org/wiki/Shapefile>`_ is a common format for distributing geospatial data. You can use the ``spatialite`` command-line tool to create a new database table from a shapefile.\n\nTry it now with the North America shapefile available from the University of North Carolina `Global River Database <http://gaia.geosci.unc.edu/rivers/>`_ project. Download the file and unzip it (this will create files called ``narivs.dbf``, ``narivs.prj``, ``narivs.shp`` and ``narivs.shx`` in the current directory), then run the following::\n\n    spatialite rivers-database.db\n\n::\n\n    SpatiaLite version ..: 4.3.0a\tSupported Extensions:\n    ...\n    spatialite> .loadshp narivs rivers CP1252 23032\n    ========\n    Loading shapefile at 'narivs' into SQLite table 'rivers'\n    ...\n    Inserted 467973 rows into 'rivers' from SHAPEFILE\n\nThis will load the data from the ``narivs`` shapefile into a new database table called ``rivers``.\n\nExit out of ``spatialite`` (using ``Ctrl+D``) and run Datasette against your new database like this::\n\n    datasette rivers-database.db \\\n        --load-extension=/usr/local/lib/mod_spatialite.dylib\n\nIf you browse to ``http://localhost:8001/rivers-database/rivers`` you will see the new table... but the ``Geometry`` column will contain unreadable binary data (SpatiaLite uses `a custom format based on WKB <https://www.gaia-gis.it/gaia-sins/BLOB-Geometry.html>`_).\n\nThe easiest way to turn this into semi-readable data is to use the SpatiaLite ``AsGeoJSON`` function. Try the following using the SQL query interface at ``http://localhost:8001/rivers-database``:\n\n.. code-block:: sql\n\n    select *, AsGeoJSON(Geometry) from rivers limit 10;\n\nThis will give you back an additional column of GeoJSON. You can copy and paste GeoJSON from this column into the debugging tool at `geojson.io <https://geojson.io/>`_ to visualize it on a map.\n\nTo see a more interesting example, try ordering the records with the longest geometry first. Since there are 467,000 rows in the table you will first need to increase the SQL time limit imposed by Datasette::\n\n    datasette rivers-database.db \\\n        --load-extension=/usr/local/lib/mod_spatialite.dylib \\\n        --setting sql_time_limit_ms 10000\n\nNow try the following query:\n\n.. code-block:: sql\n\n    select *, AsGeoJSON(Geometry) from rivers\n    order by length(Geometry) desc limit 10;\n\nImporting GeoJSON polygons using Shapely\n========================================\n\nAnother common form of polygon data is the GeoJSON format. This can be imported into SpatiaLite directly, or by using the `Shapely <https://pypi.org/project/Shapely/>`_ Python library.\n\n`Who's On First <https://whosonfirst.org/>`_ is an excellent source of openly licensed GeoJSON polygons. Let's import the geographical polygon for Wales. First, we can use the Who's On First Spelunker tool to find the record for Wales:\n\n`spelunker.whosonfirst.org/id/404227475 <https://spelunker.whosonfirst.org/id/404227475/>`_\n\nThat page includes a link to the GeoJSON record, which can be accessed here:\n\n`data.whosonfirst.org/404/227/475/404227475.geojson <https://data.whosonfirst.org/404/227/475/404227475.geojson>`_\n\nHere's Python code to create a SQLite database, enable SpatiaLite, create a places table and then add a record for Wales:\n\n.. code-block:: python\n\n    import sqlite3\n\n    conn = sqlite3.connect(\"places.db\")\n    # Enable SpatialLite extension\n    conn.enable_load_extension(True)\n    conn.load_extension(\"/usr/local/lib/mod_spatialite.dylib\")\n    # Create the masic countries table\n    conn.execute(\"select InitSpatialMetadata(1)\")\n    conn.execute(\n        \"create table places (id integer primary key, name text);\"\n    )\n    # Add a MULTIPOLYGON Geometry column\n    conn.execute(\n        \"SELECT AddGeometryColumn('places', 'geom', 4326, 'MULTIPOLYGON', 2);\"\n    )\n    # Add a spatial index against the new column\n    conn.execute(\"SELECT CreateSpatialIndex('places', 'geom');\")\n    # Now populate the table\n    from shapely.geometry.multipolygon import MultiPolygon\n    from shapely.geometry import shape\n    import requests\n\n    geojson = requests.get(\n        \"https://data.whosonfirst.org/404/227/475/404227475.geojson\"\n    ).json()\n    # Convert to \"Well Known Text\" format\n    wkt = shape(geojson[\"geometry\"]).wkt\n    # Insert and commit the record\n    conn.execute(\n        \"INSERT INTO places (id, name, geom) VALUES(null, ?, GeomFromText(?, 4326))\",\n        (\"Wales\", wkt),\n    )\n    conn.commit()\n\nQuerying polygons using within()\n================================\n\nThe ``within()`` SQL function can be used to check if a point is within a geometry:\n\n.. code-block:: sql\n\n    select\n        name\n    from\n        places\n    where\n       within(GeomFromText('POINT(-3.1724366 51.4704448)'), places.geom);\n\nThe ``GeomFromText()`` function takes a string of well-known text. Note that the order used here is ``longitude`` then  ``latitude``.\n\nTo run that same ``within()`` query in a way that benefits from the spatial index, use the following:\n\n.. code-block:: sql\n\n    select\n        name\n    from\n        places\n    where\n        within(GeomFromText('POINT(-3.1724366 51.4704448)'), places.geom)\n        and rowid in (\n            SELECT pkid FROM idx_places_geom\n            where xmin < -3.1724366\n            and xmax > -3.1724366\n            and ymin < 51.4704448\n            and ymax > 51.4704448\n        );\n"
  },
  {
    "path": "docs/sql_queries.rst",
    "content": ".. _sql:\n\nRunning SQL queries\n===================\n\nDatasette treats SQLite database files as read-only and immutable. This means it is not possible to execute INSERT or UPDATE statements using Datasette, which allows us to expose SELECT statements to the outside world without needing to worry about SQL injection attacks.\n\nThe easiest way to execute custom SQL against Datasette is through the web UI. The database index page includes a SQL editor that lets you run any SELECT query you like. You can also construct queries using the filter interface on the tables page, then click \"View and edit SQL\" to open that query in the custom SQL editor.\n\nNote that this interface is only available if the :ref:`actions_execute_sql` permission is allowed. See :ref:`authentication_permissions_execute_sql`.\n\nAny Datasette SQL query is reflected in the URL of the page, allowing you to bookmark them, share them with others and navigate through previous queries using your browser back button.\n\nYou can also retrieve the results of any query as JSON by adding ``.json`` to the base URL.\n\n.. _sql_parameters:\n\nNamed parameters\n----------------\n\nDatasette has special support for SQLite named parameters. Consider a SQL query like this:\n\n.. code-block:: sql\n\n    select * from Street_Tree_List\n    where \"PermitNotes\" like :notes\n    and \"qSpecies\" = :species\n\nIf you execute this query using the custom query editor, Datasette will extract the two named parameters and use them to construct form fields for you to provide values.\n\nYou can also provide values for these fields by constructing a URL::\n\n    /mydatabase?sql=select...&species=44\n\nSQLite string escaping rules will be applied to values passed using named parameters - they will be wrapped in quotes and their content will be correctly escaped.\n\nValues from named parameters are treated as SQLite strings. If you need to perform numeric comparisons on them you should cast them to an integer or float first using ``cast(:name as integer)`` or ``cast(:name as real)``, for example:\n\n.. code-block:: sql\n\n    select * from Street_Tree_List\n    where latitude > cast(:min_latitude as real)\n    and latitude < cast(:max_latitude as real)\n\nDatasette disallows custom SQL queries containing the string PRAGMA (with a small number `of exceptions <https://github.com/simonw/datasette/issues/761>`__) as SQLite pragma statements can be used to change database settings at runtime. If you need to include the string \"pragma\" in a query you can do so safely using a named parameter.\n\n.. _sql_views:\n\nViews\n-----\n\nIf you want to bundle some pre-written SQL queries with your Datasette-hosted database you can do so in two ways. The first is to include SQL views in your database - Datasette will then list those views on your database index page.\n\nThe quickest way to create views is with the SQLite command-line interface::\n\n    sqlite3 sf-trees.db\n\n::\n\n    SQLite version 3.19.3 2017-06-27 16:48:08\n    Enter \".help\" for usage hints.\n    sqlite> CREATE VIEW demo_view AS select qSpecies from Street_Tree_List;\n    <CTRL+D>\n\nYou can also use the `sqlite-utils <https://sqlite-utils.datasette.io/>`__ tool to `create a view <https://sqlite-utils.datasette.io/en/stable/cli.html#creating-views>`__::\n\n    sqlite-utils create-view sf-trees.db demo_view \"select qSpecies from Street_Tree_List\"\n\n.. _canned_queries:\n\nCanned queries\n--------------\n\nAs an alternative to adding views to your database, you can define canned queries inside your ``datasette.yaml`` file. Here's an example:\n\n.. [[[cog\n    from metadata_doc import config_example, config_example\n    config_example(cog, {\n        \"databases\": {\n           \"sf-trees\": {\n               \"queries\": {\n                   \"just_species\": {\n                       \"sql\": \"select qSpecies from Street_Tree_List\"\n                   }\n               }\n           }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            queries:\n              just_species:\n                sql: select qSpecies from Street_Tree_List\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"queries\": {\n                \"just_species\": {\n                  \"sql\": \"select qSpecies from Street_Tree_List\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThen run Datasette like this::\n\n    datasette sf-trees.db -m metadata.json\n\nEach canned query will be listed on the database index page, and will also get its own URL at::\n\n    /database-name/canned-query-name\n\nFor the above example, that URL would be::\n\n    /sf-trees/just_species\n\nYou can optionally include ``\"title\"`` and ``\"description\"`` keys to show a title and description on the canned query page. As with regular table metadata you can alternatively specify ``\"description_html\"`` to have your description rendered as HTML (rather than having HTML special characters escaped).\n\n.. _canned_queries_named_parameters:\n\nCanned query parameters\n~~~~~~~~~~~~~~~~~~~~~~~\n\nCanned queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the canned query page or by adding them to the URL. This means canned queries can be used to create custom JSON APIs based on a carefully designed SQL statement.\n\nHere's an example of a canned query with a named parameter:\n\n.. code-block:: sql\n\n    select neighborhood, facet_cities.name, state\n    from facetable\n      join facet_cities on facetable.city_id = facet_cities.id\n    where neighborhood like '%' || :text || '%'\n    order by neighborhood;\n\nIn the canned query configuration looks like this:\n\n\n.. [[[cog\n    config_example(cog, \"\"\"\n    databases:\n      fixtures:\n        queries:\n          neighborhood_search:\n            title: Search neighborhoods\n            sql: |-\n              select neighborhood, facet_cities.name, state\n              from facetable\n                join facet_cities on facetable.city_id = facet_cities.id\n              where neighborhood like '%' || :text || '%'\n              order by neighborhood\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n        databases:\n          fixtures:\n            queries:\n              neighborhood_search:\n                title: Search neighborhoods\n                sql: |-\n                  select neighborhood, facet_cities.name, state\n                  from facetable\n                    join facet_cities on facetable.city_id = facet_cities.id\n                  where neighborhood like '%' || :text || '%'\n                  order by neighborhood\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"fixtures\": {\n              \"queries\": {\n                \"neighborhood_search\": {\n                  \"title\": \"Search neighborhoods\",\n                  \"sql\": \"select neighborhood, facet_cities.name, state\\nfrom facetable\\n  join facet_cities on facetable.city_id = facet_cities.id\\nwhere neighborhood like '%' || :text || '%'\\norder by neighborhood\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nNote that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user.\n\nYou can try this canned query out here:\nhttps://latest.datasette.io/fixtures/neighborhood_search?text=town\n\nIn this example the ``:text`` named parameter is automatically extracted from the query using a regular expression.\n\nYou can alternatively provide an explicit list of named parameters using the ``\"params\"`` key, like this:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n    databases:\n      fixtures:\n        queries:\n          neighborhood_search:\n            title: Search neighborhoods\n            params:\n            - text\n            sql: |-\n              select neighborhood, facet_cities.name, state\n              from facetable\n                join facet_cities on facetable.city_id = facet_cities.id\n              where neighborhood like '%' || :text || '%'\n              order by neighborhood\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n        databases:\n          fixtures:\n            queries:\n              neighborhood_search:\n                title: Search neighborhoods\n                params:\n                - text\n                sql: |-\n                  select neighborhood, facet_cities.name, state\n                  from facetable\n                    join facet_cities on facetable.city_id = facet_cities.id\n                  where neighborhood like '%' || :text || '%'\n                  order by neighborhood\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"fixtures\": {\n              \"queries\": {\n                \"neighborhood_search\": {\n                  \"title\": \"Search neighborhoods\",\n                  \"params\": [\n                    \"text\"\n                  ],\n                  \"sql\": \"select neighborhood, facet_cities.name, state\\nfrom facetable\\n  join facet_cities on facetable.city_id = facet_cities.id\\nwhere neighborhood like '%' || :text || '%'\\norder by neighborhood\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n.. _canned_queries_options:\n\nAdditional canned query options\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nAdditional options can be specified for canned queries in the YAML or JSON configuration.\n\nhide_sql\n++++++++\n\nCanned queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a \"show\" link that can be used to make it visible.\n\nAdd the ``\"hide_sql\": true`` option to hide the SQL query by default.\n\nfragment\n++++++++\n\nSome plugins, such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol.\n\nYou can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``\"fragment\"`` key.\n\nThis example demonstrates both ``fragment`` and ``hide_sql``:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n    databases:\n      fixtures:\n        queries:\n          neighborhood_search:\n            fragment: fragment-goes-here\n            hide_sql: true\n            sql: |-\n              select neighborhood, facet_cities.name, state\n              from facetable join facet_cities on facetable.city_id = facet_cities.id\n              where neighborhood like '%' || :text || '%' order by neighborhood;\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n        databases:\n          fixtures:\n            queries:\n              neighborhood_search:\n                fragment: fragment-goes-here\n                hide_sql: true\n                sql: |-\n                  select neighborhood, facet_cities.name, state\n                  from facetable join facet_cities on facetable.city_id = facet_cities.id\n                  where neighborhood like '%' || :text || '%' order by neighborhood;\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"fixtures\": {\n              \"queries\": {\n                \"neighborhood_search\": {\n                  \"fragment\": \"fragment-goes-here\",\n                  \"hide_sql\": true,\n                  \"sql\": \"select neighborhood, facet_cities.name, state\\nfrom facetable join facet_cities on facetable.city_id = facet_cities.id\\nwhere neighborhood like '%' || :text || '%' order by neighborhood;\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\n`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.\n\n.. _canned_queries_writable:\n\nWritable canned queries\n~~~~~~~~~~~~~~~~~~~~~~~\n\nCanned queries by default are read-only. You can use the ``\"write\": true`` key to indicate that a canned query can write to the database.\n\nSee :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``\"allow\"`` key.\n\n.. [[[cog\n    config_example(cog, {\n        \"databases\": {\n            \"mydatabase\": {\n                \"queries\": {\n                    \"add_name\": {\n                        \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                        \"write\": True\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            queries:\n              add_name:\n                sql: INSERT INTO names (name) VALUES (:name)\n                write: true\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"queries\": {\n                \"add_name\": {\n                  \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                  \"write\": true\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThis configuration will create a page at ``/mydatabase/add_name`` displaying a form with a ``name`` field. Submitting that form will execute the configured ``INSERT`` query.\n\nYou can customize how Datasette represents success and errors using the following optional properties:\n\n- ``on_success_message`` - the message shown when a query is successful\n- ``on_success_message_sql`` - alternative to ``on_success_message``: a SQL query that should be executed to generate the message\n- ``on_success_redirect`` - the path or URL the user is redirected to on success\n- ``on_error_message`` - the message shown when a query throws an error\n- ``on_error_redirect`` - the path or URL the user is redirected to on error\n\nFor example:\n\n.. [[[cog\n    config_example(cog, {\n        \"databases\": {\n            \"mydatabase\": {\n                \"queries\": {\n                    \"add_name\": {\n                        \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                        \"params\": [\"name\"],\n                        \"write\": True,\n                        \"on_success_message_sql\": \"select 'Name inserted: ' || :name\",\n                        \"on_success_redirect\": \"/mydatabase/names\",\n                        \"on_error_message\": \"Name insert failed\",\n                        \"on_error_redirect\": \"/mydatabase\",\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          mydatabase:\n            queries:\n              add_name:\n                sql: INSERT INTO names (name) VALUES (:name)\n                params:\n                - name\n                write: true\n                on_success_message_sql: 'select ''Name inserted: '' || :name'\n                on_success_redirect: /mydatabase/names\n                on_error_message: Name insert failed\n                on_error_redirect: /mydatabase\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"queries\": {\n                \"add_name\": {\n                  \"sql\": \"INSERT INTO names (name) VALUES (:name)\",\n                  \"params\": [\n                    \"name\"\n                  ],\n                  \"write\": true,\n                  \"on_success_message_sql\": \"select 'Name inserted: ' || :name\",\n                  \"on_success_redirect\": \"/mydatabase/names\",\n                  \"on_error_message\": \"Name insert failed\",\n                  \"on_error_redirect\": \"/mydatabase\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nYou can use ``\"params\"`` to explicitly list the named parameters that should be displayed as form fields - otherwise they will be automatically detected. ``\"params\"`` is not necessary in the above example, since without it ``\"name\"`` would be automatically detected from the query.\n\nYou can pre-populate form fields when the page first loads using a query string, e.g. ``/mydatabase/add_name?name=Prepopulated``. The user will have to submit the form to execute the query.\n\nIf you specify a query in ``\"on_success_message_sql\"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well.\n\n.. _canned_queries_magic_parameters:\n\nMagic parameters\n~~~~~~~~~~~~~~~~\n\nNamed parameters that start with an underscore are special: they can be used to automatically add values created by Datasette that are not contained in the incoming form fields or query string.\n\nThese magic parameters are only supported for canned queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query.\n\nAvailable magic parameters are:\n\n``_actor_*`` - e.g. ``_actor_id``, ``_actor_name``\n    Fields from the currently authenticated :ref:`authentication_actor`.\n\n``_header_*`` - e.g. ``_header_user_agent``\n    Header from the incoming HTTP request. The key should be in lower case and with hyphens converted to underscores e.g. ``_header_user_agent`` or ``_header_accept_language``.\n\n``_cookie_*`` - e.g. ``_cookie_lang``\n    The value of the incoming cookie of that name.\n\n``_now_epoch``\n    The number of seconds since the Unix epoch.\n\n``_now_date_utc``\n    The date in UTC, e.g. ``2020-06-01``\n\n``_now_datetime_utc``\n    The ISO 8601 datetime in UTC, e.g. ``2020-06-24T18:01:07Z``\n\n``_random_chars_*`` - e.g. ``_random_chars_128``\n    A random string of characters of the specified length.\n\nHere's an example configuration that adds a message from the authenticated user, storing various pieces of additional metadata using magic parameters:\n\n.. [[[cog\n    config_example(cog, \"\"\"\n    databases:\n      mydatabase:\n        queries:\n          add_message:\n            allow:\n              id: \"*\"\n            sql: |-\n              INSERT INTO messages (\n                user_id, message, datetime\n              ) VALUES (\n                :_actor_id, :message, :_now_datetime_utc\n              )\n            write: true\n    \"\"\")\n.. ]]]\n\n.. tab:: datasette.yaml\n\n    .. code-block:: yaml\n\n\n        databases:\n          mydatabase:\n            queries:\n              add_message:\n                allow:\n                  id: \"*\"\n                sql: |-\n                  INSERT INTO messages (\n                    user_id, message, datetime\n                  ) VALUES (\n                    :_actor_id, :message, :_now_datetime_utc\n                  )\n                write: true\n\n\n.. tab:: datasette.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"mydatabase\": {\n              \"queries\": {\n                \"add_message\": {\n                  \"allow\": {\n                    \"id\": \"*\"\n                  },\n                  \"sql\": \"INSERT INTO messages (\\n  user_id, message, datetime\\n) VALUES (\\n  :_actor_id, :message, :_now_datetime_utc\\n)\",\n                  \"write\": true\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nThe form presented at ``/mydatabase/add_message`` will have just a field for ``message`` - the other parameters will be populated by the magic parameter mechanism.\n\nAdditional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.\n\n.. _canned_queries_json_api:\n\nJSON API for writable canned queries\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nWritable canned queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON.\n\nTo submit JSON to a writable canned query, encode key/value parameters as a JSON document::\n\n    POST /mydatabase/add_message\n\n    {\"message\": \"Message goes here\"}\n\nYou can also continue to submit data using regular form encoding, like so::\n\n    POST /mydatabase/add_message\n\n    message=Message+goes+here\n\nThere are three options for specifying that you would like the response to your request to return JSON data, as opposed to an HTTP redirect to another page.\n\n- Set an ``Accept: application/json`` header on your request\n- Include ``?_json=1`` in the URL that you POST to\n- Include ``\"_json\": 1`` in your JSON body, or ``&_json=1`` in your form encoded body\n\nThe JSON response will look like this:\n\n.. code-block:: json\n\n    {\n        \"ok\": true,\n        \"message\": \"Query executed, 1 row affected\",\n        \"redirect\": \"/data/add_name\"\n    }\n\nThe ``\"message\"`` and ``\"redirect\"`` values here will take into account ``on_success_message``, ``on_success_message_sql``,  ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.\n\n.. _pagination:\n\nPagination\n----------\n\nDatasette's default table pagination is designed to be extremely efficient. SQL OFFSET/LIMIT pagination can have a significant performance penalty once you get into multiple thousands of rows, as each page still requires the database to scan through every preceding row to find the correct offset.\n\nWhen paginating through tables, Datasette instead orders the rows in the table by their primary key and performs a WHERE clause against the last seen primary key for the previous page. For example:\n\n.. code-block:: sql\n\n    select rowid, * from Tree_List where rowid > 200 order by rowid limit 101\n\nThis represents page three for this particular table, with a page size of 100.\n\nNote that we request 101 items in the limit clause rather than 100. This allows us to detect if we are on the last page of the results: if the query returns less than 101 rows we know we have reached the end of the pagination set. Datasette will only return the first 100 rows - the 101st is used purely to detect if there should be another page.\n\nSince the where clause acts against the index on the primary key, the query is extremely fast even for records that are a long way into the overall pagination set.\n\n.. _cross_database_queries:\n\nCross-database queries\n----------------------\n\nSQLite has the ability to run queries that join across multiple databases. Up to ten databases can be attached to a single SQLite connection and queried together.\n\nDatasette can execute joins across multiple databases if it is started with the ``--crossdb`` option::\n\n    datasette fixtures.db extra_database.db --crossdb\n\nIf it is started in this way, the ``/_memory`` page can be used to execute queries that join across multiple databases.\n\nReferences to tables in attached databases should be preceded by the database name and a period.\n\nFor example, this query will show a list of tables across both of the above databases:\n\n.. code-block:: sql\n\n    select\n      'fixtures' as database, *\n    from\n      [fixtures].sqlite_master\n    union\n    select\n      'extra_database' as database, *\n    from\n      [extra_database].sqlite_master\n\n`Try that out here <https://latest.datasette.io/_memory?sql=select%0D%0A++%27fixtures%27+as+database%2C+*%0D%0Afrom%0D%0A++%5Bfixtures%5D.sqlite_master%0D%0Aunion%0D%0Aselect%0D%0A++%27extra_database%27+as+database%2C+*%0D%0Afrom%0D%0A++%5Bextra_database%5D.sqlite_master>`__.\n"
  },
  {
    "path": "docs/testing_plugins.rst",
    "content": ".. _testing_plugins:\n\nTesting plugins\n===============\n\nWe recommend using `pytest <https://docs.pytest.org/>`__ to write automated tests for your plugins.\n\nIf you use the template described in :ref:`writing_plugins_cookiecutter` your plugin will start with a single test in your ``tests/`` directory that looks like this:\n\n.. code-block:: python\n\n    from datasette.app import Datasette\n    import pytest\n\n\n    @pytest.mark.asyncio\n    async def test_plugin_is_installed():\n        datasette = Datasette(memory=True)\n        response = await datasette.client.get(\"/-/plugins.json\")\n        assert response.status_code == 200\n        installed_plugins = {p[\"name\"] for p in response.json()}\n        assert (\n            \"datasette-plugin-template-demo\"\n            in installed_plugins\n        )\n\n\nThis 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.\n\nThis test also uses the `pytest-asyncio <https://pypi.org/project/pytest-asyncio/>`__ package to add support for ``async def`` test functions running under pytest.\n\nYou can install these packages like so::\n\n    pip install pytest pytest-asyncio\n\nIf you are building an installable package you can add them as test dependencies to your ``pyproject.toml`` file like this:\n\n.. code-block:: toml\n\n    [project]\n    name = \"datasette-my-plugin\"\n    # ...\n\n    [project.optional-dependencies]\n    test = [\"pytest\", \"pytest-asyncio\"]\n\nYou can then install the test dependencies like so::\n\n    pip install -e '.[test]'\n\nThen run the tests using pytest like so::\n\n    pytest\n\n.. _testing_plugins_datasette_test_instance:\n\nSetting up a Datasette test instance\n------------------------------------\n\nThe above example shows the easiest way to start writing tests against a Datasette instance:\n\n.. code-block:: python\n\n    from datasette.app import Datasette\n    import pytest\n\n\n    @pytest.mark.asyncio\n    async def test_plugin_is_installed():\n        datasette = Datasette(memory=True)\n        response = await datasette.client.get(\"/-/plugins.json\")\n        assert response.status_code == 200\n\nCreating 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:\n\n.. code-block:: python\n\n    datasette = Datasette(memory=True)\n    await datasette.invoke_startup()\n\nThis method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls.\n\nIf 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.\n\n.. _testing_datasette_client:\n\nUsing datasette.client in tests\n-------------------------------\n\nThe :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.\n\nA simple test looks like this:\n\n.. literalinclude:: ../tests/test_docs.py\n   :language: python\n   :start-after: # -- start test_homepage --\n   :end-before: # -- end test_homepage --\n\nOr for a JSON API:\n\n.. literalinclude:: ../tests/test_docs.py\n   :language: python\n   :start-after: # -- start test_actor_is_null --\n   :end-before: # -- end test_actor_is_null --\n\nTo make requests as an authenticated actor, create a signed ``ds_cookie`` using the ``datasette.client.actor_cookie()`` helper function and pass it in ``cookies=`` like this:\n\n.. literalinclude:: ../tests/test_docs.py\n   :language: python\n   :start-after: # -- start test_signed_cookie_actor --\n   :end-before: # -- end test_signed_cookie_actor --\n\n.. _testing_plugins_pdb:\n\nUsing pdb for errors thrown inside Datasette\n--------------------------------------------\n\nIf an exception occurs within Datasette itself during a test, the response returned to your plugin will have a ``response.status_code`` value of 500.\n\nYou can add ``pdb=True`` to the ``Datasette`` constructor to drop into a Python debugger session inside your test run instead of getting back a 500 response code. This is equivalent to running the ``datasette`` command-line tool with the ``--pdb`` option.\n\nHere's what that looks like in a test function:\n\n.. code-block:: python\n\n    def test_that_opens_the_debugger_or_errors():\n        ds = Datasette([db_path], pdb=True)\n        response = await ds.client.get(\"/\")\n\nIf you use this pattern you will need to run ``pytest`` with the ``-s`` option to avoid capturing stdin/stdout in order to interact with the debugger prompt.\n\n.. _testing_plugins_fixtures:\n\nUsing pytest fixtures\n---------------------\n\n`Pytest fixtures <https://docs.pytest.org/en/stable/fixture.html>`__ can be used to create initial testable objects which can then be used by multiple tests.\n\nA common pattern for Datasette plugins is to create a fixture which sets up a temporary test database and wraps it in a Datasette instance.\n\nHere's an example that uses the `sqlite-utils library <https://sqlite-utils.datasette.io/en/stable/python-api.html>`__ to populate a temporary test database. It also sets the title of that table using a simulated ``metadata.json`` configuration:\n\n.. code-block:: python\n\n    from datasette.app import Datasette\n    import pytest\n    import sqlite_utils\n\n\n    @pytest.fixture(scope=\"session\")\n    def datasette(tmp_path_factory):\n        db_directory = tmp_path_factory.mktemp(\"dbs\")\n        db_path = db_directory / \"test.db\"\n        db = sqlite_utils.Database(db_path)\n        db[\"dogs\"].insert_all(\n            [\n                {\"id\": 1, \"name\": \"Cleo\", \"age\": 5},\n                {\"id\": 2, \"name\": \"Pancakes\", \"age\": 4},\n            ],\n            pk=\"id\",\n        )\n        datasette = Datasette(\n            [db_path],\n            metadata={\n                \"databases\": {\n                    \"test\": {\n                        \"tables\": {\n                            \"dogs\": {\"title\": \"Some dogs\"}\n                        }\n                    }\n                }\n            },\n        )\n        return datasette\n\n\n    @pytest.mark.asyncio\n    async def test_example_table_json(datasette):\n        response = await datasette.client.get(\n            \"/test/dogs.json?_shape=array\"\n        )\n        assert response.status_code == 200\n        assert response.json() == [\n            {\"id\": 1, \"name\": \"Cleo\", \"age\": 5},\n            {\"id\": 2, \"name\": \"Pancakes\", \"age\": 4},\n        ]\n\n\n    @pytest.mark.asyncio\n    async def test_example_table_html(datasette):\n        response = await datasette.client.get(\"/test/dogs\")\n        assert \">Some dogs</h1>\" in response.text\n\nHere the ``datasette()`` function defines the fixture, which is than automatically passed to the two test functions based on pytest automatically matching their ``datasette`` function parameters.\n\nThe ``@pytest.fixture(scope=\"session\")`` line here ensures the fixture is reused for the full ``pytest`` execution session. This means that the temporary database file will be created once and reused for each test.\n\nIf you want to create that test database repeatedly for every individual test function, write the fixture function like this instead. You may want to do this if your plugin modifies the database contents in some way:\n\n.. code-block:: python\n\n    @pytest.fixture\n    def datasette(tmp_path_factory):\n        # This fixture will be executed repeatedly for every test\n        ...\n\n.. _testing_plugins_pytest_httpx:\n\nTesting outbound HTTP calls with pytest-httpx\n---------------------------------------------\n\nIf 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.\n\nThe `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.\n\nTo avoid breaking your tests, you can return ``[\"localhost\"]`` from the ``non_mocked_hosts()`` fixture.\n\nAs an example, here's a very simple plugin which executes an HTTP response and returns the resulting content:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.utils.asgi import Response\n    import httpx\n\n\n    @hookimpl\n    def register_routes():\n        return [\n            (r\"^/-/fetch-url$\", fetch_url),\n        ]\n\n\n    async def fetch_url(datasette, request):\n        if request.method == \"GET\":\n            return Response.html(\"\"\"\n                <form action=\"/-/fetch-url\" method=\"post\">\n                <input type=\"hidden\" name=\"csrftoken\" value=\"{}\">\n                <input name=\"url\"><input type=\"submit\">\n            </form>\"\"\".format(request.scope[\"csrftoken\"]()))\n        vars = await request.post_vars()\n        url = vars[\"url\"]\n        return Response.text(httpx.get(url).text)\n\nHere's a test for that plugin that mocks the HTTPX outbound request:\n\n.. code-block:: python\n\n    from datasette.app import Datasette\n    import pytest\n\n\n    @pytest.fixture\n    def non_mocked_hosts():\n        # This ensures httpx-mock will not affect Datasette's own\n        # httpx calls made in the tests by datasette.client:\n        return [\"localhost\"]\n\n\n    async def test_outbound_http_call(httpx_mock):\n        httpx_mock.add_response(\n            url=\"https://www.example.com/\",\n            text=\"Hello world\",\n        )\n        datasette = Datasette([], memory=True)\n        response = await datasette.client.post(\n            \"/-/fetch-url\",\n            data={\"url\": \"https://www.example.com/\"},\n        )\n        assert response.text == \"Hello world\"\n\n        outbound_request = httpx_mock.get_request()\n        assert (\n            outbound_request.url == \"https://www.example.com/\"\n        )\n\n.. _testing_plugins_register_in_test:\n\nRegistering a plugin for the duration of a test\n-----------------------------------------------\n\nWhen writing tests for plugins you may find it useful to register a test plugin just for the duration of a single test. You can do this using ``datasette.pm.register()`` and ``datasette.pm.unregister()`` like this:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    from datasette.app import Datasette\n    import pytest\n\n\n    @pytest.mark.asyncio\n    async def test_using_test_plugin():\n        class TestPlugin:\n            __name__ = \"TestPlugin\"\n\n            # Use hookimpl and method names to register hooks\n            @hookimpl\n            def register_routes(self):\n                return [\n                    (r\"^/error$\", lambda: 1 / 0),\n                ]\n\n        datasette = Datasette()\n        try:\n            # The test implementation goes here\n            datasette.pm.register(TestPlugin(), name=\"undo\")\n            response = await datasette.client.get(\"/error\")\n            assert response.status_code == 500\n        finally:\n            datasette.pm.unregister(name=\"undo\")\n\nTo reuse the same temporary plugin in multiple tests, you can register it inside a fixture in your ``conftest.py`` file like this:\n\n.. literalinclude:: ../tests/test_docs_plugins.py\n   :language: python\n   :start-after: # -- start datasette_with_plugin_fixture --\n   :end-before: # -- end datasette_with_plugin_fixture --\n\nNote the ``yield`` statement here - this ensures that the ``finally:`` block that unregisters the plugin is executed only after the test function itself has completed.\n\nThen in a test:\n\n.. literalinclude:: ../tests/test_docs_plugins.py\n   :language: python\n   :start-after: # -- start datasette_with_plugin_test --\n   :end-before: # -- end datasette_with_plugin_test --\n"
  },
  {
    "path": "docs/upgrade-1.0a20.md",
    "content": "---\norphan: true\n---\n\n# Datasette 1.0a20 plugin upgrade guide\n\nDatasette 1.0a20 makes some breaking changes to Datasette's permission system. Plugins need to be updated if they use **any of the following**:\n\n- The `register_permissions()` plugin  hook - this should be replaced with `register_actions`\n- The `permission_allowed()` plugin hook - this should be upgraded to use `permission_resources_sql()`.\n- The `datasette.permission_allowed()` internal method - this should be replaced with `datasette.allowed()`\n- Logic that grants access to the `\"root\"` actor can be removed.\n\n## Permissions are now actions\n\nThe `register_permissions()` hook shoud be replaced with `register_actions()`.\n\nOld code:\n\n```python\n@hookimpl\ndef register_permissions(datasette):\n    return [\n        Permission(\n            name=\"explain-sql\",\n            abbr=None,\n            description=\"Can explain SQL queries\",\n            takes_database=True,\n            takes_resource=False,\n            default=False,\n        ),\n        Permission(\n            name=\"annotate-rows\",\n            abbr=None,\n            description=\"Can annotate rows\",\n            takes_database=True,\n            takes_resource=True,\n            default=False,\n        ),\n        Permission(\n            name=\"view-debug-info\",\n            abbr=None,\n            description=\"Can view debug information\",\n            takes_database=False,\n            takes_resource=False,\n            default=False,\n        ),\n    ]\n```\nThe new `Action` does not have a `default=` parameter.\n\nHere's the equivalent new code:\n\n```python\nfrom datasette import hookimpl\nfrom datasette.permissions import Action\nfrom datasette.resources import DatabaseResource, TableResource\n\n@hookimpl\ndef register_actions(datasette):\n    return [\n        Action(\n            name=\"explain-sql\",\n            description=\"Explain SQL queries\",\n            resource_class=DatabaseResource,\n        ),\n        Action(\n            name=\"annotate-rows\",\n            description=\"Annotate rows\",\n            resource_class=TableResource,\n        ),\n        Action(\n            name=\"view-debug-info\",\n            description=\"View debug information\",\n        ),\n    ]\n```\nThe `abbr=` is now optional and defaults to `None`.\n\nFor actions that apply to specific resources (like databases or tables), specify the `resource_class` instead of `takes_parent` and `takes_child`. Note that `view-debug-info` does not specify a `resource_class` because it applies globally.\n\n## permission_allowed() hook is replaced by permission_resources_sql()\n\nThe following old code:\n```python\n@hookimpl\ndef permission_allowed(action):\n    if action == \"permissions-debug\":\n        return True\n```\nCan be replaced by:\n```python\nfrom datasette.permissions import PermissionSQL\n\n@hookimpl\ndef permission_resources_sql(action):\n    return PermissionSQL.allow(reason=\"datasette-allow-permissions-debug\")\n```\nA `.deny(reason=\"\")` class method is also available.\n\nFor more complex permission checks consult the documentation for that plugin hook:\n<https://docs.datasette.io/en/latest/plugin_hooks.html#permission-resources-sql-datasette-actor-action>\n\n## Using datasette.allowed() to check permissions instead of datasette.permission_allowed()\n\nThe internal method `datasette.permission_allowed()` has been replaced by `datasette.allowed()`.\n\nThe old method looked like this:\n```python\ncan_debug = await datasette.permission_allowed(\n    request.actor,\n    \"view-debug-info\",\n)\ncan_explain_sql = await datasette.permission_allowed(\n    request.actor,\n    \"explain-sql\",\n    resource=\"database_name\",\n)\ncan_annotate_rows = await datasette.permission_allowed(\n    request.actor,\n    \"annotate-rows\",\n    resource=(database_name, table_name),\n)\n```\nNote the confusing design here where `resource` could be either a string or a tuple depending on the permission being checked.\n\nThe new keyword-only design makes this a lot more clear:\n```python\nfrom datasette.resources import DatabaseResource, TableResource\ncan_debug = await datasette.allowed(\n    actor=request.actor,\n    action=\"view-debug-info\",\n)\ncan_explain_sql = await datasette.allowed(\n    actor=request.actor,\n    action=\"explain-sql\",\n    resource=DatabaseResource(database_name),\n)\ncan_annotate_rows = await datasette.allowed(\n    actor=request.actor,\n    action=\"annotate-rows\",\n    resource=TableResource(database_name, table_name),\n)\n```\n\n## Root user checks are no longer necessary\n\nSome plugins would introduce their own custom permission and then ensure the `\"root\"` actor had access to it using a pattern like this:\n\n```python\n@hookimpl\ndef register_permissions(datasette):\n    return [\n        Permission(\n            name=\"upload-dbs\",\n            abbr=None,\n            description=\"Upload SQLite database files\",\n            takes_database=False,\n            takes_resource=False,\n            default=False,\n        )\n    ]\n\n\n@hookimpl\ndef permission_allowed(actor, action):\n    if action == \"upload-dbs\" and actor and actor.get(\"id\") == \"root\":\n        return True\n```\nThis is no longer necessary in Datasette 1.0a20 - the `\"root\"` actor automatically has all permissions when Datasette is started with the `datasette --root` option.\n\nThe `permission_allowed()` hook in this example can be entirely removed.\n\n### Root-enabled instances during testing\n\nWhen writing tests that exercise root-only functionality, make sure to set `datasette.root_enabled = True` on the `Datasette` instance. Root permissions are only granted automatically when Datasette is started with `datasette --root` or when the flag is enabled directly in tests.\n\n## Target the new APIs exclusively\n\nDatasette 1.0a20’s permission system is substantially different from previous releases. Attempting to keep plugin code compatible with both the old `permission_allowed()` and the new `allowed()` interfaces leads to brittle workarounds. Prefer to adopt the 1.0a20 APIs (`register_actions`, `permission_resources_sql()`, and `datasette.allowed()`) outright and drop legacy fallbacks.\n\n## Fixing async with httpx.AsyncClient(app=app)\n\nSome older plugins may use the following pattern in their tests, which is no longer supported:\n```python\napp = Datasette([], memory=True).app()\nasync with httpx.AsyncClient(app=app) as client:\n    response = await client.get(\"http://localhost/path\")\n```\nThe new pattern is to use `ds.client` like this:\n```python\nds = Datasette([], memory=True)\nresponse = await ds.client.get(\"/path\")\n```\n\n## Migrating from metadata= to config=\n\nDatasette 1.0 separates metadata (titles, descriptions, licenses) from configuration (settings, plugins, queries, permissions). Plugin tests and code need to be updated accordingly.\n\n### Update test constructors\n\nOld code:\n```python\nds = Datasette(\n    memory=True,\n    metadata={\n        \"databases\": {\n            \"_memory\": {\"queries\": {\"my_query\": {\"sql\": \"select 1\", \"title\": \"My Query\"}}}\n        },\n        \"plugins\": {\n            \"my-plugin\": {\"setting\": \"value\"}\n        }\n    }\n)\n```\n\nNew code:\n```python\nds = Datasette(\n    memory=True,\n    config={\n        \"databases\": {\n            \"_memory\": {\"queries\": {\"my_query\": {\"sql\": \"select 1\", \"title\": \"My Query\"}}}\n        },\n        \"plugins\": {\n            \"my-plugin\": {\"setting\": \"value\"}\n        }\n    }\n)\n```\n\n### Update datasette.metadata() calls\n\nThe `datasette.metadata()` method has been removed. Use these methods instead:\n\nOld code:\n```python\ntry:\n    title = datasette.metadata(database=database)[\"queries\"][query_name][\"title\"]\nexcept (KeyError, TypeError):\n    pass\n```\n\nNew code:\n```python\ntry:\n    query_info = await datasette.get_canned_query(database, query_name, request.actor)\n    if query_info and \"title\" in query_info:\n        title = query_info[\"title\"]\nexcept (KeyError, TypeError):\n    pass\n```\n\n### Update render functions to async\n\nIf your plugin's render function needs to call `datasette.get_canned_query()` or other async Datasette methods, it must be declared as async:\n\nOld code:\n```python\ndef render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):\n    # ...\n    if query_name:\n        title = datasette.metadata(database=database)[\"queries\"][query_name][\"title\"]\n```\n\nNew code:\n```python\nasync def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):\n    # ...\n    if query_name:\n        query_info = await datasette.get_canned_query(database, query_name, request.actor)\n        if query_info and \"title\" in query_info:\n            title = query_info[\"title\"]\n```\n\n### Update query URLs in tests\n\nDatasette now redirects `?sql=` parameters from database pages to the query view:\n\nOld code:\n```python\nresponse = await ds.client.get(\"/_memory.atom?sql=select+1\")\n```\n\nNew code:\n```python\nresponse = await ds.client.get(\"/_memory/-/query.atom?sql=select+1\")\n```\n"
  },
  {
    "path": "docs/upgrade_guide.md",
    "content": "(upgrade_guide)=\n# Upgrade guide\n\n(upgrade_guide_v1)=\n## Datasette 0.X -> 1.0\n\nThis section reviews breaking changes Datasette ``1.0`` has when upgrading from a ``0.XX`` version. For new features that ``1.0`` offers, see the {ref}`changelog`.\n\n(upgrade_guide_v1_sql_queries)=\n### New URL for SQL queries\n\nPrior to ``1.0a14`` the URL for executing a SQL query looked like this:\n\n```text\n/databasename?sql=select+1\n# Or for JSON:\n/databasename.json?sql=select+1\n```\n\nThis endpoint served two purposes: without a ``?sql=`` it would list the tables in the database, but with that option it would return results of a query instead.\n\nThe URL for executing a SQL query now looks like this:\n\n```text\n/databasename/-/query?sql=select+1\n# Or for JSON:\n/databasename/-/query.json?sql=select+1\n```\n\n**This isn't a breaking change.** API calls to the older ``/databasename?sql=...`` endpoint will redirect to the new ``databasename/-/query?sql=...`` endpoint. Upgrading to the new URL is recommended to avoid the overhead of the additional redirect.\n\n(upgrade_guide_v1_metadata)=\n### Metadata changes\n\nMetadata was completely revamped for Datasette 1.0. There are a number of related breaking changes, from the ``metadata.yaml`` file to Python APIs, that you'll need to consider when upgrading.\n\n(upgrade_guide_v1_metadata_split)=\n#### ``metadata.yaml`` split into ``datasette.yaml``\n\nBefore Datasette 1.0, the ``metadata.yaml`` file became a kitchen sink if a mix of metadata, configuration, and settings. Now ``metadata.yaml`` is strictly for metadata (ex title and descriptions of database and tables, licensing info, etc). Other settings have been moved to a ``datasette.yml`` configuration file, described in {ref}`configuration`.\n\nTo start Datasette with both metadata and configuration files, run it like this:\n\n```bash\ndatasette --metadata metadata.yaml --config datasette.yaml\n# Or the shortened version:\ndatasette -m metadata.yml -c datasette.yml\n```\n\n(upgrade_guide_v1_metadata_upgrade)=\n#### Upgrading an existing ``metadata.yaml`` file\n\nThe [datasette-upgrade plugin](https://github.com/datasette/datasette-upgrade) can be used to split a Datasette 0.x.x ``metadata.yaml`` (or ``.json``) file into separate ``metadata.yaml`` and ``datasette.yaml`` files. First, install the plugin:\n\n```bash\ndatasette install datasette-upgrade\n```\n\nThen run it like this to produce the two new files:\n\n```bash\ndatasette upgrade metadata-to-config metadata.json -m metadata.yml -c datasette.yml\n```\n\n#### Metadata \"fallback\" has been removed\n\nCertain keys in metadata like ``license`` used to \"fallback\" up the chain of ownership.\nFor example, if you set an ``MIT`` to a database and a table within that database did not have a specified license, then that table would inherit an ``MIT`` license.\n\nThis behavior has been removed in Datasette 1.0. Now license fields must be placed on all items, including individual databases and tables.\n\n(upgrade_guide_v1_metadata_removed)=\n#### The ``get_metadata()`` plugin hook has been removed\n\nIn Datasette ``0.x`` plugins could implement a ``get_metadata()`` plugin hook to customize how metadata was retrieved for different instances, databases and tables.\n\nThis hook could be inefficient, since some pages might load metadata for many different items (to list a large number of tables, for example) which could result in a large number of calls to potentially expensive plugin hook implementations.\n\nAs of Datasette ``1.0a14`` (2024-08-05), the ``get_metadata()`` hook has been deprecated:\n\n```python\n# ❌ DEPRECATED in Datasette 1.0\n@hookimpl\ndef get_metadata(datasette, key, database, table):\n    pass\n```\n\nInstead, plugins are encouraged to interact directly with Datasette's in-memory metadata tables in SQLite using the following methods on the {ref}`internals_datasette`:\n\n- {ref}`get_instance_metadata() <datasette_get_instance_metadata>` and {ref}`set_instance_metadata() <datasette_set_instance_metadata>`\n- {ref}`get_database_metadata() <datasette_get_database_metadata>` and {ref}`set_database_metadata() <datasette_set_database_metadata>`\n- {ref}`get_resource_metadata() <datasette_get_resource_metadata>` and {ref}`set_resource_metadata() <datasette_set_resource_metadata>`\n- {ref}`get_column_metadata() <datasette_get_column_metadata>` and {ref}`set_column_metadata() <datasette_set_column_metadata>`\n\nA plugin that stores or calculates its own metadata can implement the {ref}`plugin_hook_startup` hook to populate those items on startup, and then call those methods while it is running to persist any new metadata changes.\n\n(upgrade_guide_v1_metadata_json_removed)=\n#### The ``/metadata.json`` endpoint has been removed\n\nAs of Datasette ``1.0a14``, the root level ``/metadata.json`` endpoint has been removed. Metadata for tables will become available through currently in-development extras in a future alpha.\n\n(upgrade_guide_v1_metadata_method_removed)=\n#### The ``metadata()`` method on the Datasette class has been removed\n\nAs of Datasette ``1.0a14``, the ``.metadata()`` method on the Datasette Python API has been removed.\n\nInstead, one should use the following methods on a Datasette class:\n\n- {ref}`get_instance_metadata() <datasette_get_instance_metadata>`\n- {ref}`get_database_metadata() <datasette_get_database_metadata>`\n- {ref}`get_resource_metadata() <datasette_get_resource_metadata>`\n- {ref}`get_column_metadata() <datasette_get_column_metadata>`\n\n(upgrade_guide_v1_a20)=\n```{include} upgrade-1.0a20.md\n:heading-offset: 1\n```\n\n(upgrade_guide_v1_a25)=\n### Datasette 1.0a25: `create_token()` signature change\n\n`datasette.create_token()` is now an `async` method (previously it was synchronous). The `restrict_all`, `restrict_database`, and `restrict_resource` keyword arguments have been replaced by a single `restrictions` parameter that accepts a {ref}`TokenRestrictions <TokenRestrictions>` object.\n\nOld code:\n\n```python\ntoken = datasette.create_token(\n    actor_id=\"user1\",\n    restrict_all=[\"view-instance\", \"view-table\"],\n    restrict_database={\"docs\": [\"view-query\"]},\n    restrict_resource={\n        \"docs\": {\n            \"attachments\": [\"insert-row\", \"update-row\"]\n        }\n    },\n)\n```\n\nNew code:\n\n```python\nfrom datasette.tokens import TokenRestrictions\n\ntoken = await datasette.create_token(\n    actor_id=\"user1\",\n    restrictions=(\n        TokenRestrictions()\n        .allow_all(\"view-instance\")\n        .allow_all(\"view-table\")\n        .allow_database(\"docs\", \"view-query\")\n        .allow_resource(\"docs\", \"attachments\", \"insert-row\")\n        .allow_resource(\"docs\", \"attachments\", \"update-row\")\n    ),\n)\n```\n\nThe `datasette create-token` CLI command is unchanged.\n"
  },
  {
    "path": "docs/writing_plugins.rst",
    "content": ".. _writing_plugins:\n\nWriting plugins\n===============\n\nYou can write one-off plugins that apply to just one Datasette instance, or you can write plugins which can be installed using ``pip`` and can be shipped to the Python Package Index (`PyPI <https://pypi.org/>`__) for other people to install.\n\nWant to start by looking at an example? The `Datasette plugins directory <https://datasette.io/plugins>`__ lists more than 90 open source plugins with code you can explore. The :ref:`plugin hooks <plugin_hooks>` page includes links to example plugins for each of the documented hooks.\n\n.. _writing_plugins_tracing:\n\nTracing plugin hooks\n--------------------\n\nThe ``DATASETTE_TRACE_PLUGINS`` environment variable turns on detailed tracing showing exactly which hooks are being run. This can be useful for understanding how Datasette is using your plugin.\n\n.. code-block:: bash\n\n    DATASETTE_TRACE_PLUGINS=1 datasette mydb.db\n\nExample output::\n\n    actor_from_request:\n    {   'datasette': <datasette.app.Datasette object at 0x100bc7220>,\n        'request': <asgi.Request method=\"GET\" url=\"http://127.0.0.1:4433/\">}\n    Hook implementations:\n    [   <HookImpl plugin_name='codespaces', plugin=<module 'datasette_codespaces' from '.../site-packages/datasette_codespaces/__init__.py'>>,\n        <HookImpl plugin_name='datasette.actor_auth_cookie', plugin=<module 'datasette.actor_auth_cookie' from '.../datasette/datasette/actor_auth_cookie.py'>>,\n        <HookImpl plugin_name='datasette.default_permissions', plugin=<module 'datasette.default_permissions' from '.../datasette/default_permissions.py'>>]\n    Results:\n    [{'id': 'root'}]\n\n\n.. _writing_plugins_one_off:\n\nWriting one-off plugins\n-----------------------\n\nThe quickest way to start writing a plugin is to create a ``my_plugin.py`` file and drop it into your ``plugins/`` directory. Here is an example plugin, which adds a new custom SQL function called ``hello_world()`` which takes no arguments and returns the string ``Hello world!``.\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def prepare_connection(conn):\n        conn.create_function(\n            \"hello_world\", 0, lambda: \"Hello world!\"\n        )\n\nIf you save this in ``plugins/my_plugin.py`` you can then start Datasette like this::\n\n    datasette serve mydb.db --plugins-dir=plugins/\n\nNow you can navigate to http://localhost:8001/mydb and run this SQL::\n\n    select hello_world();\n\nTo see the output of your plugin.\n\n.. _writing_plugins_cookiecutter:\n\nStarting an installable plugin using cookiecutter\n-------------------------------------------------\n\nPlugins that can be installed should be written as Python packages using a ``setup.py`` file.\n\nThe quickest way to start writing one an installable plugin is to use the `datasette-plugin <https://github.com/simonw/datasette-plugin>`__ cookiecutter template. This creates a new plugin structure for you complete with an example test and GitHub Actions workflows for testing and publishing your plugin.\n\n`Install cookiecutter <https://cookiecutter.readthedocs.io/en/stable/installation.html>`__ and then run this command to start building a plugin using the template::\n\n    cookiecutter gh:simonw/datasette-plugin\n\nRead `a cookiecutter template for writing Datasette plugins <https://simonwillison.net/2020/Jun/20/cookiecutter-plugins/>`__ for more information about this template.\n\n.. _writing_plugins_packaging:\n\nPackaging a plugin\n------------------\n\nPlugins can be packaged using Python setuptools. You can see an example of a packaged plugin at https://github.com/simonw/datasette-plugin-demos\n\nThe example consists of two files: a ``setup.py`` file that defines the plugin:\n\n.. code-block:: python\n\n    from setuptools import setup\n\n    VERSION = \"0.1\"\n\n    setup(\n        name=\"datasette-plugin-demos\",\n        description=\"Examples of plugins for Datasette\",\n        author=\"Simon Willison\",\n        url=\"https://github.com/simonw/datasette-plugin-demos\",\n        license=\"Apache License, Version 2.0\",\n        version=VERSION,\n        py_modules=[\"datasette_plugin_demos\"],\n        entry_points={\n            \"datasette\": [\n                \"plugin_demos = datasette_plugin_demos\"\n            ]\n        },\n        install_requires=[\"datasette\"],\n    )\n\nAnd a Python module file, ``datasette_plugin_demos.py``, that implements the plugin:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n    import random\n\n\n    @hookimpl\n    def prepare_jinja2_environment(env):\n        env.filters[\"uppercase\"] = lambda u: u.upper()\n\n\n    @hookimpl\n    def prepare_connection(conn):\n        conn.create_function(\n            \"random_integer\", 2, random.randint\n        )\n\nHaving built a plugin in this way you can turn it into an installable package using the following command::\n\n    python3 setup.py sdist\n\nThis will create a ``.tar.gz`` file in the ``dist/`` directory.\n\nYou can then install your new plugin into a Datasette virtual environment or Docker container using ``pip``::\n\n    pip install datasette-plugin-demos-0.1.tar.gz\n\nTo learn how to upload your plugin to `PyPI <https://pypi.org/>`_ for use by other people, read the PyPA guide to `Packaging and distributing projects <https://packaging.python.org/tutorials/distributing-packages/>`_.\n\n.. _writing_plugins_static_assets:\n\nStatic assets\n-------------\n\nIf your plugin has a ``static/`` directory, Datasette will automatically configure itself to serve those static assets from the following path::\n\n    /-/static-plugins/NAME_OF_PLUGIN_PACKAGE/yourfile.js\n\nUse the ``datasette.urls.static_plugins(plugin_name, path)`` method to generate URLs to that asset that take the ``base_url`` setting into account, see :ref:`internals_datasette_urls`.\n\nTo bundle the static assets for a plugin in the package that you publish to PyPI, add the following to the plugin's ``setup.py``:\n\n.. code-block:: python\n\n        package_data = (\n            {\n                \"datasette_plugin_name\": [\n                    \"static/plugin.js\",\n                ],\n            },\n        )\n\nWhere ``datasette_plugin_name`` is the name of the plugin package (note that it uses underscores, not hyphens) and ``static/plugin.js`` is the path within that package to the static file.\n\n`datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`__ is a useful example of a plugin that includes packaged static assets in this way.\n\nSee :ref:`customization_css` for tips on writing CSS that is compatible with Datasette's default CSS, including details of the ``core`` class for applying Datasette's default form element styles.\n\n.. _writing_plugins_custom_templates:\n\nCustom templates\n----------------\n\nIf your plugin has a ``templates/`` directory, Datasette will attempt to load templates from that directory before it uses its own default templates.\n\nThe priority order for template loading is:\n\n* templates from the ``--template-dir`` argument, if specified\n* templates from the ``templates/`` directory in any installed plugins\n* default templates that ship with Datasette\n\nSee :ref:`customization` for more details on how to write custom templates, including which filenames to use to customize which parts of the Datasette UI.\n\nTemplates should be bundled for distribution using the same ``package_data`` mechanism in ``setup.py`` described for static assets above, for example:\n\n.. code-block:: python\n\n        package_data = (\n            {\n                \"datasette_plugin_name\": [\n                    \"templates/my_template.html\",\n                ],\n            },\n        )\n\nYou 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.\n\n.. _writing_plugins_configuration:\n\nWriting plugins that accept configuration\n-----------------------------------------\n\nWhen you are writing plugins, you can access plugin configuration like this using the ``datasette plugin_config()`` method. If you know you need plugin configuration for a specific table, you can access it like this::\n\n    plugin_config = datasette.plugin_config(\n        \"datasette-cluster-map\", database=\"sf-trees\", table=\"Street_Tree_List\"\n    )\n\nThis will return the ``{\"latitude_column\": \"lat\", \"longitude_column\": \"lng\"}`` in the above example.\n\nIf there is no configuration for that plugin, the method will return ``None``.\n\nIf it cannot find the requested configuration at the table layer, it will fall back to the database layer and then the root layer. For example, a user may have set the plugin configuration option inside ``datasette.yaml`` like so:\n\n.. [[[cog\n    from metadata_doc import metadata_example\n    metadata_example(cog, {\n        \"databases\": {\n            \"sf-trees\": {\n                \"plugins\": {\n                    \"datasette-cluster-map\": {\n                        \"latitude_column\": \"xlat\",\n                        \"longitude_column\": \"xlng\"\n                    }\n                }\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: metadata.yaml\n\n    .. code-block:: yaml\n\n        databases:\n          sf-trees:\n            plugins:\n              datasette-cluster-map:\n                latitude_column: xlat\n                longitude_column: xlng\n\n\n.. tab:: metadata.json\n\n    .. code-block:: json\n\n        {\n          \"databases\": {\n            \"sf-trees\": {\n              \"plugins\": {\n                \"datasette-cluster-map\": {\n                  \"latitude_column\": \"xlat\",\n                  \"longitude_column\": \"xlng\"\n                }\n              }\n            }\n          }\n        }\n.. [[[end]]]\n\nIn this case, the above code would return that configuration for ANY table within the ``sf-trees`` database.\n\nThe plugin configuration could also be set at the top level of ``datasette.yaml``:\n\n.. [[[cog\n    metadata_example(cog, {\n        \"plugins\": {\n            \"datasette-cluster-map\": {\n                \"latitude_column\": \"xlat\",\n                \"longitude_column\": \"xlng\"\n            }\n        }\n    })\n.. ]]]\n\n.. tab:: metadata.yaml\n\n    .. code-block:: yaml\n\n        plugins:\n          datasette-cluster-map:\n            latitude_column: xlat\n            longitude_column: xlng\n\n\n.. tab:: metadata.json\n\n    .. code-block:: json\n\n        {\n          \"plugins\": {\n            \"datasette-cluster-map\": {\n              \"latitude_column\": \"xlat\",\n              \"longitude_column\": \"xlng\"\n            }\n          }\n        }\n.. [[[end]]]\n\nNow that ``datasette-cluster-map`` plugin configuration will apply to every table in every database.\n\n.. _writing_plugins_designing_urls:\n\nDesigning URLs for your plugin\n------------------------------\n\nYou can register new URL routes within Datasette using the :ref:`plugin_register_routes` plugin hook.\n\nDatasette's default URLs include these:\n\n- ``/dbname`` - database page\n- ``/dbname/tablename`` - table page\n- ``/dbname/tablename/pk`` - row page\n\nSee :ref:`pages` and :ref:`introspection` for more default URL routes.\n\nTo avoid accidentally conflicting with a database file that may be loaded into Datasette, plugins should register URLs using a ``/-/`` prefix. For example, if your plugin adds a new interface for uploading Excel files you might register a URL route like this one:\n\n- ``/-/upload-excel``\n\nTry to avoid registering URLs that clash with other plugins that your users might have installed. There is no central repository of reserved URL paths (yet) but you can review existing plugins by browsing the `plugins directory <https://datasette.io/plugins>`__.\n\nIf your plugin includes functionality that relates to a specific database you could also register a URL route like this:\n\n- ``/dbname/-/upload-excel``\n\nOr for a specific table like this:\n\n- ``/dbname/tablename/-/modify-table-schema``\n\nNote that a row could have a primary key of ``-`` and this URL scheme will still work, because Datasette row pages do not ever have a trailing slash followed by additional path components.\n\n.. _writing_plugins_building_urls:\n\nBuilding URLs within plugins\n----------------------------\n\nPlugins that define their own custom user interface elements may need to link to other pages within Datasette.\n\nThis can be a bit tricky if the Datasette instance is using the :ref:`setting_base_url` configuration setting to run behind a proxy, since that can cause Datasette's URLs to include an additional prefix.\n\nThe ``datasette.urls`` object provides internal methods for correctly generating URLs to different pages within Datasette, taking any ``base_url`` configuration into account.\n\nThis object is exposed in templates as the ``urls`` variable, which can be used like this:\n\n.. code-block:: jinja\n\n    Back to the <a href=\"{{ urls.instance() }}\">Homepage</a>\n\nSee :ref:`internals_datasette_urls` for full details on this object.\n\n.. _writing_plugins_extra_hooks:\n\nPlugins that define new plugin hooks\n------------------------------------\n\nPlugins can define new plugin hooks that other plugins can use to further extend their functionality.\n\n`datasette-graphql <https://github.com/simonw/datasette-graphql>`__ is one example of a plugin that does this. It defines a new hook called ``graphql_extra_fields``, `described here <https://github.com/simonw/datasette-graphql/blob/main/README.md#adding-custom-fields-with-plugins>`__, which other plugins can use to define additional fields that should be included in the GraphQL schema.\n\nTo define additional hooks, add a file to the plugin called ``datasette_your_plugin/hookspecs.py`` with content that looks like this:\n\n.. code-block:: python\n\n    from pluggy import HookspecMarker\n\n    hookspec = HookspecMarker(\"datasette\")\n\n\n    @hookspec\n    def name_of_your_hook_goes_here(datasette):\n        \"Description of your hook.\"\n\nYou should define your own hook name and arguments here, following the documentation for `Pluggy specifications <https://pluggy.readthedocs.io/en/stable/#specs>`__. Make sure to pick a name that is unlikely to clash with hooks provided by any other plugins.\n\nThen, to register your plugin hooks, add the following code to your ``datasette_your_plugin/__init__.py`` file:\n\n.. code-block:: python\n\n    from datasette.plugins import pm\n    from . import hookspecs\n\n    pm.add_hookspecs(hookspecs)\n\nThis will register your plugin hooks as part of the ``datasette`` plugin hook namespace.\n\nWithin your plugin code you can trigger the hook using this pattern:\n\n.. code-block:: python\n\n    from datasette.plugins import pm\n\n    for (\n        plugin_return_value\n    ) in pm.hook.name_of_your_hook_goes_here(\n        datasette=datasette\n    ):\n        # Do something with plugin_return_value\n        pass\n\nOther plugins will then be able to register their own implementations of your hook using this syntax:\n\n.. code-block:: python\n\n    from datasette import hookimpl\n\n\n    @hookimpl\n    def name_of_your_hook_goes_here(datasette):\n        return \"Response from this plugin hook\"\n\nThese plugin implementations can accept 0 or more of the named arguments that you defined in your hook specification.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"datasette\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\"\n  },\n  \"scripts\": {\n    \"fix\": \"npm run prettier -- --write\",\n    \"prettier\": \"prettier 'datasette/static/*[!.min|bundle].js'\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-sql\": \"^6.3.3\",\n    \"@rollup/plugin-node-resolve\": \"^15.0.1\",\n    \"@rollup/plugin-terser\": \"^0.1.0\",\n    \"codemirror\": \"^6.0.1\",\n    \"rollup\": \"^3.29.5\"\n  }\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"datasette\"\ndynamic = [\"version\"]\ndescription = \"An open source multi-tool for exploring and publishing data\"\nreadme = { file = \"README.md\", content-type = \"text/markdown\" }\nauthors = [\n    { name = \"Simon Willison\" },\n]\nlicense = \"Apache-2.0\"\nrequires-python = \">=3.10\"\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Framework :: Datasette\",\n    \"Intended Audience :: Developers\",\n    \"Intended Audience :: Science/Research\",\n    \"Intended Audience :: End Users/Desktop\",\n    \"Topic :: Database\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Programming Language :: Python :: 3.14\",\n]\n\ndependencies = [\n    \"asgiref>=3.2.10\",\n    \"click>=7.1.1\",\n    \"click-default-group>=1.2.3\",\n    \"Jinja2>=2.10.3\",\n    \"hupper>=1.9\",\n    \"httpx>=0.20,<1.0\",\n    \"pluggy>=1.0\",\n    \"uvicorn>=0.11\",\n    \"aiofiles>=0.4\",\n    \"janus>=0.6.2\",\n    \"asgi-csrf>=0.10\",\n    \"PyYAML>=5.3\",\n    \"mergedeep>=1.1.1\",\n    \"itsdangerous>=1.1\",\n    \"sqlite-utils>=3.30\",\n    \"asyncinject>=0.6.1\",\n    \"setuptools\",\n    \"pip\",\n]\n\n[project.urls]\nHomepage = \"https://datasette.io/\"\nDocumentation = \"https://docs.datasette.io/en/stable/\"\nChangelog = \"https://docs.datasette.io/en/stable/changelog.html\"\n\"Live demo\" = \"https://latest.datasette.io/\"\n\"Source code\" = \"https://github.com/simonw/datasette\"\nIssues = \"https://github.com/simonw/datasette/issues\"\nCI = \"https://github.com/simonw/datasette/actions?query=workflow%3ATest\"\n\n[project.scripts]\ndatasette = \"datasette.cli:cli\"\n\n[dependency-groups]\ndev = [\n    \"pytest>=9\",\n    \"pytest-xdist>=2.2.1\",\n    \"pytest-asyncio>=1.2.0\",\n    \"beautifulsoup4>=4.8.1\",\n    \"black==26.1.0\",\n    \"blacken-docs==1.20.0\",\n    \"pytest-timeout>=1.4.2\",\n    \"trustme>=0.7\",\n    \"cogapp>=3.3.0\",\n    \"multipart-form-data-conformance==0.1a0\",\n    \"ruff>=0.9\",\n    # docs\n    \"Sphinx==7.4.7\",\n    \"furo==2025.9.25\",\n    \"sphinx-autobuild\",\n    \"codespell>=2.2.5\",\n    \"sphinx-copybutton\",\n    \"sphinx-inline-tabs\",\n    \"myst-parser\",\n    \"sphinx-markdown-builder\",\n    \"ruamel.yaml\",\n]\n\n[project.optional-dependencies]\nrich = [\"rich\"]\n\n[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.packages.find]\ninclude = [\"datasette*\"]\n\n[tool.setuptools.package-data]\ndatasette = [\"templates/*.html\"]\n\n[tool.setuptools.dynamic]\nversion = {attr = \"datasette.version.__version__\"}\n\n[tool.ruff]\nline-length = 160\nselect = [\"E\", \"F\", \"W\"]\n\n[tool.uv]\npackage = true\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nfilterwarnings=\n    # https://github.com/pallets/jinja/issues/927\n    ignore:Using or importing the ABCs::jinja2\n    # https://bugs.launchpad.net/beautifulsoup/+bug/1778909\n    ignore:Using or importing the ABCs::bs4.element\nmarkers =\n    serial: tests to avoid using with pytest-xdist\nasyncio_mode = strict"
  },
  {
    "path": "ruff.toml",
    "content": "line-length = 160\ntarget-version = \"py310\""
  },
  {
    "path": "setup.cfg",
    "content": "[aliases]\ntest=pytest\n"
  },
  {
    "path": "test-in-pyodide-with-shot-scraper.sh",
    "content": "#!/bin/bash\nset -e\n# So the script fails if there are any errors\n\n# Build the wheel\npython3 -m build\n\n# Find name of wheel, strip off the dist/\nwheel=$(basename $(ls dist/*.whl) | head -n 1)\n\n# Create a blank index page\necho '\n<script src=\"https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js\"></script>\n' > dist/index.html\n\n# Run a server for that dist/ folder\ncd dist\npython3 -m http.server 8529 &\ncd ..\n\n# Register the kill_server function to be called on script exit\nkill_server() {\n  pkill -f 'http.server 8529'\n}\ntrap kill_server EXIT\n\n\nshot-scraper javascript http://localhost:8529/ \"\nasync () => {\n  let pyodide = await loadPyodide();\n  await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);\n  let output = await pyodide.runPythonAsync(\\`\n    import micropip\n    await micropip.install('h11==0.12.0')\n    await micropip.install('httpx==0.23')\n    # To avoid 'from typing_extensions import deprecated' error:\n    await micropip.install('typing-extensions>=4.12.2')\n    await micropip.install('http://localhost:8529/$wheel')\n    import ssl\n    import setuptools\n    from datasette.app import Datasette\n    ds = Datasette(memory=True, settings={'num_sql_threads': 0})\n    (await ds.client.get('/_memory/-/query.json?sql=select+55+as+itworks&_shape=array')).text\n  \\`);\n  if (JSON.parse(output)[0].itworks != 55) {\n    throw 'Got ' + output + ', expected itworks: 55';\n  }\n  return 'Test passed!';\n}\n\"\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/build_small_spatialite_db.py",
    "content": "import sqlite3\n\n# This script generates the spatialite.db file in our tests directory.\n\n\ndef generate_it(filename):\n    conn = sqlite3.connect(filename)\n    # Lead the spatialite extension:\n    conn.enable_load_extension(True)\n    conn.load_extension(\"/usr/local/lib/mod_spatialite.dylib\")\n    conn.execute(\"select InitSpatialMetadata(1)\")\n    conn.executescript(\"create table museums (name text)\")\n    conn.execute(\"SELECT AddGeometryColumn('museums', 'point_geom', 4326, 'POINT', 2);\")\n    # At this point it is around 5MB - we can shrink it dramatically by doing thisO\n    conn.execute(\"delete from spatial_ref_sys\")\n    conn.execute(\"delete from spatial_ref_sys_aux\")\n    conn.commit()\n    conn.execute(\"vacuum\")\n    conn.close()\n\n\nif __name__ == \"__main__\":\n    generate_it(\"spatialite.db\")\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import httpx\nimport os\nimport pathlib\nimport pytest\nimport pytest_asyncio\nimport re\nimport subprocess\nimport sys\nimport tempfile\nimport time\nfrom dataclasses import dataclass\nfrom datasette import Event, hookimpl\n\ntry:\n    import pysqlite3 as sqlite3\nexcept ImportError:\n    import sqlite3\n\nUNDOCUMENTED_PERMISSIONS = {\n    \"this_is_allowed\",\n    \"this_is_denied\",\n    \"this_is_allowed_async\",\n    \"this_is_denied_async\",\n    \"no_match\",\n    # Test actions from test_hook_register_actions_with_custom_resources\n    \"manage_documents\",\n    \"view_document_collection\",\n    \"view_document\",\n}\n\n_ds_client = None\n\n\ndef wait_until_responds(url, timeout=5.0, client=httpx, **kwargs):\n    start = time.time()\n    while time.time() - start < timeout:\n        try:\n            client.get(url, **kwargs)\n            return\n        except httpx.ConnectError:\n            time.sleep(0.1)\n    raise AssertionError(\"Timed out waiting for {} to respond\".format(url))\n\n\n@pytest_asyncio.fixture\nasync def ds_client():\n    from datasette.app import Datasette\n    from datasette.database import Database\n    from .fixtures import CONFIG, METADATA, PLUGINS_DIR\n    import secrets\n\n    global _ds_client\n    if _ds_client is not None:\n        return _ds_client\n\n    ds = Datasette(\n        metadata=METADATA,\n        config=CONFIG,\n        plugins_dir=PLUGINS_DIR,\n        settings={\n            \"default_page_size\": 50,\n            \"max_returned_rows\": 100,\n            \"sql_time_limit_ms\": 200,\n            \"facet_suggest_time_limit_ms\": 200,  # Up from 50 default\n            # Default is 3 but this results in \"too many open files\"\n            # errors when running the full test suite:\n            \"num_sql_threads\": 1,\n        },\n    )\n    from .fixtures import TABLES, TABLE_PARAMETERIZED_SQL\n\n    # Use a unique memory_name to avoid collisions between different\n    # Datasette instances in the same process, but use \"fixtures\" for routing\n    unique_memory_name = f\"fixtures_{secrets.token_hex(8)}\"\n    db = ds.add_database(Database(ds, memory_name=unique_memory_name), name=\"fixtures\")\n    ds.remove_database(\"_memory\")\n\n    def prepare(conn):\n        if not conn.execute(\"select count(*) from sqlite_master\").fetchone()[0]:\n            conn.executescript(TABLES)\n            for sql, params in TABLE_PARAMETERIZED_SQL:\n                with conn:\n                    conn.execute(sql, params)\n\n    await db.execute_write_fn(prepare)\n    await ds.invoke_startup()\n    _ds_client = ds.client\n    return _ds_client\n\n\ndef pytest_report_header(config):\n    return \"SQLite: {}\".format(\n        sqlite3.connect(\":memory:\").execute(\"select sqlite_version()\").fetchone()[0]\n    )\n\n\ndef pytest_configure(config):\n    import sys\n\n    sys._called_from_test = True\n\n\ndef pytest_unconfigure(config):\n    import sys\n\n    del sys._called_from_test\n\n\ndef pytest_collection_modifyitems(items):\n    # Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in\n    move_to_front(items, \"test_cli\")\n    move_to_front(items, \"test_black\")\n    move_to_front(items, \"test_inspect_cli\")\n    move_to_front(items, \"test_serve_with_get\")\n    move_to_front(items, \"test_serve_with_get_exit_code_for_error\")\n    move_to_front(items, \"test_inspect_cli_writes_to_file\")\n    move_to_front(items, \"test_spatialite_error_if_attempt_to_open_spatialite\")\n    move_to_front(items, \"test_package\")\n    move_to_front(items, \"test_package_with_port\")\n\n\ndef move_to_front(items, test_name):\n    test = [fn for fn in items if fn.name == test_name]\n    if test:\n        items.insert(0, items.pop(items.index(test[0])))\n\n\n@pytest.fixture\ndef restore_working_directory(tmpdir, request):\n    try:\n        previous_cwd = os.getcwd()\n    except OSError:\n        # https://github.com/simonw/datasette/issues/1361\n        previous_cwd = None\n    tmpdir.chdir()\n\n    def return_to_previous():\n        os.chdir(previous_cwd)\n\n    if previous_cwd is not None:\n        request.addfinalizer(return_to_previous)\n\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef check_actions_are_documented():\n    from datasette.plugins import pm\n\n    content = (\n        pathlib.Path(__file__).parent.parent / \"docs\" / \"authentication.rst\"\n    ).read_text()\n    permissions_re = re.compile(r\"\\.\\. _actions_([^\\s:]+):\")\n    documented_actions = set(permissions_re.findall(content)).union(\n        UNDOCUMENTED_PERMISSIONS\n    )\n\n    def before(hook_name, hook_impls, kwargs):\n        if hook_name == \"permission_resources_sql\":\n            datasette = kwargs[\"datasette\"]\n            assert kwargs[\"action\"] in datasette.actions, (\n                \"'{}' has not been registered with register_actions()\".format(\n                    kwargs[\"action\"]\n                )\n                + \" (or maybe a test forgot to do await ds.invoke_startup())\"\n            )\n            action = kwargs.get(\"action\").replace(\"-\", \"_\")\n            assert (\n                action in documented_actions\n            ), \"Undocumented permission action: {}\".format(action)\n\n    pm.add_hookcall_monitoring(\n        before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None\n    )\n\n\nclass TrackEventPlugin:\n    __name__ = \"TrackEventPlugin\"\n\n    @dataclass\n    class OneEvent(Event):\n        name = \"one\"\n\n        extra: str\n\n    @hookimpl\n    def register_events(self, datasette):\n        async def inner():\n            return [self.OneEvent]\n\n        return inner\n\n    @hookimpl\n    def track_event(self, datasette, event):\n        datasette._tracked_events = getattr(datasette, \"_tracked_events\", [])\n        datasette._tracked_events.append(event)\n\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef install_event_tracking_plugin():\n    from datasette.plugins import pm\n\n    pm.register(TrackEventPlugin(), name=\"TrackEventPlugin\")\n\n\n@pytest.fixture(scope=\"session\")\ndef ds_localhost_http_server():\n    ds_proc = subprocess.Popen(\n        [sys.executable, \"-m\", \"datasette\", \"--memory\", \"-p\", \"8041\"],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        # Avoid FileNotFoundError: [Errno 2] No such file or directory:\n        cwd=tempfile.gettempdir(),\n    )\n    wait_until_responds(\"http://localhost:8041/\")\n    # Check it started successfully\n    assert not ds_proc.poll(), ds_proc.stdout.read().decode(\"utf-8\")\n    yield ds_proc\n    # Shut it down at the end of the pytest session\n    ds_proc.terminate()\n\n\n@pytest.fixture(scope=\"session\")\ndef ds_unix_domain_socket_server(tmp_path_factory):\n    # This used to use tmp_path_factory.mktemp(\"uds\") but that turned out to\n    # produce paths that were too long to use as UDS on macOS, see\n    # https://github.com/simonw/datasette/issues/1407 - so I switched to\n    # using tempfile.gettempdir()\n    uds = str(pathlib.Path(tempfile.gettempdir()) / \"datasette.sock\")\n    ds_proc = subprocess.Popen(\n        [sys.executable, \"-m\", \"datasette\", \"--memory\", \"--uds\", uds],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        cwd=tempfile.gettempdir(),\n    )\n    # Poll until available\n    transport = httpx.HTTPTransport(uds=uds)\n    client = httpx.Client(transport=transport)\n    wait_until_responds(\"http://localhost/_memory.json\", client=client)\n    # Check it started successfully\n    assert not ds_proc.poll(), ds_proc.stdout.read().decode(\"utf-8\")\n    yield ds_proc, uds\n    # Shut it down at the end of the pytest session\n    ds_proc.terminate()\n\n\n# Import fixtures from fixtures.py to make them available\nfrom .fixtures import (  # noqa: E402, F401\n    app_client,\n    app_client_base_url_prefix,\n    app_client_conflicting_database_names,\n    app_client_csv_max_mb_one,\n    app_client_immutable_and_inspect_file,\n    app_client_larger_cache_size,\n    app_client_no_files,\n    app_client_returned_rows_matches_page_size,\n    app_client_shorter_time_limit,\n    app_client_two_attached_databases,\n    app_client_two_attached_databases_crossdb_enabled,\n    app_client_two_attached_databases_one_immutable,\n    app_client_with_cors,\n    app_client_with_dot,\n    app_client_with_trace,\n    generate_compound_rows,\n    generate_sortable_rows,\n    make_app_client,\n    TEMP_PLUGIN_SECRET_FILE,\n)\n"
  },
  {
    "path": "tests/ext.c",
    "content": "/*\n** This file implements a SQLite extension with multiple entrypoints.\n**\n** The default entrypoint, sqlite3_ext_init, has a single function \"a\".\n** The 1st alternate entrypoint, sqlite3_ext_b_init, has a single function \"b\".\n** The 2nd alternate entrypoint, sqlite3_ext_c_init, has a single function \"c\".\n**\n** Compiling instructions: \n**     https://www.sqlite.org/loadext.html#compiling_a_loadable_extension\n**\n*/\n\n#include \"sqlite3ext.h\"\n\nSQLITE_EXTENSION_INIT1\n\n// SQL function that returns back the value supplied during sqlite3_create_function()\nstatic void func(sqlite3_context *context, int argc, sqlite3_value **argv) {\n  sqlite3_result_text(context, (char *) sqlite3_user_data(context), -1, SQLITE_STATIC);\n}\n\n\n// The default entrypoint, since it matches the \"ext.dylib\"/\"ext.so\" name\n#ifdef _WIN32\n__declspec(dllexport)\n#endif\nint sqlite3_ext_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {\n  SQLITE_EXTENSION_INIT2(pApi);\n  return sqlite3_create_function(db, \"a\", 0, 0, \"a\", func, 0, 0);\n}\n\n// Alternate entrypoint #1\n#ifdef _WIN32\n__declspec(dllexport)\n#endif\nint sqlite3_ext_b_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {\n  SQLITE_EXTENSION_INIT2(pApi);\n  return sqlite3_create_function(db, \"b\", 0, 0, \"b\", func, 0, 0);\n}\n\n// Alternate entrypoint #2\n#ifdef _WIN32\n__declspec(dllexport)\n#endif\nint sqlite3_ext_c_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {\n  SQLITE_EXTENSION_INIT2(pApi);\n  return sqlite3_create_function(db, \"c\", 0, 0, \"c\", func, 0, 0);\n}\n"
  },
  {
    "path": "tests/fixtures.py",
    "content": "from datasette.app import Datasette\nfrom datasette.utils.sqlite import sqlite3\nfrom datasette.utils.testing import TestClient\nimport click\nimport contextlib\nimport itertools\nimport json\nimport os\nimport pathlib\nimport pytest\nimport random\nimport string\nimport tempfile\nimport textwrap\n\n# This temp file is used by one of the plugin config tests\nTEMP_PLUGIN_SECRET_FILE = os.path.join(tempfile.gettempdir(), \"plugin-secret\")\n\nPLUGINS_DIR = str(pathlib.Path(__file__).parent / \"plugins\")\n\nEXPECTED_PLUGINS = [\n    {\n        \"name\": \"messages_output_renderer.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\"register_output_renderer\"],\n    },\n    {\n        \"name\": \"my_plugin.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\n            \"actor_from_request\",\n            \"asgi_wrapper\",\n            \"canned_queries\",\n            \"database_actions\",\n            \"extra_body_script\",\n            \"extra_css_urls\",\n            \"extra_js_urls\",\n            \"extra_template_vars\",\n            \"forbidden\",\n            \"homepage_actions\",\n            \"menu_links\",\n            \"permission_resources_sql\",\n            \"prepare_connection\",\n            \"prepare_jinja2_environment\",\n            \"query_actions\",\n            \"register_actions\",\n            \"register_facet_classes\",\n            \"register_magic_parameters\",\n            \"register_routes\",\n            \"register_token_handler\",\n            \"render_cell\",\n            \"row_actions\",\n            \"skip_csrf\",\n            \"startup\",\n            \"table_actions\",\n            \"view_actions\",\n        ],\n    },\n    {\n        \"name\": \"my_plugin_2.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\n            \"actor_from_request\",\n            \"asgi_wrapper\",\n            \"canned_queries\",\n            \"extra_js_urls\",\n            \"extra_template_vars\",\n            \"handle_exception\",\n            \"menu_links\",\n            \"prepare_jinja2_environment\",\n            \"register_routes\",\n            \"render_cell\",\n            \"startup\",\n            \"table_actions\",\n        ],\n    },\n    {\n        \"name\": \"register_output_renderer.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\"register_output_renderer\"],\n    },\n    {\n        \"name\": \"sleep_sql_function.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\"prepare_connection\"],\n    },\n    {\n        \"name\": \"view_name.py\",\n        \"static\": False,\n        \"templates\": False,\n        \"version\": None,\n        \"hooks\": [\"extra_template_vars\"],\n    },\n]\n\n\n@contextlib.contextmanager\ndef make_app_client(\n    sql_time_limit_ms=None,\n    max_returned_rows=None,\n    cors=False,\n    memory=False,\n    settings=None,\n    filename=\"fixtures.db\",\n    is_immutable=False,\n    extra_databases=None,\n    inspect_data=None,\n    static_mounts=None,\n    template_dir=None,\n    config=None,\n    metadata=None,\n    crossdb=False,\n):\n    with tempfile.TemporaryDirectory() as tmpdir:\n        filepath = os.path.join(tmpdir, filename)\n        if is_immutable:\n            files = []\n            immutables = [filepath]\n        else:\n            files = [filepath]\n            immutables = []\n        conn = sqlite3.connect(filepath)\n        conn.executescript(TABLES)\n        for sql, params in TABLE_PARAMETERIZED_SQL:\n            with conn:\n                conn.execute(sql, params)\n        # Close the connection to avoid \"too many open files\" errors\n        conn.close()\n        if extra_databases is not None:\n            for extra_filename, extra_sql in extra_databases.items():\n                extra_filepath = os.path.join(tmpdir, extra_filename)\n                c2 = sqlite3.connect(extra_filepath)\n                c2.executescript(extra_sql)\n                c2.close()\n                # Insert at start to help test /-/databases ordering:\n                files.insert(0, extra_filepath)\n        os.chdir(os.path.dirname(filepath))\n        settings = settings or {}\n        for key, value in {\n            \"default_page_size\": 50,\n            \"max_returned_rows\": max_returned_rows or 100,\n            \"sql_time_limit_ms\": sql_time_limit_ms or 200,\n            # Default is 3 but this results in \"too many open files\"\n            # errors when running the full test suite:\n            \"num_sql_threads\": 1,\n        }.items():\n            if key not in settings:\n                settings[key] = value\n        ds = Datasette(\n            files,\n            immutables=immutables,\n            memory=memory,\n            cors=cors,\n            metadata=metadata or METADATA,\n            config=config or CONFIG,\n            plugins_dir=PLUGINS_DIR,\n            settings=settings,\n            inspect_data=inspect_data,\n            static_mounts=static_mounts,\n            template_dir=template_dir,\n            crossdb=crossdb,\n        )\n        yield TestClient(ds)\n        # Close as many database connections as possible\n        # to try and avoid too many open files error\n        for db in ds.databases.values():\n            if not db.is_memory:\n                db.close()\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client():\n    with make_app_client() as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_no_files():\n    ds = Datasette([])\n    yield TestClient(ds)\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_base_url_prefix():\n    with make_app_client(settings={\"base_url\": \"/prefix/\"}) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_two_attached_databases():\n    with make_app_client(\n        extra_databases={\"extra database.db\": EXTRA_DATABASE_SQL}\n    ) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_two_attached_databases_crossdb_enabled():\n    with make_app_client(\n        extra_databases={\"extra database.db\": EXTRA_DATABASE_SQL},\n        crossdb=True,\n    ) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_conflicting_database_names():\n    with make_app_client(\n        extra_databases={\"foo.db\": EXTRA_DATABASE_SQL, \"foo-bar.db\": EXTRA_DATABASE_SQL}\n    ) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_two_attached_databases_one_immutable():\n    with make_app_client(\n        is_immutable=True, extra_databases={\"extra database.db\": EXTRA_DATABASE_SQL}\n    ) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_with_trace():\n    with make_app_client(settings={\"trace_debug\": True}, is_immutable=True) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_shorter_time_limit():\n    with make_app_client(20) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_returned_rows_matches_page_size():\n    with make_app_client(max_returned_rows=50) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_larger_cache_size():\n    with make_app_client(settings={\"cache_size_kb\": 2500}) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_csv_max_mb_one():\n    with make_app_client(settings={\"max_csv_mb\": 1}) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_with_dot():\n    with make_app_client(filename=\"fixtures.dot.db\") as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_with_cors():\n    with make_app_client(is_immutable=True, cors=True) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef app_client_immutable_and_inspect_file():\n    inspect_data = {\"fixtures\": {\"tables\": {\"sortable\": {\"count\": 100}}}}\n    with make_app_client(is_immutable=True, inspect_data=inspect_data) as client:\n        yield client\n\n\ndef generate_compound_rows(num):\n    for a, b, c in itertools.islice(\n        itertools.product(string.ascii_lowercase, repeat=3), num\n    ):\n        yield a, b, c, f\"{a}-{b}-{c}\"\n\n\ndef generate_sortable_rows(num):\n    rand = random.Random(42)\n    for a, b in itertools.islice(\n        itertools.product(string.ascii_lowercase, repeat=2), num\n    ):\n        yield {\n            \"pk1\": a,\n            \"pk2\": b,\n            \"content\": f\"{a}-{b}\",\n            \"sortable\": rand.randint(-100, 100),\n            \"sortable_with_nulls\": rand.choice([None, rand.random(), rand.random()]),\n            \"sortable_with_nulls_2\": rand.choice([None, rand.random(), rand.random()]),\n            \"text\": rand.choice([\"$null\", \"$blah\"]),\n        }\n\n\nCONFIG = {\n    \"plugins\": {\n        \"name-of-plugin\": {\"depth\": \"root\"},\n        \"env-plugin\": {\"foo\": {\"$env\": \"FOO_ENV\"}},\n        \"env-plugin-list\": [{\"in_a_list\": {\"$env\": \"FOO_ENV\"}}],\n        \"file-plugin\": {\"foo\": {\"$file\": TEMP_PLUGIN_SECRET_FILE}},\n    },\n    \"databases\": {\n        \"fixtures\": {\n            \"plugins\": {\"name-of-plugin\": {\"depth\": \"database\"}},\n            \"tables\": {\n                \"simple_primary_key\": {\n                    \"plugins\": {\n                        \"name-of-plugin\": {\n                            \"depth\": \"table\",\n                            \"special\": \"this-is-simple_primary_key\",\n                        }\n                    },\n                },\n                \"sortable\": {\n                    \"plugins\": {\"name-of-plugin\": {\"depth\": \"table\"}},\n                },\n            },\n            \"queries\": {\n                \"𝐜𝐢𝐭𝐢𝐞𝐬\": \"select id, name from facet_cities order by id limit 1;\",\n                \"pragma_cache_size\": \"PRAGMA cache_size;\",\n                \"magic_parameters\": {\n                    \"sql\": \"select :_header_user_agent as user_agent, :_now_datetime_utc as datetime\",\n                },\n                \"neighborhood_search\": {\n                    \"sql\": textwrap.dedent(\"\"\"\n                        select _neighborhood, facet_cities.name, state\n                        from facetable\n                            join facet_cities\n                                on facetable._city_id = facet_cities.id\n                        where _neighborhood like '%' || :text || '%'\n                        order by _neighborhood;\n                    \"\"\"),\n                    \"title\": \"Search neighborhoods\",\n                    \"description_html\": \"<b>Demonstrating</b> simple like search\",\n                    \"fragment\": \"fragment-goes-here\",\n                    \"hide_sql\": True,\n                },\n            },\n        }\n    },\n    \"extra_css_urls\": [\"/static/extra-css-urls.css\"],\n}\n\nMETADATA = {\n    \"title\": \"Datasette Fixtures\",\n    \"description_html\": 'An example SQLite database demonstrating Datasette. <a href=\"/login-as-root\">Sign in as root user</a>',\n    \"license\": \"Apache License 2.0\",\n    \"license_url\": \"https://github.com/simonw/datasette/blob/main/LICENSE\",\n    \"source\": \"tests/fixtures.py\",\n    \"source_url\": \"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\",\n    \"about\": \"About Datasette\",\n    \"about_url\": \"https://github.com/simonw/datasette\",\n    \"databases\": {\n        \"fixtures\": {\n            \"description\": \"Test tables description\",\n            \"tables\": {\n                \"simple_primary_key\": {\n                    \"description_html\": \"Simple <em>primary</em> key\",\n                    \"title\": \"This <em>HTML</em> is escaped\",\n                },\n                \"sortable\": {\n                    \"sortable_columns\": [\n                        \"sortable\",\n                        \"sortable_with_nulls\",\n                        \"sortable_with_nulls_2\",\n                        \"text\",\n                    ],\n                },\n                \"no_primary_key\": {\"sortable_columns\": [], \"hidden\": True},\n                \"primary_key_multiple_columns_explicit_label\": {\n                    \"label_column\": \"content2\"\n                },\n                \"simple_view\": {\"sortable_columns\": [\"content\"]},\n                \"searchable_view_configured_by_metadata\": {\n                    \"fts_table\": \"searchable_fts\",\n                    \"fts_pk\": \"pk\",\n                },\n                \"roadside_attractions\": {\n                    \"columns\": {\n                        \"name\": \"The name of the attraction\",\n                        \"address\": \"The street address for the attraction\",\n                    }\n                },\n                \"attraction_characteristic\": {\"sort_desc\": \"pk\"},\n                \"facet_cities\": {\"sort\": \"name\"},\n                \"paginated_view\": {\"size\": 25},\n            },\n        }\n    },\n}\n\nTABLES = (\n    \"\"\"\nCREATE TABLE simple_primary_key (\n  id integer primary key,\n  content text\n);\n\nCREATE TABLE primary_key_multiple_columns (\n  id varchar(30) primary key,\n  content text,\n  content2 text\n);\n\nCREATE TABLE primary_key_multiple_columns_explicit_label (\n  id varchar(30) primary key,\n  content text,\n  content2 text\n);\n\nCREATE TABLE compound_primary_key (\n  pk1 varchar(30),\n  pk2 varchar(30),\n  content text,\n  PRIMARY KEY (pk1, pk2)\n);\n\nINSERT INTO compound_primary_key VALUES ('a', 'b', 'c');\nINSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c');\nINSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO');\n\nCREATE TABLE compound_three_primary_keys (\n  pk1 varchar(30),\n  pk2 varchar(30),\n  pk3 varchar(30),\n  content text,\n  PRIMARY KEY (pk1, pk2, pk3)\n);\nCREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);\n\nCREATE TABLE foreign_key_references (\n  pk varchar(30) primary key,\n  foreign_key_with_label integer,\n  foreign_key_with_blank_label integer,\n  foreign_key_with_no_label varchar(30),\n  foreign_key_compound_pk1 varchar(30),\n  foreign_key_compound_pk2 varchar(30),\n  FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),\n  FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),\n  FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)\n  FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)\n);\n\nCREATE TABLE sortable (\n  pk1 varchar(30),\n  pk2 varchar(30),\n  content text,\n  sortable integer,\n  sortable_with_nulls real,\n  sortable_with_nulls_2 real,\n  text text,\n  PRIMARY KEY (pk1, pk2)\n);\n\nCREATE TABLE no_primary_key (\n  content text,\n  a text,\n  b text,\n  c text\n);\n\nCREATE TABLE [123_starts_with_digits] (\n  content text\n);\n\nCREATE VIEW paginated_view AS\n    SELECT\n        content,\n        '- ' || content || ' -' AS content_extra\n    FROM no_primary_key;\n\nCREATE TABLE \"Table With Space In Name\" (\n  pk varchar(30) primary key,\n  content text\n);\n\nCREATE TABLE \"table/with/slashes.csv\" (\n  pk varchar(30) primary key,\n  content text\n);\n\nCREATE TABLE \"complex_foreign_keys\" (\n  pk varchar(30) primary key,\n  f1 integer,\n  f2 integer,\n  f3 integer,\n  FOREIGN KEY (\"f1\") REFERENCES [simple_primary_key](id),\n  FOREIGN KEY (\"f2\") REFERENCES [simple_primary_key](id),\n  FOREIGN KEY (\"f3\") REFERENCES [simple_primary_key](id)\n);\n\nCREATE TABLE \"custom_foreign_key_label\" (\n  pk varchar(30) primary key,\n  foreign_key_with_custom_label text,\n  FOREIGN KEY (\"foreign_key_with_custom_label\") REFERENCES [primary_key_multiple_columns_explicit_label](id)\n);\n\nCREATE TABLE tags (\n    tag TEXT PRIMARY KEY\n);\n\nCREATE TABLE searchable (\n  pk integer primary key,\n  text1 text,\n  text2 text,\n  [name with . and spaces] text\n);\n\nCREATE TABLE searchable_tags (\n    searchable_id integer,\n    tag text,\n    PRIMARY KEY (searchable_id, tag),\n    FOREIGN KEY (searchable_id) REFERENCES searchable(pk),\n    FOREIGN KEY (tag) REFERENCES tags(tag)\n);\n\nINSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther');\nINSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma');\n\nINSERT INTO tags VALUES (\"canine\");\nINSERT INTO tags VALUES (\"feline\");\n\nINSERT INTO searchable_tags (searchable_id, tag) VALUES\n    (1, \"feline\"),\n    (2, \"canine\")\n;\n\nCREATE VIRTUAL TABLE \"searchable_fts\"\n    USING FTS5 (text1, text2, [name with . and spaces], content=\"searchable\", content_rowid=\"pk\");\nINSERT INTO \"searchable_fts\" (searchable_fts) VALUES ('rebuild');\n\nCREATE TABLE [select] (\n  [group] text,\n  [having] text,\n  [and] text,\n  [json] text\n);\nINSERT INTO [select] VALUES ('group', 'having', 'and',\n    '{\"href\": \"http://example.com/\", \"label\":\"Example\"}'\n);\n\nCREATE TABLE infinity (\n    value REAL\n);\nINSERT INTO infinity VALUES\n    (1e999),\n    (-1e999),\n    (1.5)\n;\n\nCREATE TABLE facet_cities (\n    id integer primary key,\n    name text\n);\nINSERT INTO facet_cities (id, name) VALUES\n    (1, 'San Francisco'),\n    (2, 'Los Angeles'),\n    (3, 'Detroit'),\n    (4, 'Memnonia')\n;\n\nCREATE TABLE facetable (\n    pk integer primary key,\n    created text,\n    planet_int integer,\n    on_earth integer,\n    state text,\n    _city_id integer,\n    _neighborhood text,\n    tags text,\n    complex_array text,\n    distinct_some_null,\n    n text,\n    FOREIGN KEY (\"_city_id\") REFERENCES [facet_cities](id)\n);\nINSERT INTO facetable\n    (created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n)\nVALUES\n    (\"2019-01-14 08:00:00\", 1, 1, 'CA', 1, 'Mission', '[\"tag1\", \"tag2\"]', '[{\"foo\": \"bar\"}]', 'one', 'n1'),\n    (\"2019-01-14 08:00:00\", 1, 1, 'CA', 1, 'Dogpatch', '[\"tag1\", \"tag3\"]', '[]', 'two', 'n2'),\n    (\"2019-01-14 08:00:00\", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null),\n    (\"2019-01-14 08:00:00\", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null),\n    (\"2019-01-15 08:00:00\", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null),\n    (\"2019-01-15 08:00:00\", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null),\n    (\"2019-01-15 08:00:00\", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null),\n    (\"2019-01-15 08:00:00\", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null),\n    (\"2019-01-16 08:00:00\", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null),\n    (\"2019-01-16 08:00:00\", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null),\n    (\"2019-01-16 08:00:00\", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null),\n    (\"2019-01-17 08:00:00\", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null),\n    (\"2019-01-17 08:00:00\", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null),\n    (\"2019-01-17 08:00:00\", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null),\n    (\"2019-01-17 08:00:00\", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null)\n;\n\nCREATE TABLE binary_data (\n    data BLOB\n);\n\n-- Many 2 Many demo: roadside attractions!\n\nCREATE TABLE roadside_attractions (\n    pk integer primary key,\n    name text,\n    address text,\n    url text,\n    latitude real,\n    longitude real\n);\nINSERT INTO roadside_attractions VALUES (\n    1, \"The Mystery Spot\", \"465 Mystery Spot Road, Santa Cruz, CA 95065\", \"https://www.mysteryspot.com/\",\n    37.0167, -122.0024\n);\nINSERT INTO roadside_attractions VALUES (\n    2, \"Winchester Mystery House\", \"525 South Winchester Boulevard, San Jose, CA 95128\", \"https://winchestermysteryhouse.com/\",\n    37.3184, -121.9511\n);\nINSERT INTO roadside_attractions VALUES (\n    3, \"Burlingame Museum of PEZ Memorabilia\", \"214 California Drive, Burlingame, CA 94010\", null,\n    37.5793, -122.3442\n);\nINSERT INTO roadside_attractions VALUES (\n    4, \"Bigfoot Discovery Museum\", \"5497 Highway 9, Felton, CA 95018\", \"https://www.bigfootdiscoveryproject.com/\",\n    37.0414, -122.0725\n);\n\nCREATE TABLE attraction_characteristic (\n    pk integer primary key,\n    name text\n);\nINSERT INTO attraction_characteristic VALUES (\n    1, \"Museum\"\n);\nINSERT INTO attraction_characteristic VALUES (\n    2, \"Paranormal\"\n);\n\nCREATE TABLE roadside_attraction_characteristics (\n    attraction_id INTEGER REFERENCES roadside_attractions(pk),\n    characteristic_id INTEGER REFERENCES attraction_characteristic(pk)\n);\nINSERT INTO roadside_attraction_characteristics VALUES (\n    1, 2\n);\nINSERT INTO roadside_attraction_characteristics VALUES (\n    2, 2\n);\nINSERT INTO roadside_attraction_characteristics VALUES (\n    4, 2\n);\nINSERT INTO roadside_attraction_characteristics VALUES (\n    3, 1\n);\nINSERT INTO roadside_attraction_characteristics VALUES (\n    4, 1\n);\n\nINSERT INTO simple_primary_key VALUES (1, 'hello');\nINSERT INTO simple_primary_key VALUES (2, 'world');\nINSERT INTO simple_primary_key VALUES (3, '');\nINSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');\nINSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC');\n\nINSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');\nINSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');\n\nINSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');\nINSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);\n\nINSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);\nINSERT INTO custom_foreign_key_label VALUES (1, 1);\n\nINSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');\n\nCREATE VIEW simple_view AS\n    SELECT content, upper(content) AS upper_content FROM simple_primary_key;\n\nCREATE VIEW searchable_view AS\n    SELECT * from searchable;\n\nCREATE VIEW searchable_view_configured_by_metadata AS\n    SELECT * from searchable;\n\n\"\"\"\n    + \"\\n\".join(\n        [\n            'INSERT INTO no_primary_key VALUES ({i}, \"a{i}\", \"b{i}\", \"c{i}\");'.format(\n                i=i + 1\n            )\n            for i in range(201)\n        ]\n    )\n    + '\\nINSERT INTO no_primary_key VALUES (\"RENDER_CELL_DEMO\", \"a202\", \"b202\", \"c202\");\\n'\n    + \"\\n\".join(\n        [\n            'INSERT INTO compound_three_primary_keys VALUES (\"{a}\", \"{b}\", \"{c}\", \"{content}\");'.format(\n                a=a, b=b, c=c, content=content\n            )\n            for a, b, c, content in generate_compound_rows(1001)\n        ]\n    )\n    + \"\\n\".join([\"\"\"INSERT INTO sortable VALUES (\n        \"{pk1}\", \"{pk2}\", \"{content}\", {sortable},\n        {sortable_with_nulls}, {sortable_with_nulls_2}, \"{text}\");\n    \"\"\".format(**row).replace(\"None\", \"null\") for row in generate_sortable_rows(201)])\n)\nTABLE_PARAMETERIZED_SQL = [\n    (\"insert into binary_data (data) values (?);\", [b\"\\x15\\x1c\\x02\\xc7\\xad\\x05\\xfe\"]),\n    (\"insert into binary_data (data) values (?);\", [b\"\\x15\\x1c\\x03\\xc7\\xad\\x05\\xfe\"]),\n    (\"insert into binary_data (data) values (null);\", []),\n]\n\nEXTRA_DATABASE_SQL = \"\"\"\nCREATE TABLE searchable (\n  pk integer primary key,\n  text1 text,\n  text2 text\n);\n\nCREATE VIEW searchable_view AS SELECT * FROM searchable;\n\nINSERT INTO searchable VALUES (1, 'barry cat', 'terry dog');\nINSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel');\n\nCREATE VIRTUAL TABLE \"searchable_fts\"\n    USING FTS3 (text1, text2, content=\"searchable\");\nINSERT INTO \"searchable_fts\" (rowid, text1, text2)\n    SELECT rowid, text1, text2 FROM searchable;\n\"\"\"\n\n\ndef assert_permissions_checked(datasette, actions):\n    # actions is a list of \"action\" or (action, resource) tuples\n    for action in actions:\n        if isinstance(action, str):\n            resource = None\n        else:\n            action, resource = action\n\n        # Convert PermissionCheck dataclass to old resource format for comparison\n        def check_matches(pc, action, resource):\n            if pc.action != action:\n                return False\n            # Convert parent/child to old resource format\n            if pc.parent and pc.child:\n                pc_resource = (pc.parent, pc.child)\n            elif pc.parent:\n                pc_resource = pc.parent\n            else:\n                pc_resource = None\n            return pc_resource == resource\n\n        assert [\n            pc\n            for pc in datasette._permission_checks\n            if check_matches(pc, action, resource)\n        ], \"\"\"Missing expected permission check: action={}, resource={}\n        Permission checks seen: {}\n        \"\"\".format(\n            action,\n            resource,\n            json.dumps(\n                [\n                    {\n                        \"action\": pc.action,\n                        \"parent\": pc.parent,\n                        \"child\": pc.child,\n                        \"result\": pc.result,\n                    }\n                    for pc in datasette._permission_checks\n                ],\n                indent=4,\n            ),\n        )\n\n\n@click.command()\n@click.argument(\n    \"db_filename\",\n    default=\"fixtures.db\",\n    type=click.Path(file_okay=True, dir_okay=False),\n)\n@click.argument(\"config\", required=False)\n@click.argument(\"metadata\", required=False)\n@click.argument(\n    \"plugins_path\", type=click.Path(file_okay=False, dir_okay=True), required=False\n)\n@click.option(\n    \"--recreate\",\n    is_flag=True,\n    default=False,\n    help=\"Delete and recreate database if it exists\",\n)\n@click.option(\n    \"--extra-db-filename\",\n    type=click.Path(file_okay=True, dir_okay=False),\n    help=\"Write out second test DB to this file\",\n)\ndef cli(db_filename, config, metadata, plugins_path, recreate, extra_db_filename):\n    \"\"\"Write out the fixtures database used by Datasette's test suite\"\"\"\n    if metadata and not metadata.endswith(\".json\"):\n        raise click.ClickException(\"Metadata should end with .json\")\n    if not db_filename.endswith(\".db\"):\n        raise click.ClickException(\"Database file should end with .db\")\n    if pathlib.Path(db_filename).exists():\n        if not recreate:\n            raise click.ClickException(\n                f\"{db_filename} already exists, use --recreate to reset it\"\n            )\n        else:\n            pathlib.Path(db_filename).unlink()\n    conn = sqlite3.connect(db_filename)\n    conn.executescript(TABLES)\n    for sql, params in TABLE_PARAMETERIZED_SQL:\n        with conn:\n            conn.execute(sql, params)\n    print(f\"Test tables written to {db_filename}\")\n    if metadata:\n        with open(metadata, \"w\") as fp:\n            fp.write(json.dumps(METADATA, indent=4))\n        print(f\"- metadata written to {metadata}\")\n    if config:\n        with open(config, \"w\") as fp:\n            fp.write(json.dumps(CONFIG, indent=4))\n        print(f\"- config written to {config}\")\n    if plugins_path:\n        path = pathlib.Path(plugins_path)\n        if not path.exists():\n            path.mkdir()\n        test_plugins = pathlib.Path(__file__).parent / \"plugins\"\n        for filepath in test_plugins.glob(\"*.py\"):\n            newpath = path / filepath.name\n            newpath.write_text(filepath.read_text())\n            print(f\"  Wrote plugin: {newpath}\")\n    if extra_db_filename:\n        if pathlib.Path(extra_db_filename).exists():\n            if not recreate:\n                raise click.ClickException(\n                    f\"{extra_db_filename} already exists, use --recreate to reset it\"\n                )\n            else:\n                pathlib.Path(extra_db_filename).unlink()\n        conn = sqlite3.connect(extra_db_filename)\n        conn.executescript(EXTRA_DATABASE_SQL)\n        print(f\"Test tables written to {extra_db_filename}\")\n\n\nif __name__ == \"__main__\":\n    cli()\n"
  },
  {
    "path": "tests/plugins/messages_output_renderer.py",
    "content": "from datasette import hookimpl\n\n\ndef render_message_debug(datasette, request):\n    if request.args.get(\"add_msg\"):\n        msg_type = request.args.get(\"type\", \"INFO\")\n        datasette.add_message(\n            request, request.args[\"add_msg\"], getattr(datasette, msg_type)\n        )\n    return {\"body\": \"Hello from message debug\"}\n\n\n@hookimpl\ndef register_output_renderer(datasette):\n    return [\n        {\n            \"extension\": \"message\",\n            \"render\": render_message_debug,\n            \"can_render\": lambda: False,\n        }\n    ]\n"
  },
  {
    "path": "tests/plugins/my_plugin.py",
    "content": "import asyncio\nfrom datasette import hookimpl\nfrom datasette.facets import Facet\nfrom datasette.tokens import TokenHandler\nfrom datasette import tracer\nfrom datasette.permissions import Action\nfrom datasette.resources import DatabaseResource\nfrom datasette.utils import path_with_added_args\nfrom datasette.utils.asgi import asgi_send_json, Response\nimport base64\nimport json\nimport urllib.parse\n\n\n@hookimpl\ndef prepare_connection(conn, database, datasette):\n    def convert_units(amount, from_, to_):\n        \"\"\"select convert_units(100, 'm', 'ft');\"\"\"\n        # Convert meters to feet\n        if from_ == \"m\" and to_ == \"ft\":\n            return amount * 3.28084\n        # Convert feet to meters\n        if from_ == \"ft\" and to_ == \"m\":\n            return amount / 3.28084\n        assert False, \"Unsupported conversion\"\n\n    conn.create_function(\"convert_units\", 3, convert_units)\n\n    def prepare_connection_args():\n        return 'database={}, datasette.plugin_config(\"name-of-plugin\")={}'.format(\n            database, datasette.plugin_config(\"name-of-plugin\")\n        )\n\n    conn.create_function(\"prepare_connection_args\", 0, prepare_connection_args)\n\n\n@hookimpl\ndef extra_css_urls(template, database, table, view_name, columns, request, datasette):\n    async def inner():\n        return [\n            \"https://plugin-example.datasette.io/{}/extra-css-urls-demo.css\".format(\n                base64.b64encode(\n                    json.dumps(\n                        {\n                            \"template\": template,\n                            \"database\": database,\n                            \"table\": table,\n                            \"view_name\": view_name,\n                            \"request_path\": (\n                                request.path if request is not None else None\n                            ),\n                            \"added\": (\n                                await datasette.get_database().execute(\"select 3 * 5\")\n                            ).first()[0],\n                            \"columns\": columns,\n                        }\n                    ).encode(\"utf8\")\n                ).decode(\"utf8\")\n            )\n        ]\n\n    return inner\n\n\n@hookimpl\ndef extra_js_urls():\n    return [\n        {\n            \"url\": \"https://plugin-example.datasette.io/jquery.js\",\n            \"sri\": \"SRIHASH\",\n        },\n        \"https://plugin-example.datasette.io/plugin1.js\",\n        {\"url\": \"https://plugin-example.datasette.io/plugin.module.js\", \"module\": True},\n    ]\n\n\n@hookimpl\ndef extra_body_script(\n    template, database, table, view_name, columns, request, datasette\n):\n    async def inner():\n        script = \"var extra_body_script = {};\".format(\n            json.dumps(\n                {\n                    \"template\": template,\n                    \"database\": database,\n                    \"table\": table,\n                    \"config\": datasette.plugin_config(\n                        \"name-of-plugin\",\n                        database=database,\n                        table=table,\n                    ),\n                    \"view_name\": view_name,\n                    \"request_path\": request.path if request is not None else None,\n                    \"added\": (\n                        await datasette.get_database().execute(\"select 3 * 5\")\n                    ).first()[0],\n                    \"columns\": columns,\n                }\n            )\n        )\n        return {\"script\": script, \"module\": True}\n\n    return inner\n\n\n@hookimpl\ndef render_cell(row, value, column, table, pks, database, datasette, request):\n    async def inner():\n        # Render some debug output in cell with value RENDER_CELL_DEMO\n        if value == \"RENDER_CELL_DEMO\":\n            data = {\n                \"row\": dict(row),\n                \"column\": column,\n                \"table\": table,\n                \"database\": database,\n                \"pks\": pks,\n                \"config\": datasette.plugin_config(\n                    \"name-of-plugin\",\n                    database=database,\n                    table=table,\n                ),\n            }\n            if request.args.get(\"_render_cell_extra\"):\n                data[\"render_cell_extra\"] = 1\n            return json.dumps(data)\n        elif value == \"RENDER_CELL_ASYNC\":\n            return (\n                await datasette.get_database(database).execute(\n                    \"select 'RENDER_CELL_ASYNC_RESULT'\"\n                )\n            ).single_value()\n\n    return inner\n\n\n@hookimpl\ndef extra_template_vars(\n    template, database, table, view_name, columns, request, datasette\n):\n    return {\n        \"extra_template_vars\": json.dumps(\n            {\n                \"template\": template,\n                \"scope_path\": request.scope[\"path\"] if request else None,\n                \"columns\": columns,\n            },\n            default=lambda b: b.decode(\"utf8\"),\n        )\n    }\n\n\n@hookimpl\ndef prepare_jinja2_environment(env, datasette):\n    async def select_times_three(s):\n        db = datasette.get_database()\n        return (await db.execute(\"select 3 * ?\", [int(s)])).first()[0]\n\n    async def inner():\n        env.filters[\"select_times_three\"] = select_times_three\n\n    return inner\n\n\n@hookimpl\ndef register_facet_classes():\n    return [DummyFacet]\n\n\nclass DummyFacet(Facet):\n    type = \"dummy\"\n\n    async def suggest(self):\n        columns = await self.get_columns(self.sql, self.params)\n        return (\n            [\n                {\n                    \"name\": column,\n                    \"toggle_url\": self.ds.absolute_url(\n                        self.request,\n                        path_with_added_args(self.request, {\"_facet_dummy\": column}),\n                    ),\n                    \"type\": \"dummy\",\n                }\n                for column in columns\n            ]\n            if self.request.args.get(\"_dummy_facet\")\n            else []\n        )\n\n    async def facet_results(self):\n        facet_results = {}\n        facets_timed_out = []\n        return facet_results, facets_timed_out\n\n\n@hookimpl\ndef actor_from_request(datasette, request):\n    if request.args.get(\"_bot\"):\n        return {\"id\": \"bot\"}\n    else:\n        return None\n\n\n@hookimpl\ndef asgi_wrapper():\n    def wrap(app):\n        async def maybe_set_actor_in_scope(scope, receive, send):\n            if b\"_actor_in_scope\" in scope.get(\"query_string\", b\"\"):\n                scope = dict(scope, actor={\"id\": \"from-scope\"})\n                print(scope)\n            await app(scope, receive, send)\n\n        return maybe_set_actor_in_scope\n\n    return wrap\n\n\n@hookimpl\ndef register_routes():\n    async def one(datasette):\n        return Response.text(\n            (await datasette.get_database().execute(\"select 1 + 1\")).first()[0]\n        )\n\n    async def two(request):\n        name = request.url_vars[\"name\"]\n        greeting = request.args.get(\"greeting\")\n        return Response.text(f\"{greeting} {name}\")\n\n    async def three(scope, send):\n        await asgi_send_json(\n            send, {\"hello\": \"world\"}, status=200, headers={\"x-three\": \"1\"}\n        )\n\n    async def post(request):\n        if request.method == \"GET\":\n            return Response.html(request.scope[\"csrftoken\"]())\n        else:\n            return Response.json(await request.post_vars())\n\n    async def csrftoken_form(request, datasette):\n        return Response.html(\n            await datasette.render_template(\"csrftoken_form.html\", request=request)\n        )\n\n    def not_async():\n        return Response.html(\"This was not async\")\n\n    def add_message(datasette, request):\n        datasette.add_message(request, \"Hello from messages\")\n        return Response.html(\"Added message\")\n\n    async def render_message(datasette, request):\n        return Response.html(\n            await datasette.render_template(\"render_message.html\", request=request)\n        )\n\n    def login_as_root(datasette, request):\n        # Mainly for the latest.datasette.io demo\n        if request.method == \"POST\":\n            response = Response.redirect(\"/\")\n            datasette.set_actor_cookie(response, {\"id\": \"root\"})\n            return response\n        return Response.html(\"\"\"\n            <form action=\"{}\" method=\"POST\">\n                <p>\n                    <input type=\"hidden\" name=\"csrftoken\" value=\"{}\">\n                    <input type=\"submit\"\n                      value=\"Sign in as root user\"\n                      style=\"font-size: 2em; padding: 0.1em 0.5em;\">\n                </p>\n            </form>\n        \"\"\".format(request.path, request.scope[\"csrftoken\"]()))\n\n    def asgi_scope(scope):\n        return Response.json(scope, default=repr)\n\n    async def parallel_queries(datasette):\n        db = datasette.get_database()\n        with tracer.trace_child_tasks():\n            one, two = await asyncio.gather(\n                db.execute(\"select coalesce(sleep(0.1), 1)\"),\n                db.execute(\"select coalesce(sleep(0.1), 2)\"),\n            )\n        return Response.json({\"one\": one.single_value(), \"two\": two.single_value()})\n\n    return [\n        (r\"/one/$\", one),\n        (r\"/two/(?P<name>.*)$\", two),\n        (r\"/three/$\", three),\n        (r\"/post/$\", post),\n        (r\"/csrftoken-form/$\", csrftoken_form),\n        (r\"/login-as-root$\", login_as_root),\n        (r\"/not-async/$\", not_async),\n        (r\"/add-message/$\", add_message),\n        (r\"/render-message/$\", render_message),\n        (r\"/asgi-scope$\", asgi_scope),\n        (r\"/parallel-queries$\", parallel_queries),\n    ]\n\n\n@hookimpl\ndef startup(datasette):\n    datasette._startup_hook_fired = True\n\n    # And test some import shortcuts too\n    from datasette import Response\n    from datasette import Forbidden\n    from datasette import NotFound\n    from datasette import hookimpl\n    from datasette import actor_matches_allow\n\n    _ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)\n\n\n@hookimpl\ndef canned_queries(datasette, database, actor):\n    return {\"from_hook\": f\"select 1, '{actor['id'] if actor else 'null'}' as actor_id\"}\n\n\n@hookimpl\ndef register_magic_parameters():\n    from uuid import uuid4\n\n    def uuid(key, request):\n        if key == \"new\":\n            return str(uuid4())\n        else:\n            raise KeyError\n\n    def request(key, request):\n        if key == \"http_version\":\n            return request.scope[\"http_version\"]\n        else:\n            raise KeyError\n\n    async def asyncrequest(key, request):\n        return key\n\n    return [\n        (\"request\", request),\n        (\"uuid\", uuid),\n        (\"asyncrequest\", asyncrequest),\n    ]\n\n\n@hookimpl\ndef forbidden(datasette, request, message):\n    datasette._last_forbidden_message = message\n    if request.path == \"/data2\":\n        return Response.redirect(\"/login?message=\" + message)\n\n\n@hookimpl\ndef menu_links(datasette, actor, request):\n    if actor:\n        label = \"Hello\"\n        if request.args.get(\"_hello\"):\n            label += \", \" + request.args[\"_hello\"]\n        return [{\"href\": datasette.urls.instance(), \"label\": label}]\n\n\n@hookimpl\ndef table_actions(datasette, database, table, actor):\n    if actor:\n        return [\n            {\n                \"href\": datasette.urls.instance(),\n                \"label\": f\"Database: {database}\",\n            },\n            {\"href\": datasette.urls.instance(), \"label\": f\"Table: {table}\"},\n        ]\n\n\n@hookimpl\ndef view_actions(datasette, database, view, actor):\n    if actor:\n        return [\n            {\n                \"href\": datasette.urls.instance(),\n                \"label\": f\"Database: {database}\",\n            },\n            {\"href\": datasette.urls.instance(), \"label\": f\"View: {view}\"},\n        ]\n\n\n@hookimpl\ndef query_actions(datasette, database, query_name, sql):\n    # Don't explain an explain\n    if sql.lower().startswith(\"explain\"):\n        return\n    return [\n        {\n            \"href\": datasette.urls.database(database)\n            + \"/-/query\"\n            + \"?\"\n            + urllib.parse.urlencode(\n                {\n                    \"sql\": \"explain \" + sql,\n                }\n            ),\n            \"label\": \"Explain this query\",\n            \"description\": \"Runs a SQLite explain\",\n        },\n    ]\n\n\n@hookimpl\ndef row_actions(datasette, database, table, actor, row):\n    if actor:\n        return [\n            {\n                \"href\": datasette.urls.instance(),\n                \"label\": f\"Row details for {actor['id']}\",\n                \"description\": json.dumps(dict(row), default=repr),\n            },\n        ]\n\n\n@hookimpl\ndef database_actions(datasette, database, actor, request):\n    if actor:\n        label = f\"Database: {database}\"\n        if request.args.get(\"_hello\"):\n            label += \" - \" + request.args[\"_hello\"]\n        return [\n            {\n                \"href\": datasette.urls.instance(),\n                \"label\": label,\n            }\n        ]\n\n\n@hookimpl\ndef homepage_actions(datasette, actor, request):\n    if actor:\n        label = f\"Custom homepage for: {actor['id']}\"\n        return [\n            {\n                \"href\": datasette.urls.path(\"/-/custom-homepage\"),\n                \"label\": label,\n            }\n        ]\n\n\n@hookimpl\ndef skip_csrf(scope):\n    return scope[\"path\"] == \"/skip-csrf\"\n\n\n@hookimpl\ndef register_actions(datasette):\n    extras_old = datasette.plugin_config(\"datasette-register-permissions\") or {}\n    extras_new = datasette.plugin_config(\"datasette-register-actions\") or {}\n\n    actions = [\n        Action(\n            name=\"action-from-plugin\",\n            abbr=\"ap\",\n            description=\"New action added by a plugin\",\n            resource_class=DatabaseResource,\n        ),\n        Action(\n            name=\"view-collection\",\n            abbr=\"vc\",\n            description=\"View a collection\",\n            resource_class=DatabaseResource,\n        ),\n        # Test actions for test_hook_custom_allowed (global actions - no resource_class)\n        Action(\n            name=\"this_is_allowed\",\n            abbr=None,\n            description=None,\n        ),\n        Action(\n            name=\"this_is_denied\",\n            abbr=None,\n            description=None,\n        ),\n        Action(\n            name=\"this_is_allowed_async\",\n            abbr=None,\n            description=None,\n        ),\n        Action(\n            name=\"this_is_denied_async\",\n            abbr=None,\n            description=None,\n        ),\n    ]\n\n    # Support old-style config for backwards compatibility\n    if extras_old:\n        for p in extras_old[\"permissions\"]:\n            # Map old takes_database/takes_resource to new global/resource_class\n            if p.get(\"takes_database\"):\n                # Has database -> DatabaseResource\n                actions.append(\n                    Action(\n                        name=p[\"name\"],\n                        abbr=p[\"abbr\"],\n                        description=p[\"description\"],\n                        resource_class=DatabaseResource,\n                    )\n                )\n            else:\n                # No database -> global action (no resource_class)\n                actions.append(\n                    Action(\n                        name=p[\"name\"],\n                        abbr=p[\"abbr\"],\n                        description=p[\"description\"],\n                    )\n                )\n\n    # Support new-style config\n    if extras_new:\n        for a in extras_new[\"actions\"]:\n            # Check if this is a global action (no resource_class specified)\n            if not a.get(\"resource_class\"):\n                actions.append(\n                    Action(\n                        name=a[\"name\"],\n                        abbr=a[\"abbr\"],\n                        description=a[\"description\"],\n                    )\n                )\n            else:\n                # Map string resource_class to actual class\n                resource_class_map = {\n                    \"DatabaseResource\": DatabaseResource,\n                }\n                resource_class = resource_class_map.get(\n                    a.get(\"resource_class\", \"DatabaseResource\"), DatabaseResource\n                )\n\n                actions.append(\n                    Action(\n                        name=a[\"name\"],\n                        abbr=a[\"abbr\"],\n                        description=a[\"description\"],\n                        resource_class=resource_class,\n                    )\n                )\n\n    return actions\n\n\n@hookimpl\ndef permission_resources_sql(datasette, actor, action):\n    from datasette.permissions import PermissionSQL\n\n    # Handle test actions used in test_hook_custom_allowed\n    if action == \"this_is_allowed\":\n        return PermissionSQL.allow(reason=\"test plugin allows this_is_allowed\")\n    elif action == \"this_is_denied\":\n        return PermissionSQL.deny(reason=\"test plugin denies this_is_denied\")\n    elif action == \"this_is_allowed_async\":\n        return PermissionSQL.allow(reason=\"test plugin allows this_is_allowed_async\")\n    elif action == \"this_is_denied_async\":\n        return PermissionSQL.deny(reason=\"test plugin denies this_is_denied_async\")\n    elif action == \"view-database-download\":\n        # Return rule based on actor's can_download permission\n        if actor and actor.get(\"can_download\"):\n            return PermissionSQL.allow(reason=\"actor has can_download\")\n        else:\n            return None  # No opinion\n    elif action == \"view-database\":\n        # Also grant view-database if actor has can_download (needed for download to work)\n        if actor and actor.get(\"can_download\"):\n            return PermissionSQL.allow(\n                reason=\"actor has can_download, grants view-database\"\n            )\n        else:\n            return None\n    elif action in (\n        \"insert-row\",\n        \"create-table\",\n        \"drop-table\",\n        \"delete-row\",\n        \"update-row\",\n    ):\n        # Special permissions for latest.datasette.io demos\n        actor_id = actor.get(\"id\") if actor else None\n        if actor_id == \"todomvc\":\n            return PermissionSQL.allow(reason=f\"todomvc actor allowed for {action}\")\n\n    return None\n\n\nclass HardcodedTokenHandler(TokenHandler):\n    name = \"hardcoded\"\n    _counter = 0\n\n    async def create_token(\n        self,\n        datasette,\n        actor_id,\n        *,\n        expires_after=None,\n        restrictions=None,\n    ):\n        HardcodedTokenHandler._counter += 1\n        return f\"dstok_hardcoded_token_{HardcodedTokenHandler._counter}\"\n\n    async def verify_token(self, datasette, token):\n        if token.startswith(\"dstok_hardcoded_token_\"):\n            return {\"id\": \"hardcoded-actor\", \"token\": \"hardcoded\"}\n        return None\n\n\n@hookimpl\ndef register_token_handler(datasette):\n    return HardcodedTokenHandler()\n"
  },
  {
    "path": "tests/plugins/my_plugin_2.py",
    "content": "from datasette import hookimpl\nfrom datasette.utils.asgi import Response\nfrom functools import wraps\nimport markupsafe\nimport json\n\n\n@hookimpl\ndef extra_js_urls():\n    return [\n        {\n            \"url\": \"https://plugin-example.datasette.io/jquery.js\",\n            \"sri\": \"SRIHASH\",\n        },\n        \"https://plugin-example.datasette.io/plugin2.js\",\n    ]\n\n\n@hookimpl\ndef render_cell(value, database):\n    # Render {\"href\": \"...\", \"label\": \"...\"} as link\n    if not isinstance(value, str):\n        return None\n    stripped = value.strip()\n    if not stripped.startswith(\"{\") and stripped.endswith(\"}\"):\n        return None\n    try:\n        data = json.loads(value)\n    except ValueError:\n        return None\n    if not isinstance(data, dict):\n        return None\n    if set(data.keys()) != {\"href\", \"label\"}:\n        return None\n    href = data[\"href\"]\n    if not (\n        href.startswith(\"/\")\n        or href.startswith(\"http://\")\n        or href.startswith(\"https://\")\n    ):\n        return None\n    return markupsafe.Markup(\n        '<a data-database=\"{database}\" href=\"{href}\">{label}</a>'.format(\n            database=database,\n            href=markupsafe.escape(data[\"href\"]),\n            label=markupsafe.escape(data[\"label\"] or \"\") or \"&nbsp;\",\n        )\n    )\n\n\n@hookimpl\ndef extra_template_vars(template, database, table, view_name, request, datasette):\n    # This helps unit tests that want to run assertions against the request object:\n    datasette._last_request = request\n\n    async def query_database(sql):\n        first_db = list(datasette.databases.keys())[0]\n        return (await datasette.execute(first_db, sql)).rows[0][0]\n\n    async def inner():\n        return {\n            \"extra_template_vars_from_awaitable\": json.dumps(\n                {\n                    \"template\": template,\n                    \"scope_path\": request.scope[\"path\"] if request else None,\n                    \"awaitable\": True,\n                },\n                default=lambda b: b.decode(\"utf8\"),\n            ),\n            \"query_database\": query_database,\n        }\n\n    return inner\n\n\n@hookimpl\ndef asgi_wrapper(datasette):\n    def wrap_with_databases_header(app):\n        @wraps(app)\n        async def add_x_databases_header(scope, receive, send):\n            async def wrapped_send(event):\n                if event[\"type\"] == \"http.response.start\":\n                    original_headers = event.get(\"headers\") or []\n                    event = {\n                        \"type\": event[\"type\"],\n                        \"status\": event[\"status\"],\n                        \"headers\": original_headers\n                        + [\n                            [\n                                b\"x-databases\",\n                                \", \".join(datasette.databases.keys()).encode(\"utf-8\"),\n                            ]\n                        ],\n                    }\n                await send(event)\n\n            await app(scope, receive, wrapped_send)\n\n        return add_x_databases_header\n\n    return wrap_with_databases_header\n\n\n@hookimpl\ndef actor_from_request(datasette, request):\n    async def inner():\n        if request.args.get(\"_bot2\"):\n            result = await datasette.get_database().execute(\"select 1 + 1\")\n            return {\"id\": \"bot2\", \"1+1\": result.first()[0]}\n        else:\n            return None\n\n    return inner\n\n\n@hookimpl\ndef prepare_jinja2_environment(env, datasette):\n    env.filters[\"format_numeric\"] = lambda s: f\"{float(s):,.0f}\"\n    env.filters[\"to_hello\"] = lambda s: datasette._HELLO\n\n\n@hookimpl\ndef startup(datasette):\n    async def inner():\n        # Run against _internal so tests that use the ds_client fixture\n        # (which has no databases yet on startup) do not fail:\n        internal_db = datasette.get_internal_database()\n        result = await internal_db.execute(\"select 1 + 1\")\n        datasette._startup_hook_calculation = result.first()[0]\n        # Check that metadata tables have been populated before startup fires\n        metadata_rows = await internal_db.execute(\n            \"select key, value from metadata_instance\"\n        )\n        datasette._startup_metadata_keys = [row[\"key\"] for row in metadata_rows]\n        # Check that catalog/schema tables have been populated before startup fires\n        catalog_rows = await internal_db.execute(\n            \"select database_name from catalog_databases\"\n        )\n        datasette._startup_catalog_databases = [\n            row[\"database_name\"] for row in catalog_rows\n        ]\n\n    return inner\n\n\n@hookimpl\ndef canned_queries(datasette, database):\n    async def inner():\n        return {\n            \"from_async_hook\": \"select {}\".format(\n                (\n                    await datasette.get_database(database).execute(\"select 1 + 1\")\n                ).first()[0]\n            )\n        }\n\n    return inner\n\n\n@hookimpl(trylast=True)\ndef menu_links(datasette, actor):\n    async def inner():\n        if actor:\n            return [{\"href\": datasette.urls.instance(), \"label\": \"Hello 2\"}]\n\n    return inner\n\n\n@hookimpl\ndef table_actions(datasette, database, table, actor, request):\n    async def inner():\n        if actor:\n            label = \"From async\"\n            if request.args.get(\"_hello\"):\n                label += \" \" + request.args[\"_hello\"]\n            return [{\"href\": datasette.urls.instance(), \"label\": label}]\n\n    return inner\n\n\n@hookimpl\ndef register_routes(datasette):\n    config = datasette.plugin_config(\"register-route-demo\")\n    if not config:\n        return\n    path = config[\"path\"]\n\n    def new_table(request):\n        return Response.text(\"/db/table: {}\".format(sorted(request.url_vars.items())))\n\n    return [\n        (r\"/{}/$\".format(path), lambda: Response.text(path.upper())),\n        # Also serves to demonstrate over-ride of default paths:\n        (r\"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)\", new_table),\n    ]\n\n\n@hookimpl\ndef handle_exception(datasette, request, exception):\n    datasette._exception_hook_fired = (request, exception)\n    if request.args.get(\"_custom_error\"):\n        return Response.text(\"_custom_error\")\n    elif request.args.get(\"_custom_error_async\"):\n\n        async def inner():\n            return Response.text(\"_custom_error_async\")\n\n        return inner\n\n\n@hookimpl(specname=\"register_routes\")\ndef register_triger_error():\n    return ((r\"/trigger-error\", lambda: 1 / 0),)\n"
  },
  {
    "path": "tests/plugins/register_output_renderer.py",
    "content": "from datasette import hookimpl\nfrom datasette.utils.asgi import Response\nimport json\n\n\nasync def can_render(\n    datasette, columns, rows, sql, query_name, database, table, request, view_name\n):\n    # We stash this on datasette so the calling unit test can see it\n    datasette._can_render_saw = {\n        \"datasette\": datasette,\n        \"columns\": columns,\n        \"rows\": rows,\n        \"sql\": sql,\n        \"query_name\": query_name,\n        \"database\": database,\n        \"table\": table,\n        \"request\": request,\n        \"view_name\": view_name,\n    }\n    if request.args.get(\"_no_can_render\"):\n        return False\n    return True\n\n\nasync def render_test_all_parameters(\n    datasette, columns, rows, sql, query_name, database, table, request, view_name, data\n):\n    headers = {}\n    for custom_header in request.args.getlist(\"header\"):\n        key, value = custom_header.split(\":\")\n        headers[key] = value\n    result = await datasette.databases[\"fixtures\"].execute(\"select 1 + 1\")\n    return {\n        \"body\": json.dumps(\n            {\n                \"datasette\": datasette,\n                \"columns\": columns,\n                \"rows\": rows,\n                \"sql\": sql,\n                \"query_name\": query_name,\n                \"database\": database,\n                \"table\": table,\n                \"request\": request,\n                \"view_name\": view_name,\n                \"1+1\": result.first()[0],\n            },\n            default=repr,\n        ),\n        \"content_type\": request.args.get(\"content_type\", \"text/plain\"),\n        \"status_code\": int(request.args.get(\"status_code\", 200)),\n        \"headers\": headers,\n    }\n\n\ndef render_test_no_parameters():\n    return {\"body\": \"Hello\"}\n\n\nasync def render_response(request):\n    if request.args.get(\"_broken\"):\n        return \"this should break\"\n    return Response.json({\"this_is\": \"json\"})\n\n\n@hookimpl\ndef register_output_renderer(datasette):\n    return [\n        {\n            \"extension\": \"testall\",\n            \"render\": render_test_all_parameters,\n            \"can_render\": can_render,\n        },\n        {\"extension\": \"testnone\", \"callback\": render_test_no_parameters},\n        {\"extension\": \"testresponse\", \"render\": render_response},\n    ]\n"
  },
  {
    "path": "tests/plugins/sleep_sql_function.py",
    "content": "from datasette import hookimpl\nimport time\n\n\n@hookimpl\ndef prepare_connection(conn):\n    conn.create_function(\"sleep\", 1, lambda n: time.sleep(float(n)))\n"
  },
  {
    "path": "tests/plugins/view_name.py",
    "content": "from datasette import hookimpl\n\n\n@hookimpl\ndef extra_template_vars(view_name, request):\n    return {\n        \"view_name\": view_name,\n        \"request\": request,\n    }\n"
  },
  {
    "path": "tests/test-datasette-load-plugins.sh",
    "content": "#!/bin/bash\n# This should only run in environments where both\n# datasette-init and datasette-json-html are installed\n\nPLUGINS=$(datasette plugins)\nif ! echo \"$PLUGINS\" | jq 'any(.[]; .name == \"datasette-json-html\")' | grep -q true; then\n  echo \"Test failed: datasette-json-html not found\"\n  exit 1\nfi\n\nPLUGINS2=$(DATASETTE_LOAD_PLUGINS=datasette-init datasette plugins)\nif ! echo \"$PLUGINS2\" | jq 'any(.[]; .name == \"datasette-json-html\")' | grep -q false; then\n  echo \"Test failed: datasette-json-html should not have been loaded\"\n  exit 1\nfi\n\nif ! echo \"$PLUGINS2\" | jq 'any(.[]; .name == \"datasette-init\")' | grep -q true; then\n  echo \"Test failed: datasette-init should have been loaded\"\n  exit 1\nfi\n\nPLUGINS3=$(DATASETTE_LOAD_PLUGINS='' datasette plugins)\nif ! echo \"$PLUGINS3\" | grep -q '\\[\\]'; then\n  echo \"Test failed: datasette plugins should have returned []\"\n  exit 1\nfi\n"
  },
  {
    "path": "tests/test_actions_sql.py",
    "content": "\"\"\"\nTests for the new Resource-based permission system.\n\nThese tests verify:\n1. The new Datasette.allowed_resources() method (with pagination)\n2. The new Datasette.allowed() method\n3. The include_reasons parameter for debugging\n4. That SQL does the heavy lifting (no Python filtering)\n\"\"\"\n\nimport pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\nfrom datasette.permissions import PermissionSQL\nfrom datasette.resources import TableResource\nfrom datasette import hookimpl\n\n\n# Test plugin that provides permission rules\nclass PermissionRulesPlugin:\n    def __init__(self, rules_callback):\n        self.rules_callback = rules_callback\n\n    @hookimpl\n    def permission_resources_sql(self, datasette, actor, action):\n        \"\"\"Return permission rules based on the callback\"\"\"\n        return self.rules_callback(datasette, actor, action)\n\n\n@pytest_asyncio.fixture\nasync def test_ds():\n    \"\"\"Create a test Datasette instance with sample data\"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add test databases with some tables\n    db = ds.add_memory_database(\"analytics\")\n    await db.execute_write(\"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)\")\n    await db.execute_write(\"CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)\")\n    await db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)\"\n    )\n\n    db2 = ds.add_memory_database(\"production\")\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)\"\n    )\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)\"\n    )\n\n    # Refresh schemas to populate catalog_tables in internal database\n    await ds._refresh_schemas()\n\n    return ds\n\n\n@pytest.mark.asyncio\nasync def test_allowed_resources_global_allow(test_ds):\n    \"\"\"Test allowed_resources() with a global allow rule\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"alice\":\n            sql = \"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        # Use the new allowed_resources() method\n        result = await test_ds.allowed_resources(\"view-table\", {\"id\": \"alice\"})\n        tables = result.resources\n\n        # Alice should see all tables\n        assert len(tables) == 5\n        assert all(isinstance(t, TableResource) for t in tables)\n\n        # Check specific tables are present\n        table_set = set((t.parent, t.child) for t in tables)\n        assert (\"analytics\", \"events\") in table_set\n        assert (\"analytics\", \"users\") in table_set\n        assert (\"analytics\", \"sensitive\") in table_set\n        assert (\"production\", \"customers\") in table_set\n        assert (\"production\", \"orders\") in table_set\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_allowed_specific_resource(test_ds):\n    \"\"\"Test allowed() method checks specific resource efficiently\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"role\") == \"analyst\":\n            # Allow analytics database, deny everything else (global deny)\n            sql = \"\"\"\n                SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason\n                UNION ALL\n                SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        actor = {\"id\": \"bob\", \"role\": \"analyst\"}\n\n        # Check specific resources using allowed()\n        # This should use SQL WHERE clause, not fetch all resources\n        assert await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"users\"),\n            actor=actor,\n        )\n        assert await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"events\"),\n            actor=actor,\n        )\n        assert not await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"production\", \"orders\"),\n            actor=actor,\n        )\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_allowed_resources_include_reasons(test_ds):\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"role\") == \"analyst\":\n            sql = \"\"\"\n                SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,\n                       'parent: analyst access to analytics' AS reason\n                UNION ALL\n                SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,\n                       'child: sensitive data denied' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        # Use allowed_resources with include_reasons to get debugging info\n        result = await test_ds.allowed_resources(\n            \"view-table\", {\"id\": \"bob\", \"role\": \"analyst\"}, include_reasons=True\n        )\n        allowed = result.resources\n\n        # Should get analytics tables except sensitive\n        assert len(allowed) >= 2  # At least users and events\n\n        # Check we can access both resource and reason\n        for resource in allowed:\n            assert isinstance(resource, TableResource)\n            assert isinstance(resource.reasons, list)\n            if resource.parent == \"analytics\":\n                # Should mention parent-level reason in at least one of the reasons\n                reasons_text = \" \".join(resource.reasons).lower()\n                assert \"analyst access\" in reasons_text\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_child_deny_overrides_parent_allow(test_ds):\n    \"\"\"Test that child-level DENY beats parent-level ALLOW\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"role\") == \"analyst\":\n            sql = \"\"\"\n                SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,\n                       'parent: allow analytics' AS reason\n                UNION ALL\n                SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,\n                       'child: deny sensitive' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        actor = {\"id\": \"bob\", \"role\": \"analyst\"}\n        result = await test_ds.allowed_resources(\"view-table\", actor)\n        tables = result.resources\n\n        # Should see analytics tables except sensitive\n        analytics_tables = [t for t in tables if t.parent == \"analytics\"]\n        assert len(analytics_tables) >= 2\n\n        table_names = {t.child for t in analytics_tables}\n        assert \"users\" in table_names\n        assert \"events\" in table_names\n        assert \"sensitive\" not in table_names\n\n        # Verify with allowed() method\n        assert await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"users\"),\n            actor=actor,\n        )\n        assert not await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"sensitive\"),\n            actor=actor,\n        )\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_child_allow_overrides_parent_deny(test_ds):\n    \"\"\"Test that child-level ALLOW beats parent-level DENY\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"carol\":\n            sql = \"\"\"\n                SELECT 'production' AS parent, NULL AS child, 0 AS allow,\n                       'parent: deny production' AS reason\n                UNION ALL\n                SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,\n                       'child: carol can see orders' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        actor = {\"id\": \"carol\"}\n        result = await test_ds.allowed_resources(\"view-table\", actor)\n        tables = result.resources\n\n        # Should only see production.orders\n        production_tables = [t for t in tables if t.parent == \"production\"]\n        assert len(production_tables) == 1\n        assert production_tables[0].child == \"orders\"\n\n        # Verify with allowed() method\n        assert await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"production\", \"orders\"),\n            actor=actor,\n        )\n        assert not await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"production\", \"customers\"),\n            actor=actor,\n        )\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_sql_does_filtering_not_python(test_ds):\n    \"\"\"\n    Verify that allowed() uses SQL WHERE clause, not Python filtering.\n\n    This test doesn't actually verify the SQL itself (that would require\n    query introspection), but it demonstrates the API contract.\n    \"\"\"\n\n    def rules_callback(datasette, actor, action):\n        # Deny everything by default, allow only analytics.users specifically\n        sql = \"\"\"\n            SELECT NULL AS parent, NULL AS child, 0 AS allow,\n                   'global deny' AS reason\n            UNION ALL\n            SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,\n                   'specific allow' AS reason\n        \"\"\"\n        return PermissionSQL(sql=sql)\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        actor = {\"id\": \"dave\"}\n\n        # allowed() should execute a targeted SQL query\n        # NOT fetch all resources and filter in Python\n        assert await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"users\"),\n            actor=actor,\n        )\n        assert not await test_ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"analytics\", \"events\"),\n            actor=actor,\n        )\n\n        # allowed_resources() should also use SQL filtering\n        result = await test_ds.allowed_resources(\"view-table\", actor)\n        tables = result.resources\n        assert len(tables) == 1\n        assert tables[0].parent == \"analytics\"\n        assert tables[0].child == \"users\"\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n"
  },
  {
    "path": "tests/test_actor_restriction_bug.py",
    "content": "\"\"\"\nTest for actor restrictions bug with database-level config.\n\nThis test currently FAILS, demonstrating the bug where database-level\nconfig allow blocks can bypass table-level restrictions.\n\"\"\"\n\nimport pytest\nfrom datasette.app import Datasette\nfrom datasette.resources import TableResource\n\n\n@pytest.mark.asyncio\nasync def test_table_restrictions_not_bypassed_by_database_level_config():\n    \"\"\"\n    Actor restrictions should act as hard limits that config cannot override.\n\n    BUG: When an actor has table-level restrictions (e.g., only table2 and table3)\n    but config has a database-level allow block, the database-level config rule\n    currently allows ALL tables, not just those in the restriction allowlist.\n\n    This test documents the expected behavior and will FAIL until the bug is fixed.\n    \"\"\"\n    # Config grants access at DATABASE level (not table level)\n    config = {\n        \"databases\": {\n            \"test_db_rnbbdlc\": {\n                \"allow\": {\n                    \"id\": \"user\"\n                }  # Database-level allow - grants access to all tables\n            }\n        }\n    }\n\n    ds = Datasette(config=config)\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_db_rnbbdlc\")\n    await db.execute_write(\"create table table1 (id integer primary key)\")\n    await db.execute_write(\"create table table2 (id integer primary key)\")\n    await db.execute_write(\"create table table3 (id integer primary key)\")\n    await db.execute_write(\"create table table4 (id integer primary key)\")\n\n    # Actor restricted to ONLY table2 and table3\n    # Even though config allows the whole database, restrictions should limit access\n    actor = {\n        \"id\": \"user\",\n        \"_r\": {\n            \"r\": {  # Resource-level (table-level) restrictions\n                \"test_db_rnbbdlc\": {\n                    \"table2\": [\"vt\"],  # vt = view-table abbreviation\n                    \"table3\": [\"vt\"],\n                }\n            }\n        },\n    }\n\n    # table2 should be allowed (in restriction allowlist AND config allows)\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rnbbdlc\", \"table2\"),\n        actor=actor,\n    )\n    assert result is True, \"table2 should be allowed - in restriction allowlist\"\n\n    # table3 should be allowed (in restriction allowlist AND config allows)\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rnbbdlc\", \"table3\"),\n        actor=actor,\n    )\n    assert result is True, \"table3 should be allowed - in restriction allowlist\"\n\n    # table1 should be DENIED (NOT in restriction allowlist)\n    # Even though database-level config allows it, restrictions should deny it\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rnbbdlc\", \"table1\"),\n        actor=actor,\n    )\n    assert (\n        result is False\n    ), \"table1 should be DENIED - not in restriction allowlist, config cannot override\"\n\n    # table4 should be DENIED (NOT in restriction allowlist)\n    # Even though database-level config allows it, restrictions should deny it\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rnbbdlc\", \"table4\"),\n        actor=actor,\n    )\n    assert (\n        result is False\n    ), \"table4 should be DENIED - not in restriction allowlist, config cannot override\"\n\n\n@pytest.mark.asyncio\nasync def test_database_restrictions_with_database_level_config():\n    \"\"\"\n    Verify that database-level restrictions work correctly with database-level config.\n\n    This should pass - it's testing the case where restriction granularity\n    matches config granularity.\n    \"\"\"\n    config = {\n        \"databases\": {\"test_db_rwdl\": {\"allow\": {\"id\": \"user\"}}}  # Database-level allow\n    }\n\n    ds = Datasette(config=config)\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_db_rwdl\")\n    await db.execute_write(\"create table table1 (id integer primary key)\")\n    await db.execute_write(\"create table table2 (id integer primary key)\")\n\n    # Actor has database-level restriction (all tables in test_db_rwdl)\n    actor = {\n        \"id\": \"user\",\n        \"_r\": {\"d\": {\"test_db_rwdl\": [\"vt\"]}},  # Database-level restrictions\n    }\n\n    # Both tables should be allowed (database-level restriction matches database-level config)\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rwdl\", \"table1\"),\n        actor=actor,\n    )\n    assert result is True, \"table1 should be allowed\"\n\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"test_db_rwdl\", \"table2\"),\n        actor=actor,\n    )\n    assert result is True, \"table2 should be allowed\"\n"
  },
  {
    "path": "tests/test_allowed_resources.py",
    "content": "\"\"\"\nTests for the allowed_resources() API.\n\nThese tests verify that the allowed_resources() API correctly filters resources\nbased on permission rules from plugins and configuration.\n\"\"\"\n\nimport pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\nfrom datasette.permissions import PermissionSQL\nfrom datasette import hookimpl\n\n\n# Test plugin that provides permission rules\nclass PermissionRulesPlugin:\n    def __init__(self, rules_callback):\n        self.rules_callback = rules_callback\n\n    @hookimpl\n    def permission_resources_sql(self, datasette, actor, action):\n        return self.rules_callback(datasette, actor, action)\n\n\n@pytest_asyncio.fixture(scope=\"function\")\nasync def test_ds():\n    \"\"\"Create a test Datasette instance with sample data (fresh for each test)\"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add test databases with some tables\n    db = ds.add_memory_database(\"analytics\")\n    await db.execute_write(\"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)\")\n    await db.execute_write(\"CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)\")\n    await db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)\"\n    )\n\n    db2 = ds.add_memory_database(\"production\")\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)\"\n    )\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)\"\n    )\n\n    # Refresh schemas to populate catalog_tables in internal database\n    await ds._refresh_schemas()\n\n    return ds\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_global_access(test_ds):\n    \"\"\"Test /-/tables with global access permissions\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"alice\":\n            sql = \"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        # Use the allowed_resources API directly\n        page = await test_ds.allowed_resources(\"view-table\", {\"id\": \"alice\"})\n\n        # Convert to the format the endpoint returns\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        # Alice should see all tables\n        assert len(result) == 5\n        table_names = {m[\"name\"] for m in result}\n        assert \"analytics/events\" in table_names\n        assert \"analytics/users\" in table_names\n        assert \"analytics/sensitive\" in table_names\n        assert \"production/customers\" in table_names\n        assert \"production/orders\" in table_names\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_database_restriction(test_ds):\n    \"\"\"Test /-/tables with database-level restriction\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"role\") == \"analyst\":\n            # Allow only analytics database\n            sql = \"SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        page = await test_ds.allowed_resources(\n            \"view-table\", {\"id\": \"bob\", \"role\": \"analyst\"}\n        )\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        # Bob should only see analytics tables\n        analytics_tables = [m for m in result if m[\"name\"].startswith(\"analytics/\")]\n\n        assert len(analytics_tables) == 3\n        table_names = {m[\"name\"] for m in analytics_tables}\n        assert \"analytics/events\" in table_names\n        assert \"analytics/users\" in table_names\n        assert \"analytics/sensitive\" in table_names\n\n        # Should not see production tables (unless default_permissions allows them)\n        # Note: default_permissions.py provides default allows, so we just check analytics are present\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_table_exception(test_ds):\n    \"\"\"Test /-/tables with table-level exception (deny database, allow specific table)\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"carol\":\n            # Deny analytics database, but allow analytics.users specifically\n            sql = \"\"\"\n                SELECT 'analytics' AS parent, NULL AS child, 0 AS allow, 'deny analytics' AS reason\n                UNION ALL\n                SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        page = await test_ds.allowed_resources(\"view-table\", {\"id\": \"carol\"})\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        # Carol should see analytics.users but not other analytics tables\n        analytics_tables = [m for m in result if m[\"name\"].startswith(\"analytics/\")]\n        assert len(analytics_tables) == 1\n        table_names = {m[\"name\"] for m in analytics_tables}\n        assert \"analytics/users\" in table_names\n\n        # Should NOT see analytics.events or analytics.sensitive\n        assert \"analytics/events\" not in table_names\n        assert \"analytics/sensitive\" not in table_names\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_deny_overrides_allow(test_ds):\n    \"\"\"Test that child-level DENY beats parent-level ALLOW\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"role\") == \"analyst\":\n            # Allow analytics, but deny sensitive table\n            sql = \"\"\"\n                SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'allow analytics' AS reason\n                UNION ALL\n                SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        page = await test_ds.allowed_resources(\n            \"view-table\", {\"id\": \"bob\", \"role\": \"analyst\"}\n        )\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        analytics_tables = [m for m in result if m[\"name\"].startswith(\"analytics/\")]\n\n        # Should see users and events but NOT sensitive\n        table_names = {m[\"name\"] for m in analytics_tables}\n        assert \"analytics/users\" in table_names\n        assert \"analytics/events\" in table_names\n        assert \"analytics/sensitive\" not in table_names\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_no_permissions():\n    \"\"\"Test /-/tables when user has no custom permissions (only defaults)\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add a single database\n    db = ds.add_memory_database(\"testdb\")\n    await db.execute_write(\"CREATE TABLE items (id INTEGER PRIMARY KEY)\")\n    await ds._refresh_schemas()\n\n    # Unknown actor with no custom permissions\n    page = await ds.allowed_resources(\"view-table\", {\"id\": \"unknown\"})\n    result = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Should see tables (due to default_permissions.py providing default allow)\n    assert len(result) >= 1\n    assert any(m[\"name\"].endswith(\"/items\") for m in result)\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_specific_table_only(test_ds):\n    \"\"\"Test /-/tables when only specific tables are allowed (no parent/global rules)\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"dave\":\n            # Allow only specific tables, no parent-level or global rules\n            sql = \"\"\"\n                SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'specific table 1' AS reason\n                UNION ALL\n                SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason\n            \"\"\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        page = await test_ds.allowed_resources(\"view-table\", {\"id\": \"dave\"})\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        # Should see only the two specifically allowed tables\n        specific_tables = [\n            m for m in result if m[\"name\"] in (\"analytics/users\", \"production/orders\")\n        ]\n\n        assert len(specific_tables) == 2\n        table_names = {m[\"name\"] for m in specific_tables}\n        assert \"analytics/users\" in table_names\n        assert \"production/orders\" in table_names\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_empty_result(test_ds):\n    \"\"\"Test /-/tables when all tables are explicitly denied\"\"\"\n\n    def rules_callback(datasette, actor, action):\n        if actor and actor.get(\"id\") == \"blocked\":\n            # Global deny\n            sql = \"SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason\"\n            return PermissionSQL(sql=sql)\n        return None\n\n    plugin = PermissionRulesPlugin(rules_callback)\n    test_ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        page = await test_ds.allowed_resources(\"view-table\", {\"id\": \"blocked\"})\n        result = [\n            {\n                \"name\": f\"{t.parent}/{t.child}\",\n                \"url\": test_ds.urls.table(t.parent, t.child),\n            }\n            for t in page.resources\n        ]\n\n        # Global deny should block access to all tables\n        assert len(result) == 0\n\n    finally:\n        test_ds.pm.unregister(plugin, name=\"test_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_no_query_returns_all():\n    \"\"\"Test /-/tables without query parameter returns all tables\"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with a few tables\n    db = ds.add_memory_database(\"test_db\")\n    await db.execute_write(\"CREATE TABLE users (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE posts (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE comments (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables without query\n    page = await ds.allowed_resources(\"view-table\", None)\n\n    # Should return all tables with truncated: false\n    assert len(page.resources) >= 3\n    table_names = {f\"{t.parent}/{t.child}\" for t in page.resources}\n    assert \"test_db/users\" in table_names\n    assert \"test_db/posts\" in table_names\n    assert \"test_db/comments\" in table_names\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_truncation():\n    \"\"\"Test /-/tables truncates at 100 tables and sets truncated flag\"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Create a database with 105 tables\n    db = ds.add_memory_database(\"big_db\")\n    for i in range(105):\n        await db.execute_write(f\"CREATE TABLE table_{i:03d} (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables - should be paginated with limit=100 by default\n    page = await ds.allowed_resources(\"view-table\", None)\n    big_db_tables = [t for t in page.resources if t.parent == \"big_db\"]\n\n    # Should have exactly 100 tables in first page (default limit)\n    assert len(big_db_tables) == 100\n    assert page.next is not None  # More results available\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_search_single_term():\n    \"\"\"Test /-/tables?q=user to filter tables matching 'user'\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with various table names\n    db = ds.add_memory_database(\"search_test\")\n    await db.execute_write(\"CREATE TABLE users (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE user_profiles (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE events (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE posts (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables in the new format\n    page = await ds.allowed_resources(\"view-table\", None)\n    matches = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Filter for \"user\" (extract table name from \"db/table\")\n    import re\n\n    pattern = \".*user.*\"\n    regex = re.compile(pattern, re.IGNORECASE)\n    filtered = [m for m in matches if regex.match(m[\"name\"].split(\"/\", 1)[1])]\n\n    # Should match users and user_profiles but not events or posts\n    table_names = {m[\"name\"].split(\"/\", 1)[1] for m in filtered}\n    assert \"users\" in table_names\n    assert \"user_profiles\" in table_names\n    assert \"events\" not in table_names\n    assert \"posts\" not in table_names\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_search_multiple_terms():\n    \"\"\"Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with various table names\n    db = ds.add_memory_database(\"search_test2\")\n    await db.execute_write(\"CREATE TABLE user_profiles (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE users (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE profile_settings (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE events (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables in the new format\n    page = await ds.allowed_resources(\"view-table\", None)\n    matches = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Filter for \"user profile\" (two terms, extract table name from \"db/table\")\n    import re\n\n    terms = [\"user\", \"profile\"]\n    pattern = \".*\" + \".*\".join(re.escape(term) for term in terms) + \".*\"\n    regex = re.compile(pattern, re.IGNORECASE)\n    filtered = [m for m in matches if regex.match(m[\"name\"].split(\"/\", 1)[1])]\n\n    # Should match only user_profiles (has both user and profile in that order)\n    table_names = {m[\"name\"].split(\"/\", 1)[1] for m in filtered}\n    assert \"user_profiles\" in table_names\n    assert \"users\" not in table_names  # doesn't have \"profile\"\n    assert \"profile_settings\" not in table_names  # doesn't have \"user\"\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_search_ordering():\n    \"\"\"Test that search results are ordered by shortest name first\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with tables of various lengths containing \"user\"\n    db = ds.add_memory_database(\"order_test\")\n    await db.execute_write(\"CREATE TABLE users (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE user_profiles (id INTEGER)\")\n    await db.execute_write(\n        \"CREATE TABLE u (id INTEGER)\"\n    )  # Shortest, but doesn't match \"user\"\n    await db.execute_write(\n        \"CREATE TABLE user_authentication_tokens (id INTEGER)\"\n    )  # Longest\n    await db.execute_write(\"CREATE TABLE user_data (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables in the new format\n    page = await ds.allowed_resources(\"view-table\", None)\n    matches = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Filter for \"user\" and sort by table name length\n    import re\n\n    pattern = \".*user.*\"\n    regex = re.compile(pattern, re.IGNORECASE)\n    filtered = [m for m in matches if regex.match(m[\"name\"].split(\"/\", 1)[1])]\n    filtered.sort(key=lambda m: len(m[\"name\"].split(\"/\", 1)[1]))\n\n    # Should be ordered: users, user_data, user_profiles, user_authentication_tokens\n    matching_names = [m[\"name\"].split(\"/\", 1)[1] for m in filtered]\n    assert matching_names[0] == \"users\"  # shortest\n    assert len(matching_names[0]) < len(matching_names[1])\n    assert len(matching_names[-1]) > len(matching_names[-2])\n    assert matching_names[-1] == \"user_authentication_tokens\"  # longest\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_search_case_insensitive():\n    \"\"\"Test that search is case-insensitive\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with mixed case table names\n    db = ds.add_memory_database(\"case_test\")\n    await db.execute_write(\"CREATE TABLE Users (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE USER_PROFILES (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE user_data (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables in the new format\n    page = await ds.allowed_resources(\"view-table\", None)\n    matches = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Filter for \"user\" (lowercase) should match all case variants\n    import re\n\n    pattern = \".*user.*\"\n    regex = re.compile(pattern, re.IGNORECASE)\n    filtered = [m for m in matches if regex.match(m[\"name\"].split(\"/\", 1)[1])]\n\n    # Should match all three tables regardless of case\n    table_names = {m[\"name\"].split(\"/\", 1)[1] for m in filtered}\n    assert \"Users\" in table_names\n    assert \"USER_PROFILES\" in table_names\n    assert \"user_data\" in table_names\n    assert len(filtered) >= 3\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_search_no_matches():\n    \"\"\"Test search with no matching tables returns empty list\"\"\"\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Add database with tables that won't match search\n    db = ds.add_memory_database(\"nomatch_test\")\n    await db.execute_write(\"CREATE TABLE events (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE posts (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Get all tables in the new format\n    page = await ds.allowed_resources(\"view-table\", None)\n    matches = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in page.resources\n    ]\n\n    # Filter for \"zzz\" which doesn't exist\n    import re\n\n    pattern = \".*zzz.*\"\n    regex = re.compile(pattern, re.IGNORECASE)\n    filtered = [m for m in matches if regex.match(m[\"name\"].split(\"/\", 1)[1])]\n\n    # Should return empty list\n    assert len(filtered) == 0\n\n\n@pytest.mark.asyncio\nasync def test_tables_endpoint_config_database_allow():\n    \"\"\"Test that database-level allow blocks work for view-table action\"\"\"\n\n    # Simulate: -s databases.restricted_db.allow.id root\n    config = {\"databases\": {\"restricted_db\": {\"allow\": {\"id\": \"root\"}}}}\n\n    ds = Datasette(config=config)\n    await ds.invoke_startup()\n\n    # Create databases\n    restricted_db = ds.add_memory_database(\"restricted_db\")\n    await restricted_db.execute_write(\"CREATE TABLE users (id INTEGER)\")\n    await restricted_db.execute_write(\"CREATE TABLE posts (id INTEGER)\")\n\n    public_db = ds.add_memory_database(\"public_db\")\n    await public_db.execute_write(\"CREATE TABLE articles (id INTEGER)\")\n\n    await ds._refresh_schemas()\n\n    # Root user should see restricted_db tables\n    root_page = await ds.allowed_resources(\"view-table\", {\"id\": \"root\"})\n    root_list = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in root_page.resources\n    ]\n    restricted_tables_root = [\n        m for m in root_list if m[\"name\"].startswith(\"restricted_db/\")\n    ]\n    assert len(restricted_tables_root) == 2\n    table_names = {m[\"name\"] for m in restricted_tables_root}\n    assert \"restricted_db/users\" in table_names\n    assert \"restricted_db/posts\" in table_names\n\n    # Alice should NOT see restricted_db tables\n    alice_page = await ds.allowed_resources(\"view-table\", {\"id\": \"alice\"})\n    alice_list = [\n        {\"name\": f\"{t.parent}/{t.child}\", \"url\": ds.urls.table(t.parent, t.child)}\n        for t in alice_page.resources\n    ]\n    restricted_tables_alice = [\n        m for m in alice_list if m[\"name\"].startswith(\"restricted_db/\")\n    ]\n    assert len(restricted_tables_alice) == 0\n\n    # But Alice should see public_db tables (no restrictions)\n    public_tables_alice = [m for m in alice_list if m[\"name\"].startswith(\"public_db/\")]\n    assert len(public_tables_alice) == 1\n    assert \"public_db/articles\" in {m[\"name\"] for m in public_tables_alice}\n"
  },
  {
    "path": "tests/test_api.py",
    "content": "from datasette.app import Datasette\nfrom datasette.plugins import DEFAULT_PLUGINS\nfrom datasette.utils.sqlite import sqlite_version\nfrom datasette.version import __version__\nfrom .fixtures import make_app_client, EXPECTED_PLUGINS\nimport pathlib\nimport pytest\nimport sys\nimport urllib\n\n\n@pytest.mark.asyncio\nasync def test_homepage(ds_client):\n    response = await ds_client.get(\"/.json\")\n    assert response.status_code == 200\n    assert \"application/json; charset=utf-8\" == response.headers[\"content-type\"]\n    data = response.json()\n    assert sorted(list(data.get(\"metadata\").keys())) == [\n        \"about\",\n        \"about_url\",\n        \"description_html\",\n        \"license\",\n        \"license_url\",\n        \"source\",\n        \"source_url\",\n        \"title\",\n    ]\n    databases = data.get(\"databases\")\n    assert databases.keys() == {\"fixtures\": 0}.keys()\n    d = databases[\"fixtures\"]\n    assert d[\"name\"] == \"fixtures\"\n    assert isinstance(d[\"tables_count\"], int)\n    assert isinstance(len(d[\"tables_and_views_truncated\"]), int)\n    assert d[\"tables_and_views_more\"] is True\n    assert isinstance(d[\"hidden_tables_count\"], int)\n    assert isinstance(d[\"hidden_table_rows_sum\"], int)\n    assert isinstance(d[\"views_count\"], int)\n\n\n@pytest.mark.asyncio\nasync def test_homepage_sort_by_relationships(ds_client):\n    response = await ds_client.get(\"/.json?_sort=relationships\")\n    assert response.status_code == 200\n    tables = [\n        t[\"name\"]\n        for t in response.json()[\"databases\"][\"fixtures\"][\"tables_and_views_truncated\"]\n    ]\n    assert tables == [\n        \"simple_primary_key\",\n        \"foreign_key_references\",\n        \"complex_foreign_keys\",\n        \"roadside_attraction_characteristics\",\n        \"searchable_tags\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_database_page(ds_client):\n    response = await ds_client.get(\"/fixtures.json\")\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"database\"] == \"fixtures\"\n\n    # Build lookup for easier assertions\n    tables = data[\"tables\"]\n    tables_by_name = {t[\"name\"]: t for t in tables}\n\n    # Verify tables are sorted by (hidden, name) - visible first, then hidden\n    table_names = [t[\"name\"] for t in tables]\n    expected_order = sorted(tables, key=lambda t: (t[\"hidden\"], t[\"name\"]))\n    assert table_names == [t[\"name\"] for t in expected_order]\n\n    # Expected visible tables (not hidden)\n    expected_visible_tables = {\n        \"123_starts_with_digits\",\n        \"Table With Space In Name\",\n        \"attraction_characteristic\",\n        \"binary_data\",\n        \"complex_foreign_keys\",\n        \"compound_primary_key\",\n        \"compound_three_primary_keys\",\n        \"custom_foreign_key_label\",\n        \"facet_cities\",\n        \"facetable\",\n        \"foreign_key_references\",\n        \"infinity\",\n        \"primary_key_multiple_columns\",\n        \"primary_key_multiple_columns_explicit_label\",\n        \"roadside_attraction_characteristics\",\n        \"roadside_attractions\",\n        \"searchable\",\n        \"searchable_tags\",\n        \"select\",\n        \"simple_primary_key\",\n        \"sortable\",\n        \"table/with/slashes.csv\",\n        \"tags\",\n    }\n\n    # Expected hidden tables\n    expected_hidden_tables = {\n        \"no_primary_key\",\n        \"searchable_fts\",\n        \"searchable_fts_config\",\n        \"searchable_fts_data\",\n        \"searchable_fts_docsize\",\n        \"searchable_fts_idx\",\n    }\n\n    # Verify all expected tables exist\n    assert expected_visible_tables.issubset(tables_by_name.keys())\n    assert expected_hidden_tables.issubset(tables_by_name.keys())\n\n    # Verify hidden status\n    visible_tables = {t[\"name\"] for t in tables if not t[\"hidden\"]}\n    hidden_tables = {t[\"name\"] for t in tables if t[\"hidden\"]}\n    assert expected_visible_tables == visible_tables\n    assert expected_hidden_tables == hidden_tables\n\n    # Helper to compare foreign keys (order-insensitive)\n    def fk_set(fks):\n        return {(fk[\"other_table\"], fk[\"column\"], fk[\"other_column\"]) for fk in fks}\n\n    # Test specific table properties\n    # -- facetable: has outgoing FK to facet_cities\n    facetable = tables_by_name[\"facetable\"]\n    assert facetable[\"count\"] == 15\n    assert facetable[\"primary_keys\"] == [\"pk\"]\n    assert facetable[\"fts_table\"] is None\n    assert facetable[\"private\"] is False\n    assert fk_set(facetable[\"foreign_keys\"][\"outgoing\"]) == {\n        (\"facet_cities\", \"_city_id\", \"id\")\n    }\n    assert fk_set(facetable[\"foreign_keys\"][\"incoming\"]) == set()\n\n    # -- facet_cities: has incoming FK from facetable\n    facet_cities = tables_by_name[\"facet_cities\"]\n    assert facet_cities[\"count\"] == 4\n    assert facet_cities[\"columns\"] == [\"id\", \"name\"]\n    assert fk_set(facet_cities[\"foreign_keys\"][\"incoming\"]) == {\n        (\"facetable\", \"id\", \"_city_id\")\n    }\n\n    # -- simple_primary_key: has multiple incoming FKs\n    simple_pk = tables_by_name[\"simple_primary_key\"]\n    assert simple_pk[\"count\"] == 5\n    assert simple_pk[\"columns\"] == [\"id\", \"content\"]\n    assert simple_pk[\"primary_keys\"] == [\"id\"]\n    # Should have incoming FKs from complex_foreign_keys (f1, f2, f3) and foreign_key_references\n    incoming = fk_set(simple_pk[\"foreign_keys\"][\"incoming\"])\n    assert (\"complex_foreign_keys\", \"id\", \"f1\") in incoming\n    assert (\"complex_foreign_keys\", \"id\", \"f2\") in incoming\n    assert (\"complex_foreign_keys\", \"id\", \"f3\") in incoming\n    assert (\"foreign_key_references\", \"id\", \"foreign_key_with_label\") in incoming\n    assert (\"foreign_key_references\", \"id\", \"foreign_key_with_blank_label\") in incoming\n\n    # -- complex_foreign_keys: has multiple outgoing FKs to same table\n    complex_fk = tables_by_name[\"complex_foreign_keys\"]\n    assert complex_fk[\"count\"] == 1\n    assert complex_fk[\"columns\"] == [\"pk\", \"f1\", \"f2\", \"f3\"]\n    outgoing = fk_set(complex_fk[\"foreign_keys\"][\"outgoing\"])\n    assert outgoing == {\n        (\"simple_primary_key\", \"f1\", \"id\"),\n        (\"simple_primary_key\", \"f2\", \"id\"),\n        (\"simple_primary_key\", \"f3\", \"id\"),\n    }\n\n    # -- searchable: has FTS table association\n    searchable = tables_by_name[\"searchable\"]\n    assert searchable[\"count\"] == 2\n    assert searchable[\"fts_table\"] == \"searchable_fts\"\n    assert searchable[\"columns\"] == [\"pk\", \"text1\", \"text2\", \"name with . and spaces\"]\n\n    # -- searchable_fts: is the FTS virtual table (hidden)\n    searchable_fts = tables_by_name[\"searchable_fts\"]\n    assert searchable_fts[\"hidden\"] is True\n    assert searchable_fts[\"fts_table\"] == \"searchable_fts\"\n    # The \"rank\" column became visible in pragma_table_info in SQLite 3.37+\n    if sqlite_version() >= (3, 37, 0):\n        assert \"rank\" in searchable_fts[\"columns\"]\n\n    # -- compound primary keys\n    compound_pk = tables_by_name[\"compound_primary_key\"]\n    assert compound_pk[\"primary_keys\"] == [\"pk1\", \"pk2\"]\n    assert compound_pk[\"count\"] == 3\n\n    compound_three = tables_by_name[\"compound_three_primary_keys\"]\n    assert compound_three[\"primary_keys\"] == [\"pk1\", \"pk2\", \"pk3\"]\n    assert compound_three[\"count\"] == 1001\n\n    # -- sortable: generated data\n    sortable = tables_by_name[\"sortable\"]\n    assert sortable[\"count\"] == 201\n    assert sortable[\"primary_keys\"] == [\"pk1\", \"pk2\"]\n\n    # -- no_primary_key: hidden table with generated data\n    no_pk = tables_by_name[\"no_primary_key\"]\n    assert no_pk[\"hidden\"] is True\n    assert no_pk[\"count\"] == 202\n    assert no_pk[\"primary_keys\"] == []\n\n    # -- roadside attractions relationship chain\n    attractions = tables_by_name[\"roadside_attractions\"]\n    assert attractions[\"count\"] == 4\n    assert fk_set(attractions[\"foreign_keys\"][\"incoming\"]) == {\n        (\"roadside_attraction_characteristics\", \"pk\", \"attraction_id\")\n    }\n\n    characteristics = tables_by_name[\"attraction_characteristic\"]\n    assert characteristics[\"count\"] == 2\n    assert fk_set(characteristics[\"foreign_keys\"][\"incoming\"]) == {\n        (\"roadside_attraction_characteristics\", \"pk\", \"characteristic_id\")\n    }\n\n    # -- searchable_tags: multiple outgoing FKs\n    searchable_tags = tables_by_name[\"searchable_tags\"]\n    assert searchable_tags[\"primary_keys\"] == [\"searchable_id\", \"tag\"]\n    outgoing = fk_set(searchable_tags[\"foreign_keys\"][\"outgoing\"])\n    assert outgoing == {\n        (\"searchable\", \"searchable_id\", \"pk\"),\n        (\"tags\", \"tag\", \"tag\"),\n    }\n\n    # -- tables with special names\n    assert \"123_starts_with_digits\" in tables_by_name\n    assert \"Table With Space In Name\" in tables_by_name\n    assert \"table/with/slashes.csv\" in tables_by_name\n    assert \"select\" in tables_by_name  # SQL reserved word\n\n    # Verify select table has SQL reserved word columns\n    select_table = tables_by_name[\"select\"]\n    assert set(select_table[\"columns\"]) == {\"group\", \"having\", \"and\", \"json\"}\n\n    # Verify all tables have required fields\n    for table in tables:\n        assert \"name\" in table\n        assert \"columns\" in table\n        assert \"primary_keys\" in table\n        assert \"count\" in table\n        assert \"hidden\" in table\n        assert \"fts_table\" in table\n        assert \"foreign_keys\" in table\n        assert \"private\" in table\n        assert \"incoming\" in table[\"foreign_keys\"]\n        assert \"outgoing\" in table[\"foreign_keys\"]\n\n\ndef test_no_files_uses_memory_database(app_client_no_files):\n    response = app_client_no_files.get(\"/.json\")\n    assert response.status == 200\n    assert {\n        \"databases\": {\n            \"_memory\": {\n                \"name\": \"_memory\",\n                \"hash\": None,\n                \"color\": \"a6c7b9\",\n                \"path\": \"/_memory\",\n                \"tables_and_views_truncated\": [],\n                \"tables_and_views_more\": False,\n                \"tables_count\": 0,\n                \"table_rows_sum\": 0,\n                \"show_table_row_counts\": False,\n                \"hidden_table_rows_sum\": 0,\n                \"hidden_tables_count\": 0,\n                \"views_count\": 0,\n                \"private\": False,\n            },\n        },\n        \"metadata\": {},\n    } == response.json\n    # Try that SQL query\n    response = app_client_no_files.get(\n        \"/_memory/-/query.json?sql=select+sqlite_version()&_shape=array\"\n    )\n    assert 1 == len(response.json)\n    assert [\"sqlite_version()\"] == list(response.json[0].keys())\n\n\n@pytest.mark.parametrize(\n    \"path,expected_redirect\",\n    (\n        (\"/:memory:\", \"/_memory\"),\n        (\"/:memory:.json\", \"/_memory.json\"),\n        (\"/:memory:?sql=select+1\", \"/_memory?sql=select+1\"),\n        (\"/:memory:.json?sql=select+1\", \"/_memory.json?sql=select+1\"),\n        (\"/:memory:.csv?sql=select+1\", \"/_memory.csv?sql=select+1\"),\n    ),\n)\ndef test_old_memory_urls_redirect(app_client_no_files, path, expected_redirect):\n    response = app_client_no_files.get(path)\n    assert response.status == 301\n    assert response.headers[\"location\"] == expected_redirect\n\n\ndef test_database_page_for_database_with_dot_in_name(app_client_with_dot):\n    response = app_client_with_dot.get(\"/fixtures~2Edot.json\")\n    assert response.status == 200\n\n\n@pytest.mark.asyncio\nasync def test_custom_sql(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=select+content+from+simple_primary_key\",\n    )\n    data = response.json()\n    assert data == {\n        \"rows\": [\n            {\"content\": \"hello\"},\n            {\"content\": \"world\"},\n            {\"content\": \"\"},\n            {\"content\": \"RENDER_CELL_DEMO\"},\n            {\"content\": \"RENDER_CELL_ASYNC\"},\n        ],\n        \"ok\": True,\n        \"truncated\": False,\n    }\n\n\n@pytest.mark.xfail(reason=\"Sometimes flaky in CI due to timing issues\")\ndef test_sql_time_limit(app_client_shorter_time_limit):\n    response = app_client_shorter_time_limit.get(\n        \"/fixtures/-/query.json?sql=select+sleep(0.5)\",\n    )\n    assert 400 == response.status\n    assert response.json == {\n        \"ok\": False,\n        \"error\": (\n            \"<p>SQL query took too long. The time limit is controlled by the\\n\"\n            '<a href=\"https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms\">sql_time_limit_ms</a>\\n'\n            \"configuration option.</p>\\n\"\n            '<textarea style=\"width: 90%\">select sleep(0.5)</textarea>\\n'\n            \"<script>\\n\"\n            'let ta = document.querySelector(\"textarea\");\\n'\n            'ta.style.height = ta.scrollHeight + \"px\";\\n'\n            \"</script>\"\n        ),\n        \"status\": 400,\n        \"title\": \"SQL Interrupted\",\n    }\n\n\n@pytest.mark.asyncio\nasync def test_custom_sql_time_limit(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=select+sleep(0.01)\",\n    )\n    assert response.status_code == 200\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5\",\n    )\n    assert response.status_code == 400\n    assert response.json()[\"title\"] == \"SQL Interrupted\"\n\n\n@pytest.mark.asyncio\nasync def test_invalid_custom_sql(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=.schema\",\n    )\n    assert response.status_code == 400\n    assert response.json()[\"ok\"] is False\n    assert \"Statement must be a SELECT\" == response.json()[\"error\"]\n\n\n@pytest.mark.asyncio\nasync def test_row(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key/1.json?_shape=objects\")\n    assert response.status_code == 200\n    assert response.json()[\"ok\"] is True\n    assert response.json()[\"rows\"] == [{\"id\": 1, \"content\": \"hello\"}]\n\n\n@pytest.mark.asyncio\nasync def test_row_strange_table_name(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/table~2Fwith~2Fslashes~2Ecsv/3.json?_shape=objects\"\n    )\n    assert response.status_code == 200\n    assert response.json()[\"rows\"] == [{\"pk\": \"3\", \"content\": \"hey\"}]\n\n\n@pytest.mark.asyncio\nasync def test_row_foreign_key_tables(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/simple_primary_key/1.json?_extras=foreign_key_tables\"\n    )\n    assert response.status_code == 200\n    # Foreign keys are sorted by (other_table, column, other_column)\n    assert response.json()[\"foreign_key_tables\"] == [\n        {\n            \"other_table\": \"complex_foreign_keys\",\n            \"column\": \"id\",\n            \"other_column\": \"f1\",\n            \"count\": 1,\n            \"link\": \"/fixtures/complex_foreign_keys?f1=1\",\n        },\n        {\n            \"other_table\": \"complex_foreign_keys\",\n            \"column\": \"id\",\n            \"other_column\": \"f2\",\n            \"count\": 0,\n            \"link\": \"/fixtures/complex_foreign_keys?f2=1\",\n        },\n        {\n            \"other_table\": \"complex_foreign_keys\",\n            \"column\": \"id\",\n            \"other_column\": \"f3\",\n            \"count\": 1,\n            \"link\": \"/fixtures/complex_foreign_keys?f3=1\",\n        },\n        {\n            \"other_table\": \"foreign_key_references\",\n            \"column\": \"id\",\n            \"other_column\": \"foreign_key_with_blank_label\",\n            \"count\": 0,\n            \"link\": \"/fixtures/foreign_key_references?foreign_key_with_blank_label=1\",\n        },\n        {\n            \"other_table\": \"foreign_key_references\",\n            \"column\": \"id\",\n            \"other_column\": \"foreign_key_with_label\",\n            \"count\": 1,\n            \"link\": \"/fixtures/foreign_key_references?foreign_key_with_label=1\",\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_row_extra_render_cell():\n    \"\"\"Test that _extra=render_cell returns rendered HTML from render_cell plugin hook on row pages\"\"\"\n    from datasette import hookimpl\n    from datasette.app import Datasette\n\n    class TestRenderCellPlugin:\n        __name__ = \"TestRenderCellPlugin\"\n\n        @hookimpl\n        def render_cell(self, value, column, table, database):\n            # Only modify cells in our test table\n            if table == \"test_render\" and column == \"name\":\n                return f\"<strong>{value}</strong>\"\n            return None\n\n    ds = Datasette(memory=True)\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_row_render\")\n    await db.execute_write(\n        \"create table test_render (id integer primary key, name text)\"\n    )\n    await db.execute_write(\"insert into test_render values (1, 'Alice')\")\n\n    # Register our test plugin\n    ds.pm.register(TestRenderCellPlugin(), name=\"TestRenderCellPlugin\")\n\n    try:\n        # Request row with _extra=render_cell\n        response = await ds.client.get(\n            \"/test_row_render/test_render/1.json?_extra=render_cell\"\n        )\n        assert response.status_code == 200\n        data = response.json()\n\n        # Verify the response structure\n        assert \"render_cell\" in data\n        assert \"rows\" in data\n\n        # render_cell should be a list with one row (since this is a row page)\n        # Only columns modified by plugins are included (sparse output)\n        render_cell = data[\"render_cell\"]\n        assert len(render_cell) == 1\n\n        # The row: id=1, name='Alice'\n        # The 'name' column should be rendered by our plugin as <strong>Alice</strong>\n        assert render_cell[0][\"name\"] == \"<strong>Alice</strong>\"\n        # The 'id' column is not included since no plugin modified it\n        assert \"id\" not in render_cell[0]\n\n        # The regular rows should still contain raw values\n        assert data[\"rows\"] == [{\"id\": 1, \"name\": \"Alice\"}]\n\n    finally:\n        ds.pm.unregister(name=\"TestRenderCellPlugin\")\n\n\ndef test_databases_json(app_client_two_attached_databases_one_immutable):\n    response = app_client_two_attached_databases_one_immutable.get(\"/-/databases.json\")\n    databases = response.json\n    assert 2 == len(databases)\n    extra_database, fixtures_database = databases\n    assert \"extra database\" == extra_database[\"name\"]\n    assert extra_database[\"hash\"] is None\n    assert extra_database[\"is_mutable\"] is True\n    assert extra_database[\"is_memory\"] is False\n\n    assert \"fixtures\" == fixtures_database[\"name\"]\n    assert fixtures_database[\"hash\"] is not None\n    assert fixtures_database[\"is_mutable\"] is False\n    assert fixtures_database[\"is_memory\"] is False\n\n\n@pytest.mark.asyncio\nasync def test_threads_json(ds_client):\n    response = await ds_client.get(\"/-/threads.json\")\n    expected_keys = {\"threads\", \"num_threads\"}\n    if sys.version_info >= (3, 7, 0):\n        expected_keys.update({\"tasks\", \"num_tasks\"})\n    data = response.json()\n    assert set(data.keys()) == expected_keys\n    # Should be at least one _execute_writes thread for __INTERNAL__\n    thread_names = [thread[\"name\"] for thread in data[\"threads\"]]\n    assert \"_execute_writes for database __INTERNAL__\" in thread_names\n\n\n@pytest.mark.asyncio\nasync def test_plugins_json(ds_client):\n    response = await ds_client.get(\"/-/plugins.json\")\n    # Filter out TrackEventPlugin\n    actual_plugins = sorted(\n        [p for p in response.json() if p[\"name\"] != \"TrackEventPlugin\"],\n        key=lambda p: p[\"name\"],\n    )\n    assert EXPECTED_PLUGINS == actual_plugins\n    # Try with ?all=1\n    response = await ds_client.get(\"/-/plugins.json?all=1\")\n    names = {p[\"name\"] for p in response.json()}\n    assert names.issuperset(p[\"name\"] for p in EXPECTED_PLUGINS)\n    assert names.issuperset(DEFAULT_PLUGINS)\n\n\n@pytest.mark.asyncio\nasync def test_versions_json(ds_client):\n    response = await ds_client.get(\"/-/versions.json\")\n    data = response.json()\n    assert \"python\" in data\n    assert \"3.0\" == data.get(\"asgi\")\n    assert \"version\" in data[\"python\"]\n    assert \"full\" in data[\"python\"]\n    assert \"datasette\" in data\n    assert \"version\" in data[\"datasette\"]\n    assert data[\"datasette\"][\"version\"] == __version__\n    assert \"sqlite\" in data\n    assert \"version\" in data[\"sqlite\"]\n    assert \"fts_versions\" in data[\"sqlite\"]\n    assert \"compile_options\" in data[\"sqlite\"]\n    # By default, the json1 extension is enabled in the SQLite\n    # provided by the `ubuntu-latest` github actions runner, and\n    # all versions of SQLite from 3.38.0 onwards\n    assert data[\"sqlite\"][\"extensions\"][\"json1\"]\n\n\n@pytest.mark.asyncio\nasync def test_actions_json(ds_client):\n    original_root_enabled = ds_client.ds.root_enabled\n    try:\n        ds_client.ds.root_enabled = True\n        cookies = {\"ds_actor\": ds_client.actor_cookie({\"id\": \"root\"})}\n        response = await ds_client.get(\"/-/actions.json\", cookies=cookies)\n        data = response.json()\n    finally:\n        ds_client.ds.root_enabled = original_root_enabled\n    assert isinstance(data, list)\n    assert len(data) > 0\n    # Check structure of first action\n    action = data[0]\n    for key in (\n        \"name\",\n        \"abbr\",\n        \"description\",\n        \"takes_parent\",\n        \"takes_child\",\n        \"resource_class\",\n        \"also_requires\",\n    ):\n        assert key in action\n    # Check that some expected actions exist\n    action_names = {a[\"name\"] for a in data}\n    for expected_action in (\n        \"view-instance\",\n        \"view-database\",\n        \"view-table\",\n        \"execute-sql\",\n    ):\n        assert expected_action in action_names\n\n\n@pytest.mark.asyncio\nasync def test_settings_json(ds_client):\n    response = await ds_client.get(\"/-/settings.json\")\n    assert response.json() == {\n        \"default_page_size\": 50,\n        \"default_facet_size\": 30,\n        \"default_allow_sql\": True,\n        \"facet_suggest_time_limit_ms\": 200,\n        \"facet_time_limit_ms\": 200,\n        \"max_returned_rows\": 100,\n        \"max_insert_rows\": 100,\n        \"sql_time_limit_ms\": 200,\n        \"allow_download\": True,\n        \"allow_signed_tokens\": True,\n        \"max_signed_tokens_ttl\": 0,\n        \"allow_facet\": True,\n        \"suggest_facets\": True,\n        \"default_cache_ttl\": 5,\n        \"num_sql_threads\": 1,\n        \"cache_size_kb\": 0,\n        \"allow_csv_stream\": True,\n        \"max_csv_mb\": 100,\n        \"truncate_cells_html\": 2048,\n        \"force_https_urls\": False,\n        \"template_debug\": False,\n        \"trace_debug\": False,\n        \"base_url\": \"/\",\n    }\n\n\ntest_json_columns_default_expected = [\n    {\"intval\": 1, \"strval\": \"s\", \"floatval\": 0.5, \"jsonval\": '{\"foo\": \"bar\"}'}\n]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"extra_args,expected\",\n    [\n        (\"\", test_json_columns_default_expected),\n        (\"&_json=intval\", test_json_columns_default_expected),\n        (\"&_json=strval\", test_json_columns_default_expected),\n        (\"&_json=floatval\", test_json_columns_default_expected),\n        (\n            \"&_json=jsonval\",\n            [{\"intval\": 1, \"strval\": \"s\", \"floatval\": 0.5, \"jsonval\": {\"foo\": \"bar\"}}],\n        ),\n    ],\n)\nasync def test_json_columns(ds_client, extra_args, expected):\n    sql = \"\"\"\n        select 1 as intval, \"s\" as strval, 0.5 as floatval,\n        '{\"foo\": \"bar\"}' as jsonval\n    \"\"\"\n    path = \"/fixtures/-/query.json?\" + urllib.parse.urlencode(\n        {\"sql\": sql, \"_shape\": \"array\"}\n    )\n    path += extra_args\n    response = await ds_client.get(\n        path,\n    )\n    assert response.json() == expected\n\n\ndef test_config_cache_size(app_client_larger_cache_size):\n    response = app_client_larger_cache_size.get(\"/fixtures/pragma_cache_size.json\")\n    assert response.json[\"rows\"] == [{\"cache_size\": -2500}]\n\n\ndef test_config_force_https_urls():\n    with make_app_client(settings={\"force_https_urls\": True}) as client:\n        response = client.get(\n            \"/fixtures/facetable.json?_size=3&_facet=state&_extra=next_url,suggested_facets\"\n        )\n        assert response.json[\"next_url\"].startswith(\"https://\")\n        assert response.json[\"facet_results\"][\"results\"][\"state\"][\"results\"][0][\n            \"toggle_url\"\n        ].startswith(\"https://\")\n        assert response.json[\"suggested_facets\"][0][\"toggle_url\"].startswith(\"https://\")\n        # Also confirm that request.url and request.scheme are set correctly\n        response = client.get(\"/\")\n        assert client.ds._last_request.url.startswith(\"https://\")\n        assert client.ds._last_request.scheme == \"https\"\n\n\n@pytest.mark.parametrize(\n    \"path,status_code\",\n    [\n        (\"/fixtures.db\", 200),\n        (\"/fixtures.json\", 200),\n        (\"/fixtures/no_primary_key.json\", 200),\n        # A 400 invalid SQL query should still have the header:\n        (\"/fixtures/-/query.json?sql=select+blah\", 400),\n        # Write APIs\n        (\"/fixtures/-/create\", 405),\n        (\"/fixtures/facetable/-/insert\", 405),\n        (\"/fixtures/facetable/-/drop\", 405),\n    ],\n)\ndef test_cors(\n    app_client_with_cors,\n    app_client_two_attached_databases_one_immutable,\n    path,\n    status_code,\n):\n    response = app_client_with_cors.get(\n        path,\n    )\n    assert response.status == status_code\n    assert response.headers[\"Access-Control-Allow-Origin\"] == \"*\"\n    assert (\n        response.headers[\"Access-Control-Allow-Headers\"]\n        == \"Authorization, Content-Type\"\n    )\n    assert response.headers[\"Access-Control-Expose-Headers\"] == \"Link\"\n    assert (\n        response.headers[\"Access-Control-Allow-Methods\"] == \"GET, POST, HEAD, OPTIONS\"\n    )\n    assert response.headers[\"Access-Control-Max-Age\"] == \"3600\"\n    # Same request to app_client_two_attached_databases_one_immutable\n    # should not have those headers - I'm using that fixture because\n    # regular app_client doesn't have immutable fixtures.db which means\n    # the test for /fixtures.db returns a 403 error\n    response = app_client_two_attached_databases_one_immutable.get(\n        path,\n    )\n    assert response.status == status_code\n    assert \"Access-Control-Allow-Origin\" not in response.headers\n    assert \"Access-Control-Allow-Headers\" not in response.headers\n    assert \"Access-Control-Expose-Headers\" not in response.headers\n    assert \"Access-Control-Allow-Methods\" not in response.headers\n    assert \"Access-Control-Max-Age\" not in response.headers\n\n\n@pytest.mark.parametrize(\n    \"path\",\n    (\n        \"/\",\n        \".json\",\n        \"/searchable\",\n        \"/searchable.json\",\n        \"/searchable_view\",\n        \"/searchable_view.json\",\n    ),\n)\ndef test_database_with_space_in_name(app_client_two_attached_databases, path):\n    response = app_client_two_attached_databases.get(\n        \"/extra~20database\" + path, follow_redirects=True\n    )\n    assert response.status == 200\n\n\ndef test_common_prefix_database_names(app_client_conflicting_database_names):\n    # https://github.com/simonw/datasette/issues/597\n    assert [\"foo-bar\", \"foo\", \"fixtures\"] == [\n        d[\"name\"]\n        for d in app_client_conflicting_database_names.get(\"/-/databases.json\").json\n    ]\n    for db_name, path in ((\"foo\", \"/foo.json\"), (\"foo-bar\", \"/foo-bar.json\")):\n        data = app_client_conflicting_database_names.get(path).json\n        assert db_name == data[\"database\"]\n\n\ndef test_inspect_file_used_for_count(app_client_immutable_and_inspect_file):\n    response = app_client_immutable_and_inspect_file.get(\n        \"/fixtures/sortable.json?_extra=count\"\n    )\n    assert response.json[\"count\"] == 100\n\n\n@pytest.mark.asyncio\nasync def test_http_options_request(ds_client):\n    response = await ds_client.options(\"/fixtures\")\n    assert response.status_code == 200\n    assert response.text == \"ok\"\n\n\n@pytest.mark.asyncio\nasync def test_db_path(app_client):\n    # Needs app_client because needs file based database\n    db = app_client.ds.get_database()\n    path = pathlib.Path(db.path)\n\n    assert path.exists()\n\n    datasette = Datasette([path])\n\n    # Previously this broke if path was a pathlib.Path:\n    await datasette.refresh_schemas()\n\n\n@pytest.mark.asyncio\nasync def test_hidden_sqlite_stat1_table():\n    ds = Datasette()\n    db = ds.add_memory_database(\"db\")\n    await db.execute_write(\"create table normal (id integer primary key, name text)\")\n    await db.execute_write(\"create index idx on normal (name)\")\n    await db.execute_write(\"analyze\")\n    data = (await ds.client.get(\"/db.json?_show_hidden=1\")).json()\n    tables = [(t[\"name\"], t[\"hidden\"]) for t in data[\"tables\"]]\n    assert tables in (\n        [(\"normal\", False), (\"sqlite_stat1\", True)],\n        [(\"normal\", False), (\"sqlite_stat1\", True), (\"sqlite_stat4\", True)],\n    )\n\n\n@pytest.mark.asyncio\nasync def test_hide_tables_starting_with_underscore():\n    ds = Datasette()\n    db = ds.add_memory_database(\"test_hide_tables_starting_with_underscore\")\n    await db.execute_write(\"create table normal (id integer primary key, name text)\")\n    await db.execute_write(\"create table _hidden (id integer primary key, name text)\")\n    data = (\n        await ds.client.get(\n            \"/test_hide_tables_starting_with_underscore.json?_show_hidden=1\"\n        )\n    ).json()\n    tables = [(t[\"name\"], t[\"hidden\"]) for t in data[\"tables\"]]\n    assert tables == [(\"normal\", False), (\"_hidden\", True)]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"db_name\", (\"foo\", r\"fo%o\", \"f~/c.d\"))\nasync def test_tilde_encoded_database_names(db_name):\n    ds = Datasette()\n    ds.add_memory_database(db_name)\n    response = await ds.client.get(\"/.json\")\n    assert db_name in response.json()[\"databases\"].keys()\n    path = response.json()[\"databases\"][db_name][\"path\"]\n    # And the JSON for that database\n    response2 = await ds.client.get(path + \".json\")\n    assert response2.status_code == 200\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"config,expected\",\n    (\n        ({}, {}),\n        ({\"plugins\": {\"datasette-foo\": \"bar\"}}, {\"plugins\": {\"datasette-foo\": \"bar\"}}),\n        # Test redaction\n        (\n            {\n                \"plugins\": {\n                    \"datasette-auth\": {\"secret_key\": \"key\"},\n                    \"datasette-foo\": \"bar\",\n                    \"datasette-auth2\": {\"password\": \"password\"},\n                    \"datasette-sentry\": {\n                        \"dsn\": \"sentry:///foo\",\n                    },\n                }\n            },\n            {\n                \"plugins\": {\n                    \"datasette-auth\": {\"secret_key\": \"***\"},\n                    \"datasette-foo\": \"bar\",\n                    \"datasette-auth2\": {\"password\": \"***\"},\n                    \"datasette-sentry\": {\"dsn\": \"***\"},\n                }\n            },\n        ),\n    ),\n)\nasync def test_config_json(config, expected):\n    \"/-/config.json should return redacted configuration\"\n    ds = Datasette(config=config)\n    response = await ds.client.get(\"/-/config.json\")\n    assert response.json() == expected\n\n\n@pytest.mark.asyncio\n@pytest.mark.skip(reason=\"rm?\")\n@pytest.mark.parametrize(\n    \"metadata,expected_config,expected_metadata\",\n    (\n        ({}, {}, {}),\n        (\n            # Metadata input\n            {\n                \"title\": \"Datasette Fixtures\",\n                \"databases\": {\n                    \"fixtures\": {\n                        \"tables\": {\n                            \"sortable\": {\n                                \"sortable_columns\": [\n                                    \"sortable\",\n                                    \"sortable_with_nulls\",\n                                    \"sortable_with_nulls_2\",\n                                    \"text\",\n                                ],\n                            },\n                            \"no_primary_key\": {\"sortable_columns\": [], \"hidden\": True},\n                            \"primary_key_multiple_columns_explicit_label\": {\n                                \"label_column\": \"content2\"\n                            },\n                            \"simple_view\": {\"sortable_columns\": [\"content\"]},\n                            \"searchable_view_configured_by_metadata\": {\n                                \"fts_table\": \"searchable_fts\",\n                                \"fts_pk\": \"pk\",\n                            },\n                            \"roadside_attractions\": {\n                                \"columns\": {\n                                    \"name\": \"The name of the attraction\",\n                                    \"address\": \"The street address for the attraction\",\n                                }\n                            },\n                            \"attraction_characteristic\": {\"sort_desc\": \"pk\"},\n                            \"facet_cities\": {\"sort\": \"name\"},\n                            \"paginated_view\": {\"size\": 25},\n                        },\n                    }\n                },\n            },\n            # Should produce a config with just the table configuration keys\n            {\n                \"databases\": {\n                    \"fixtures\": {\n                        \"tables\": {\n                            \"sortable\": {\n                                \"sortable_columns\": [\n                                    \"sortable\",\n                                    \"sortable_with_nulls\",\n                                    \"sortable_with_nulls_2\",\n                                    \"text\",\n                                ]\n                            },\n                            # These one get redacted:\n                            \"no_primary_key\": \"***\",\n                            \"primary_key_multiple_columns_explicit_label\": \"***\",\n                            \"simple_view\": {\"sortable_columns\": [\"content\"]},\n                            \"searchable_view_configured_by_metadata\": {\n                                \"fts_table\": \"searchable_fts\",\n                                \"fts_pk\": \"pk\",\n                            },\n                            \"attraction_characteristic\": {\"sort_desc\": \"pk\"},\n                            \"facet_cities\": {\"sort\": \"name\"},\n                            \"paginated_view\": {\"size\": 25},\n                        }\n                    }\n                }\n            },\n            # And metadata with everything else\n            {\n                \"title\": \"Datasette Fixtures\",\n                \"databases\": {\n                    \"fixtures\": {\n                        \"tables\": {\n                            \"roadside_attractions\": {\n                                \"columns\": {\n                                    \"name\": \"The name of the attraction\",\n                                    \"address\": \"The street address for the attraction\",\n                                }\n                            },\n                        }\n                    }\n                },\n            },\n        ),\n    ),\n)\nasync def test_upgrade_metadata(metadata, expected_config, expected_metadata):\n    ds = Datasette(metadata=metadata)\n    response = await ds.client.get(\"/-/config.json\")\n    assert response.json() == expected_config\n    response2 = await ds.client.get(\"/-/metadata.json\")\n    assert response2.json() == expected_metadata\n\n\nclass Either:\n    def __init__(self, a, b):\n        self.a = a\n        self.b = b\n\n    def __eq__(self, other):\n        return other == self.a or other == self.b\n"
  },
  {
    "path": "tests/test_api_write.py",
    "content": "from datasette.app import Datasette\nfrom datasette.utils import sqlite3\nfrom .utils import last_event\nimport pytest\nimport time\n\n\n@pytest.fixture\ndef ds_write(tmp_path_factory):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db_path_immutable = str(db_directory / \"immutable.db\")\n    db1 = sqlite3.connect(str(db_path))\n    db2 = sqlite3.connect(str(db_path_immutable))\n    for db in (db1, db2):\n        db.execute(\"vacuum\")\n        db.execute(\n            \"create table docs (id integer primary key, title text, score float, age integer)\"\n        )\n    ds = Datasette([db_path], immutables=[db_path_immutable])\n    ds.root_enabled = True\n    yield ds\n    # Close both setup connections plus any Datasette-managed connections.\n    db1.close()\n    db2.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\ndef write_token(ds, actor_id=\"root\", permissions=None):\n    to_sign = {\"a\": actor_id, \"token\": \"dstok\", \"t\": int(time.time())}\n    if permissions:\n        to_sign[\"_r\"] = {\"a\": permissions}\n    return \"dstok_{}\".format(ds.sign(to_sign, namespace=\"token\"))\n\n\ndef _headers(token):\n    return {\n        \"Authorization\": \"Bearer {}\".format(token),\n        \"Content-Type\": \"application/json\",\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"content_type\",\n    (\n        \"application/json\",\n        \"application/json; charset=utf-8\",\n    ),\n)\nasync def test_insert_row(ds_write, content_type):\n    token = write_token(ds_write)\n    response = await ds_write.client.post(\n        \"/data/docs/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"score\": 1.2, \"age\": 5}},\n        headers={\n            \"Authorization\": \"Bearer {}\".format(token),\n            \"Content-Type\": content_type,\n        },\n    )\n    expected_row = {\"id\": 1, \"title\": \"Test\", \"score\": 1.2, \"age\": 5}\n    assert response.status_code == 201\n    assert response.json()[\"ok\"] is True\n    assert response.json()[\"rows\"] == [expected_row]\n    rows = (await ds_write.get_database(\"data\").execute(\"select * from docs\")).dicts()\n    assert rows[0] == expected_row\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.name == \"insert-rows\"\n    assert event.num_rows == 1\n    assert event.database == \"data\"\n    assert event.table == \"docs\"\n    assert not event.ignore\n    assert not event.replace\n\n\n@pytest.mark.asyncio\nasync def test_insert_row_alter(ds_write):\n    token = write_token(ds_write)\n    response = await ds_write.client.post(\n        \"/data/docs/-/insert\",\n        json={\n            \"row\": {\"title\": \"Test\", \"score\": 1.2, \"age\": 5, \"extra\": \"extra\"},\n            \"alter\": True,\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n    assert response.json()[\"ok\"] is True\n    assert response.json()[\"rows\"][0][\"extra\"] == \"extra\"\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.name == \"alter-table\"\n    assert \"extra\" not in event.before_schema\n    assert \"extra\" in event.after_schema\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"return_rows\", (True, False))\nasync def test_insert_rows(ds_write, return_rows):\n    token = write_token(ds_write)\n    data = {\n        \"rows\": [\n            {\"title\": \"Test {}\".format(i), \"score\": 1.0, \"age\": 5} for i in range(20)\n        ]\n    }\n    if return_rows:\n        data[\"return\"] = True\n    response = await ds_write.client.post(\n        \"/data/docs/-/insert\",\n        json=data,\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.name == \"insert-rows\"\n    assert event.num_rows == 20\n    assert event.database == \"data\"\n    assert event.table == \"docs\"\n    assert not event.ignore\n    assert not event.replace\n\n    actual_rows = (\n        await ds_write.get_database(\"data\").execute(\"select * from docs\")\n    ).dicts()\n    assert len(actual_rows) == 20\n    assert actual_rows == [\n        {\"id\": i + 1, \"title\": \"Test {}\".format(i), \"score\": 1.0, \"age\": 5}\n        for i in range(20)\n    ]\n    assert response.json()[\"ok\"] is True\n    if return_rows:\n        assert response.json()[\"rows\"] == actual_rows\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,input,special_case,expected_status,expected_errors\",\n    (\n        (\n            \"/data2/docs/-/insert\",\n            {},\n            None,\n            404,\n            [\"Database not found\"],\n        ),\n        (\n            \"/data/docs2/-/insert\",\n            {},\n            None,\n            404,\n            [\"Table not found\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"} for i in range(10)]},\n            \"bad_token\",\n            403,\n            [\"Permission denied\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {},\n            \"invalid_json\",\n            400,\n            [\n                \"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)\"\n            ],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {},\n            \"invalid_content_type\",\n            400,\n            [\"Invalid content-type, must be application/json\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            [],\n            None,\n            400,\n            [\"JSON must be a dictionary\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"row\": \"blah\"},\n            None,\n            400,\n            ['\"row\" must be a dictionary'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"blah\": \"blah\"},\n            None,\n            400,\n            ['JSON must have one or other of \"row\" or \"rows\"'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": \"blah\"},\n            None,\n            400,\n            ['\"rows\" must be a list'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [\"blah\"]},\n            None,\n            400,\n            ['\"rows\" must be a list of dictionaries'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"} for i in range(101)]},\n            None,\n            400,\n            [\"Too many rows, maximum allowed is 100\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"id\": 1, \"title\": \"Test\"}, {\"id\": 2, \"title\": \"Test\"}]},\n            \"duplicate_id\",\n            400,\n            [\"UNIQUE constraint failed: docs.id\"],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"}], \"ignore\": True, \"replace\": True},\n            None,\n            400,\n            ['Cannot use \"ignore\" and \"replace\" at the same time'],\n        ),\n        (\n            # Replace is not allowed if you don't have update-row\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"}], \"replace\": True},\n            \"insert-but-not-update\",\n            403,\n            ['Permission denied: need update-row to use \"replace\"'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"}], \"invalid_param\": True},\n            None,\n            400,\n            ['Invalid parameter: \"invalid_param\"'],\n        ),\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"}], \"one\": True, \"two\": True},\n            None,\n            400,\n            ['Invalid parameter: \"one\", \"two\"'],\n        ),\n        (\n            \"/immutable/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\"}]},\n            None,\n            403,\n            [\"Database is immutable\"],\n        ),\n        # Validate columns of each row\n        (\n            \"/data/docs/-/insert\",\n            {\"rows\": [{\"title\": \"Test\", \"bad\": 1, \"worse\": 2} for i in range(2)]},\n            None,\n            400,\n            [\n                \"Row 0 has invalid columns: bad, worse\",\n                \"Row 1 has invalid columns: bad, worse\",\n            ],\n        ),\n        ## UPSERT ERRORS:\n        (\n            \"/immutable/docs/-/upsert\",\n            {\"rows\": [{\"title\": \"Test\"}]},\n            None,\n            403,\n            [\"Database is immutable\"],\n        ),\n        (\n            \"/data/badtable/-/upsert\",\n            {\"rows\": [{\"title\": \"Test\"}]},\n            None,\n            404,\n            [\"Table not found\"],\n        ),\n        # missing primary key\n        (\n            \"/data/docs/-/upsert\",\n            {\"rows\": [{\"title\": \"Missing PK\"}]},\n            None,\n            400,\n            ['Row 0 is missing primary key column(s): \"id\"'],\n        ),\n        # Upsert does not support ignore or replace\n        (\n            \"/data/docs/-/upsert\",\n            {\"rows\": [{\"id\": 1, \"title\": \"Bad\"}], \"ignore\": True},\n            None,\n            400,\n            [\"Upsert does not support ignore or replace\"],\n        ),\n        # Upsert permissions\n        (\n            \"/data/docs/-/upsert\",\n            {\"rows\": [{\"id\": 1, \"title\": \"Disallowed\"}]},\n            \"insert-but-not-update\",\n            403,\n            [\"Permission denied: need both insert-row and update-row\"],\n        ),\n        (\n            \"/data/docs/-/upsert\",\n            {\"rows\": [{\"id\": 1, \"title\": \"Disallowed\"}]},\n            \"update-but-not-insert\",\n            403,\n            [\"Permission denied: need both insert-row and update-row\"],\n        ),\n        # Alter table forbidden without alter permission\n        (\n            \"/data/docs/-/upsert\",\n            {\"rows\": [{\"id\": 1, \"title\": \"One\", \"extra\": \"extra\"}], \"alter\": True},\n            \"update-and-insert-but-no-alter\",\n            403,\n            [\"Permission denied for alter-table\"],\n        ),\n    ),\n)\nasync def test_insert_or_upsert_row_errors(\n    ds_write, path, input, special_case, expected_status, expected_errors\n):\n    token_permissions = []\n    if special_case == \"insert-but-not-update\":\n        token_permissions = [\"ir\", \"vi\"]\n    if special_case == \"update-but-not-insert\":\n        token_permissions = [\"ur\", \"vi\"]\n    if special_case == \"update-and-insert-but-no-alter\":\n        token_permissions = [\"ur\", \"ir\"]\n    token = write_token(ds_write, permissions=token_permissions)\n    if special_case == \"duplicate_id\":\n        await ds_write.get_database(\"data\").execute_write(\n            \"insert into docs (id) values (1)\"\n        )\n    if special_case == \"bad_token\":\n        token += \"bad\"\n    kwargs = dict(\n        json=input,\n        headers={\n            \"Authorization\": \"Bearer {}\".format(token),\n            \"Content-Type\": (\n                \"text/plain\"\n                if special_case == \"invalid_content_type\"\n                else \"application/json\"\n            ),\n        },\n    )\n\n    actor_response = (\n        await ds_write.client.get(\"/-/actor.json\", headers=kwargs[\"headers\"])\n    ).json()\n    assert set((actor_response[\"actor\"] or {}).get(\"_r\", {}).get(\"a\") or []) == set(\n        token_permissions\n    )\n\n    if special_case == \"invalid_json\":\n        del kwargs[\"json\"]\n        kwargs[\"content\"] = \"{bad json\"\n    before_count = (\n        await ds_write.get_database(\"data\").execute(\"select count(*) from docs\")\n    ).rows[0][0] == 0\n    response = await ds_write.client.post(\n        path,\n        **kwargs,\n    )\n    assert response.status_code == expected_status\n    assert response.json()[\"ok\"] is False\n    assert response.json()[\"errors\"] == expected_errors\n    # Check that no rows were inserted\n    after_count = (\n        await ds_write.get_database(\"data\").execute(\"select count(*) from docs\")\n    ).rows[0][0] == 0\n    assert before_count == after_count\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"allowed\", (True, False))\nasync def test_upsert_permissions_per_table(ds_write, allowed):\n    # https://github.com/simonw/datasette/issues/2262\n    token = \"dstok_{}\".format(\n        ds_write.sign(\n            {\n                \"a\": \"root\",\n                \"token\": \"dstok\",\n                \"t\": int(time.time()),\n                \"_r\": {\n                    \"r\": {\n                        \"data\": {\n                            \"docs\" if allowed else \"other\": [\"ir\", \"ur\"],\n                        }\n                    }\n                },\n            },\n            namespace=\"token\",\n        )\n    )\n    response = await ds_write.client.post(\n        \"/data/docs/-/upsert\",\n        json={\"rows\": [{\"id\": 1, \"title\": \"One\"}]},\n        headers={\n            \"Authorization\": \"Bearer {}\".format(token),\n        },\n    )\n    if allowed:\n        assert response.status_code == 200\n        assert response.json()[\"ok\"] is True\n    else:\n        assert response.status_code == 403\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"ignore,replace,expected_rows\",\n    (\n        (\n            True,\n            False,\n            [\n                {\"id\": 1, \"title\": \"Exists\", \"score\": None, \"age\": None},\n            ],\n        ),\n        (\n            False,\n            True,\n            [\n                {\"id\": 1, \"title\": \"One\", \"score\": None, \"age\": None},\n            ],\n        ),\n    ),\n)\n@pytest.mark.parametrize(\"should_return\", (True, False))\nasync def test_insert_ignore_replace(\n    ds_write, ignore, replace, expected_rows, should_return\n):\n    await ds_write.get_database(\"data\").execute_write(\n        \"insert into docs (id, title) values (1, 'Exists')\"\n    )\n    token = write_token(ds_write)\n    data = {\"rows\": [{\"id\": 1, \"title\": \"One\"}]}\n    if ignore:\n        data[\"ignore\"] = True\n    if replace:\n        data[\"replace\"] = True\n    if should_return:\n        data[\"return\"] = True\n    response = await ds_write.client.post(\n        \"/data/docs/-/insert\",\n        json=data,\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.name == \"insert-rows\"\n    assert event.num_rows == 1\n    assert event.database == \"data\"\n    assert event.table == \"docs\"\n    assert event.ignore == ignore\n    assert event.replace == replace\n\n    actual_rows = (\n        await ds_write.get_database(\"data\").execute(\"select * from docs\")\n    ).dicts()\n\n    assert actual_rows == expected_rows\n    assert response.json()[\"ok\"] is True\n    if should_return:\n        assert response.json()[\"rows\"] == expected_rows\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"initial,input,expected_rows\",\n    (\n        (\n            # Simple primary key update\n            {\"rows\": [{\"id\": 1, \"title\": \"One\"}], \"pk\": \"id\"},\n            {\"rows\": [{\"id\": 1, \"title\": \"Two\"}]},\n            [\n                {\"id\": 1, \"title\": \"Two\"},\n            ],\n        ),\n        (\n            # Multiple rows update one of them\n            {\n                \"rows\": [{\"id\": 1, \"title\": \"One\"}, {\"id\": 2, \"title\": \"Two\"}],\n                \"pk\": \"id\",\n            },\n            {\"rows\": [{\"id\": 1, \"title\": \"Three\"}]},\n            [\n                {\"id\": 1, \"title\": \"Three\"},\n                {\"id\": 2, \"title\": \"Two\"},\n            ],\n        ),\n        (\n            # rowid update\n            {\"rows\": [{\"title\": \"One\"}]},\n            {\"rows\": [{\"rowid\": 1, \"title\": \"Two\"}]},\n            [\n                {\"rowid\": 1, \"title\": \"Two\"},\n            ],\n        ),\n        (\n            # Compound primary key update\n            {\"rows\": [{\"id\": 1, \"title\": \"One\", \"score\": 1}], \"pks\": [\"id\", \"score\"]},\n            {\"rows\": [{\"id\": 1, \"title\": \"Two\", \"score\": 1}]},\n            [\n                {\"id\": 1, \"title\": \"Two\", \"score\": 1},\n            ],\n        ),\n        (\n            # Upsert with an alter\n            {\"rows\": [{\"id\": 1, \"title\": \"One\"}], \"pk\": \"id\"},\n            {\"rows\": [{\"id\": 1, \"title\": \"Two\", \"extra\": \"extra\"}], \"alter\": True},\n            [{\"id\": 1, \"title\": \"Two\", \"extra\": \"extra\"}],\n        ),\n    ),\n)\n@pytest.mark.parametrize(\"should_return\", (False, True))\nasync def test_upsert(ds_write, initial, input, expected_rows, should_return):\n    token = write_token(ds_write)\n    # Insert initial data\n    initial[\"table\"] = \"upsert_test\"\n    create_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=initial,\n        headers=_headers(token),\n    )\n    assert create_response.status_code == 201\n    if should_return:\n        input[\"return\"] = True\n    response = await ds_write.client.post(\n        \"/data/upsert_test/-/upsert\",\n        json=input,\n        headers=_headers(token),\n    )\n    assert response.status_code == 200, response.text\n    assert response.json()[\"ok\"] is True\n\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.database == \"data\"\n    assert event.table == \"upsert_test\"\n    if input.get(\"alter\"):\n        assert event.name == \"alter-table\"\n        assert \"extra\" in event.after_schema\n    else:\n        assert event.name == \"upsert-rows\"\n        assert event.num_rows == 1\n\n    if should_return:\n        # We only expect it to return rows corresponding to those we sent\n        expected_returned_rows = expected_rows[: len(input[\"rows\"])]\n        assert response.json()[\"rows\"] == expected_returned_rows\n    # Check the database too\n    actual_rows = (\n        await ds_write.client.get(\"/data/upsert_test.json?_shape=array\")\n    ).json()\n    assert actual_rows == expected_rows\n    # Drop the upsert_test table\n    await ds_write.get_database(\"data\").execute_write(\"drop table upsert_test\")\n\n\nasync def _insert_row(ds):\n    insert_response = await ds.client.post(\n        \"/data/docs/-/insert\",\n        json={\"row\": {\"title\": \"Row one\", \"score\": 1.2, \"age\": 5}, \"return\": True},\n        headers=_headers(write_token(ds)),\n    )\n    assert insert_response.status_code == 201\n    return insert_response.json()[\"rows\"][0][\"id\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"scenario\", (\"no_token\", \"no_perm\", \"bad_table\"))\nasync def test_delete_row_errors(ds_write, scenario):\n    if scenario == \"no_token\":\n        token = \"bad_token\"\n    elif scenario == \"no_perm\":\n        token = write_token(ds_write, actor_id=\"not-root\")\n    else:\n        token = write_token(ds_write)\n\n    pk = await _insert_row(ds_write)\n\n    path = \"/data/{}/{}/-/delete\".format(\n        \"docs\" if scenario != \"bad_table\" else \"bad_table\", pk\n    )\n    response = await ds_write.client.post(\n        path,\n        headers=_headers(token),\n    )\n    assert response.status_code == 403 if scenario in (\"no_token\", \"bad_token\") else 404\n    assert response.json()[\"ok\"] is False\n    assert (\n        response.json()[\"errors\"] == [\"Permission denied\"]\n        if scenario == \"no_token\"\n        else [\"Table not found\"]\n    )\n    assert len((await ds_write.client.get(\"/data/docs.json?_shape=array\")).json()) == 1\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"table,row_for_create,pks,delete_path\",\n    (\n        (\"rowid_table\", {\"name\": \"rowid row\"}, None, None),\n        (\"pk_table\", {\"id\": 1, \"name\": \"ID table\"}, \"id\", \"1\"),\n        (\n            \"compound_pk_table\",\n            {\"type\": \"article\", \"key\": \"k\"},\n            [\"type\", \"key\"],\n            \"article,k\",\n        ),\n    ),\n)\nasync def test_delete_row(ds_write, table, row_for_create, pks, delete_path):\n    # First create the table with that example row\n    create_data = {\n        \"table\": table,\n        \"row\": row_for_create,\n    }\n    if pks:\n        if isinstance(pks, str):\n            create_data[\"pk\"] = pks\n        else:\n            create_data[\"pks\"] = pks\n    create_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=create_data,\n        headers=_headers(write_token(ds_write)),\n    )\n    assert create_response.status_code == 201, create_response.json()\n    # Should be a single row\n    assert (\n        await ds_write.client.get(\n            \"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}\".format(\n                table\n            )\n        )\n    ).json() == [1]\n    # Now delete the row\n    if delete_path is None:\n        # Special case for that rowid table\n        delete_path = (\n            await ds_write.client.get(\n                \"/data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+{}\".format(\n                    table\n                )\n            )\n        ).json()[0]\n\n    delete_response = await ds_write.client.post(\n        \"/data/{}/{}/-/delete\".format(table, delete_path),\n        headers=_headers(write_token(ds_write)),\n    )\n    assert delete_response.status_code == 200\n\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.name == \"delete-row\"\n    assert event.database == \"data\"\n    assert event.table == table\n    assert event.pks == str(delete_path).split(\",\")\n    assert (\n        await ds_write.client.get(\n            \"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}\".format(\n                table\n            )\n        )\n    ).json() == [0]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"scenario\", (\"no_token\", \"no_perm\", \"bad_table\", \"cannot_alter\")\n)\nasync def test_update_row_check_permission(ds_write, scenario):\n    if scenario == \"no_token\":\n        token = \"bad_token\"\n    elif scenario == \"no_perm\":\n        token = write_token(ds_write, actor_id=\"not-root\")\n    elif scenario == \"cannot_alter\":\n        # update-row but no alter-table:\n        token = write_token(ds_write, permissions=[\"ur\"])\n    else:\n        token = write_token(ds_write)\n\n    pk = await _insert_row(ds_write)\n\n    path = \"/data/{}/{}/-/update\".format(\n        \"docs\" if scenario != \"bad_table\" else \"bad_table\", pk\n    )\n\n    json_body = {\"update\": {\"title\": \"New title\"}}\n    if scenario == \"cannot_alter\":\n        json_body[\"alter\"] = True\n\n    response = await ds_write.client.post(\n        path,\n        json=json_body,\n        headers=_headers(token),\n    )\n    assert response.status_code == 403 if scenario in (\"no_token\", \"bad_token\") else 404\n    assert response.json()[\"ok\"] is False\n    assert (\n        response.json()[\"errors\"] == [\"Permission denied\"]\n        if scenario == \"no_token\"\n        else [\"Table not found\"]\n    )\n\n\n@pytest.mark.asyncio\nasync def test_update_row_invalid_key(ds_write):\n    token = write_token(ds_write)\n\n    pk = await _insert_row(ds_write)\n\n    path = \"/data/docs/{}/-/update\".format(pk)\n    response = await ds_write.client.post(\n        path,\n        json={\"update\": {\"title\": \"New title\"}, \"bad_key\": 1},\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert response.json() == {\"ok\": False, \"errors\": [\"Invalid keys: bad_key\"]}\n\n\n@pytest.mark.asyncio\nasync def test_update_row_alter(ds_write):\n    token = write_token(ds_write, permissions=[\"ur\", \"at\"])\n    pk = await _insert_row(ds_write)\n    path = \"/data/docs/{}/-/update\".format(pk)\n    response = await ds_write.client.post(\n        path,\n        json={\"update\": {\"title\": \"New title\", \"extra\": \"extra\"}, \"alter\": True},\n        headers=_headers(token),\n    )\n    assert response.status_code == 200\n    assert response.json() == {\"ok\": True}\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"input,expected_errors\",\n    (\n        ({\"title\": \"New title\"}, None),\n        ({\"title\": None}, None),\n        ({\"score\": 1.6}, None),\n        ({\"age\": 10}, None),\n        ({\"title\": \"New title\", \"score\": 1.6}, None),\n        ({\"title2\": \"New title\"}, [\"no such column: title2\"]),\n    ),\n)\n@pytest.mark.parametrize(\"use_return\", (True, False))\nasync def test_update_row(ds_write, input, expected_errors, use_return):\n    token = write_token(ds_write)\n    pk = await _insert_row(ds_write)\n\n    path = \"/data/docs/{}/-/update\".format(pk)\n\n    data = {\"update\": input}\n    if use_return:\n        data[\"return\"] = True\n\n    response = await ds_write.client.post(\n        path,\n        json=data,\n        headers=_headers(token),\n    )\n    if expected_errors:\n        assert response.status_code == 400\n        assert response.json()[\"ok\"] is False\n        assert response.json()[\"errors\"] == expected_errors\n        return\n\n    assert response.json()[\"ok\"] is True\n    if not use_return:\n        assert \"row\" not in response.json()\n    else:\n        returned_row = response.json()[\"row\"]\n        assert returned_row[\"id\"] == pk\n        for k, v in input.items():\n            assert returned_row[k] == v\n\n    # Analytics event\n    event = last_event(ds_write)\n    assert event.actor == {\"id\": \"root\", \"token\": \"dstok\"}\n    assert event.database == \"data\"\n    assert event.table == \"docs\"\n    assert event.pks == [str(pk)]\n\n    # And fetch the row to check it's updated\n    response = await ds_write.client.get(\n        \"/data/docs/{}.json?_shape=array\".format(pk),\n    )\n    assert response.status_code == 200\n    row = response.json()[0]\n    assert row[\"id\"] == pk\n    for k, v in input.items():\n        assert row[k] == v\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"scenario\", (\"no_token\", \"no_perm\", \"bad_table\", \"has_perm\", \"immutable\")\n)\nasync def test_drop_table(ds_write, scenario):\n    if scenario == \"no_token\":\n        token = \"bad_token\"\n    elif scenario == \"no_perm\":\n        token = write_token(ds_write, actor_id=\"not-root\")\n    else:\n        token = write_token(ds_write)\n    should_work = scenario == \"has_perm\"\n    await ds_write.get_database(\"data\").execute_write(\n        \"insert into docs (id, title) values (1, 'Row 1')\"\n    )\n    path = \"/{database}/{table}/-/drop\".format(\n        database=\"immutable\" if scenario == \"immutable\" else \"data\",\n        table=\"docs\" if scenario != \"bad_table\" else \"bad_table\",\n    )\n    response = await ds_write.client.post(\n        path,\n        headers=_headers(token),\n    )\n    if not should_work:\n        assert (\n            response.status_code == 403\n            if scenario in (\"no_token\", \"bad_token\")\n            else 404\n        )\n        assert response.json()[\"ok\"] is False\n        expected_error = \"Permission denied\"\n        if scenario == \"bad_table\":\n            expected_error = \"Table not found\"\n        elif scenario == \"immutable\":\n            expected_error = \"Database is immutable\"\n        assert response.json()[\"errors\"] == [expected_error]\n        assert (await ds_write.client.get(\"/data/docs\")).status_code == 200\n    else:\n        # It should show a confirmation page\n        assert response.status_code == 200\n        assert response.json() == {\n            \"ok\": True,\n            \"database\": \"data\",\n            \"table\": \"docs\",\n            \"row_count\": 1,\n            \"message\": 'Pass \"confirm\": true to confirm',\n        }\n        assert (await ds_write.client.get(\"/data/docs\")).status_code == 200\n        # Now send confirm: true\n        response2 = await ds_write.client.post(\n            path,\n            json={\"confirm\": True},\n            headers=_headers(token),\n        )\n        assert response2.json() == {\"ok\": True}\n        # Check event\n        event = last_event(ds_write)\n        assert event.name == \"drop-table\"\n        assert event.actor == {\"id\": \"root\", \"token\": \"dstok\"}\n        assert event.table == \"docs\"\n        assert event.database == \"data\"\n        # Table should 404\n        assert (await ds_write.client.get(\"/data/docs\")).status_code == 404\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"input,expected_status,expected_response,expected_events\",\n    (\n        # Permission error with a bad token\n        (\n            {\"table\": \"bad\", \"row\": {\"id\": 1}},\n            403,\n            {\"ok\": False, \"errors\": [\"Permission denied\"]},\n            [],\n        ),\n        # Successful creation with columns:\n        (\n            {\n                \"table\": \"one\",\n                \"columns\": [\n                    {\n                        \"name\": \"id\",\n                        \"type\": \"integer\",\n                    },\n                    {\n                        \"name\": \"title\",\n                        \"type\": \"text\",\n                    },\n                    {\n                        \"name\": \"score\",\n                        \"type\": \"integer\",\n                    },\n                    {\n                        \"name\": \"weight\",\n                        \"type\": \"float\",\n                    },\n                    {\n                        \"name\": \"thumbnail\",\n                        \"type\": \"blob\",\n                    },\n                ],\n                \"pk\": \"id\",\n            },\n            201,\n            {\n                \"ok\": True,\n                \"database\": \"data\",\n                \"table\": \"one\",\n                \"table_url\": \"http://localhost/data/one\",\n                \"table_api_url\": \"http://localhost/data/one.json\",\n                \"schema\": (\n                    \"CREATE TABLE [one] (\\n\"\n                    \"   [id] INTEGER PRIMARY KEY,\\n\"\n                    \"   [title] TEXT,\\n\"\n                    \"   [score] INTEGER,\\n\"\n                    \"   [weight] FLOAT,\\n\"\n                    \"   [thumbnail] BLOB\\n\"\n                    \")\"\n                ),\n            },\n            [\"create-table\"],\n        ),\n        # Successful creation with rows:\n        (\n            {\n                \"table\": \"two\",\n                \"rows\": [\n                    {\n                        \"id\": 1,\n                        \"title\": \"Row 1\",\n                        \"score\": 1.5,\n                    },\n                    {\n                        \"id\": 2,\n                        \"title\": \"Row 2\",\n                        \"score\": 1.5,\n                    },\n                ],\n                \"pk\": \"id\",\n            },\n            201,\n            {\n                \"ok\": True,\n                \"database\": \"data\",\n                \"table\": \"two\",\n                \"table_url\": \"http://localhost/data/two\",\n                \"table_api_url\": \"http://localhost/data/two.json\",\n                \"schema\": (\n                    \"CREATE TABLE [two] (\\n\"\n                    \"   [id] INTEGER PRIMARY KEY,\\n\"\n                    \"   [title] TEXT,\\n\"\n                    \"   [score] FLOAT\\n\"\n                    \")\"\n                ),\n                \"row_count\": 2,\n            },\n            [\"create-table\", \"insert-rows\"],\n        ),\n        # Successful creation with row:\n        (\n            {\n                \"table\": \"three\",\n                \"row\": {\n                    \"id\": 1,\n                    \"title\": \"Row 1\",\n                    \"score\": 1.5,\n                },\n                \"pk\": \"id\",\n            },\n            201,\n            {\n                \"ok\": True,\n                \"database\": \"data\",\n                \"table\": \"three\",\n                \"table_url\": \"http://localhost/data/three\",\n                \"table_api_url\": \"http://localhost/data/three.json\",\n                \"schema\": (\n                    \"CREATE TABLE [three] (\\n\"\n                    \"   [id] INTEGER PRIMARY KEY,\\n\"\n                    \"   [title] TEXT,\\n\"\n                    \"   [score] FLOAT\\n\"\n                    \")\"\n                ),\n                \"row_count\": 1,\n            },\n            [\"create-table\", \"insert-rows\"],\n        ),\n        # Create with row and no primary key\n        (\n            {\n                \"table\": \"four\",\n                \"row\": {\n                    \"name\": \"Row 1\",\n                },\n            },\n            201,\n            {\n                \"ok\": True,\n                \"database\": \"data\",\n                \"table\": \"four\",\n                \"table_url\": \"http://localhost/data/four\",\n                \"table_api_url\": \"http://localhost/data/four.json\",\n                \"schema\": (\"CREATE TABLE [four] (\\n\" \"   [name] TEXT\\n\" \")\"),\n                \"row_count\": 1,\n            },\n            [\"create-table\", \"insert-rows\"],\n        ),\n        # Create table with compound primary key\n        (\n            {\n                \"table\": \"five\",\n                \"row\": {\"type\": \"article\", \"key\": 123, \"title\": \"Article 1\"},\n                \"pks\": [\"type\", \"key\"],\n            },\n            201,\n            {\n                \"ok\": True,\n                \"database\": \"data\",\n                \"table\": \"five\",\n                \"table_url\": \"http://localhost/data/five\",\n                \"table_api_url\": \"http://localhost/data/five.json\",\n                \"schema\": (\n                    \"CREATE TABLE [five] (\\n   [type] TEXT,\\n   [key] INTEGER,\\n\"\n                    \"   [title] TEXT,\\n   PRIMARY KEY ([type], [key])\\n)\"\n                ),\n                \"row_count\": 1,\n            },\n            [\"create-table\", \"insert-rows\"],\n        ),\n        # Error: Table is required\n        (\n            {\n                \"row\": {\"id\": 1},\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Table is required\"],\n            },\n            [],\n        ),\n        # Error: Invalid table name\n        (\n            {\n                \"table\": \"sqlite_bad_name\",\n                \"row\": {\"id\": 1},\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Invalid table name\"],\n            },\n            [],\n        ),\n        # Error: JSON must be an object\n        (\n            [],\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"JSON must be an object\"],\n            },\n            [],\n        ),\n        # Error: Cannot specify columns with rows or row\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [{\"name\": \"id\", \"type\": \"integer\"}],\n                \"rows\": [{\"id\": 1}],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Cannot specify columns with rows or row\"],\n            },\n            [],\n        ),\n        # Error: columns, rows or row is required\n        (\n            {\n                \"table\": \"bad\",\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"columns, rows or row is required\"],\n            },\n            [],\n        ),\n        # Error: columns must be a list\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": {\"name\": \"id\", \"type\": \"integer\"},\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"columns must be a list\"],\n            },\n            [],\n        ),\n        # Error: columns must be a list of objects\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [\"id\"],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"columns must be a list of objects\"],\n            },\n            [],\n        ),\n        # Error: Column name is required\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [{\"type\": \"integer\"}],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Column name is required\"],\n            },\n            [],\n        ),\n        # Error: Unsupported column type\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [{\"name\": \"id\", \"type\": \"bad\"}],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Unsupported column type: bad\"],\n            },\n            [],\n        ),\n        # Error: Duplicate column name\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [\n                    {\"name\": \"id\", \"type\": \"integer\"},\n                    {\"name\": \"id\", \"type\": \"integer\"},\n                ],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Duplicate column name: id\"],\n            },\n            [],\n        ),\n        # Error: rows must be a list\n        (\n            {\n                \"table\": \"bad\",\n                \"rows\": {\"id\": 1},\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"rows must be a list\"],\n            },\n            [],\n        ),\n        # Error: rows must be a list of objects\n        (\n            {\n                \"table\": \"bad\",\n                \"rows\": [\"id\"],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"rows must be a list of objects\"],\n            },\n            [],\n        ),\n        # Error: pk must be a string\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1},\n                \"pk\": 1,\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"pk must be a string\"],\n            },\n            [],\n        ),\n        # Error: Cannot specify both pk and pks\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1, \"name\": \"Row 1\"},\n                \"pk\": \"id\",\n                \"pks\": [\"id\", \"name\"],\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"Cannot specify both pk and pks\"],\n            },\n            [],\n        ),\n        # Error: pks must be a list\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1, \"name\": \"Row 1\"},\n                \"pks\": \"id\",\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"pks must be a list\"],\n            },\n            [],\n        ),\n        # Error: pks must be a list of strings\n        (\n            {\"table\": \"bad\", \"row\": {\"id\": 1, \"name\": \"Row 1\"}, \"pks\": [1, 2]},\n            400,\n            {\"ok\": False, \"errors\": [\"pks must be a list of strings\"]},\n            [],\n        ),\n        # Error: ignore and replace are mutually exclusive\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1, \"name\": \"Row 1\"},\n                \"pk\": \"id\",\n                \"ignore\": True,\n                \"replace\": True,\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"ignore and replace are mutually exclusive\"],\n            },\n            [],\n        ),\n        # ignore and replace require row or rows\n        (\n            {\n                \"table\": \"bad\",\n                \"columns\": [{\"name\": \"id\", \"type\": \"integer\"}],\n                \"ignore\": True,\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"ignore and replace require row or rows\"],\n            },\n            [],\n        ),\n        # ignore and replace require pk or pks\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1},\n                \"ignore\": True,\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"ignore and replace require pk or pks\"],\n            },\n            [],\n        ),\n        (\n            {\n                \"table\": \"bad\",\n                \"row\": {\"id\": 1},\n                \"replace\": True,\n            },\n            400,\n            {\n                \"ok\": False,\n                \"errors\": [\"ignore and replace require pk or pks\"],\n            },\n            [],\n        ),\n    ),\n)\nasync def test_create_table(\n    ds_write, input, expected_status, expected_response, expected_events\n):\n    ds_write._tracked_events = []\n    # Special case for expected status of 403\n    if expected_status == 403:\n        token = \"bad_token\"\n    else:\n        token = write_token(ds_write)\n    response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=input,\n        headers=_headers(token),\n    )\n    assert response.status_code == expected_status\n    data = response.json()\n    assert data == expected_response\n    # Should have tracked the expected events\n    events = ds_write._tracked_events\n    assert [e.name for e in events] == expected_events\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"permissions,body,expected_status,expected_errors\",\n    (\n        ([\"create-table\"], {\"table\": \"t\", \"columns\": [{\"name\": \"c\"}]}, 201, None),\n        # Need insert-row too if you use \"rows\":\n        (\n            [\"create-table\"],\n            {\"table\": \"t\", \"rows\": [{\"name\": \"c\"}]},\n            403,\n            [\"Permission denied: need insert-row\"],\n        ),\n        # This should work:\n        (\n            [\"create-table\", \"insert-row\"],\n            {\"table\": \"t\", \"rows\": [{\"name\": \"c\"}]},\n            201,\n            None,\n        ),\n        # If you use replace: true you need update-row too:\n        (\n            [\"create-table\", \"insert-row\"],\n            {\"table\": \"t\", \"rows\": [{\"id\": 1}], \"pk\": \"id\", \"replace\": True},\n            403,\n            [\"Permission denied: need update-row\"],\n        ),\n    ),\n)\nasync def test_create_table_permissions(\n    ds_write, permissions, body, expected_status, expected_errors\n):\n    from datasette.tokens import TokenRestrictions\n\n    restrictions = TokenRestrictions()\n    for action in [\"view-instance\"] + permissions:\n        restrictions.allow_all(action)\n    token = await ds_write.create_token(\n        \"root\", handler=\"signed\", restrictions=restrictions\n    )\n    response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=body,\n        headers=_headers(token),\n    )\n    assert response.status_code == expected_status\n    if expected_errors:\n        data = response.json()\n        assert data[\"ok\"] is False\n        assert data[\"errors\"] == expected_errors\n\n\n@pytest.mark.asyncio\n@pytest.mark.xfail(reason=\"Flaky, see https://github.com/simonw/datasette/issues/2356\")\n@pytest.mark.parametrize(\n    \"input,expected_rows_after\",\n    (\n        (\n            {\n                \"table\": \"test_insert_replace\",\n                \"rows\": [\n                    {\"id\": 1, \"name\": \"Row 1 new\"},\n                    {\"id\": 3, \"name\": \"Row 3 new\"},\n                ],\n                \"pk\": \"id\",\n                \"ignore\": True,\n            },\n            [\n                {\"id\": 1, \"name\": \"Row 1\"},\n                {\"id\": 2, \"name\": \"Row 2\"},\n                {\"id\": 3, \"name\": \"Row 3 new\"},\n            ],\n        ),\n        (\n            {\n                \"table\": \"test_insert_replace\",\n                \"rows\": [\n                    {\"id\": 1, \"name\": \"Row 1 new\"},\n                    {\"id\": 3, \"name\": \"Row 3 new\"},\n                ],\n                \"pk\": \"id\",\n                \"replace\": True,\n            },\n            [\n                {\"id\": 1, \"name\": \"Row 1 new\"},\n                {\"id\": 2, \"name\": \"Row 2\"},\n                {\"id\": 3, \"name\": \"Row 3 new\"},\n            ],\n        ),\n    ),\n)\nasync def test_create_table_ignore_replace(ds_write, input, expected_rows_after):\n    # Create table with two rows\n    token = write_token(ds_write)\n    first_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"rows\": [{\"id\": 1, \"name\": \"Row 1\"}, {\"id\": 2, \"name\": \"Row 2\"}],\n            \"table\": \"test_insert_replace\",\n            \"pk\": \"id\",\n        },\n        headers=_headers(token),\n    )\n    assert first_response.status_code == 201\n\n    ds_write._tracked_events = []\n\n    # Try a second time\n    second_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=input,\n        headers=_headers(token),\n    )\n    assert second_response.status_code == 201\n    # Check that the rows are as expected\n    rows = await ds_write.client.get(\"/data/test_insert_replace.json?_shape=array\")\n    assert rows.json() == expected_rows_after\n\n    # Check it fired the right events\n    event_names = [e.name for e in ds_write._tracked_events]\n    assert event_names == [\"insert-rows\"]\n\n\n@pytest.mark.asyncio\nasync def test_create_table_error_if_pk_changed(ds_write):\n    token = write_token(ds_write)\n    first_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"rows\": [{\"id\": 1, \"name\": \"Row 1\"}, {\"id\": 2, \"name\": \"Row 2\"}],\n            \"table\": \"test_insert_replace\",\n            \"pk\": \"id\",\n        },\n        headers=_headers(token),\n    )\n    assert first_response.status_code == 201\n    # Try a second time with a different pk\n    second_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"rows\": [{\"id\": 1, \"name\": \"Row 1\"}, {\"id\": 2, \"name\": \"Row 2\"}],\n            \"table\": \"test_insert_replace\",\n            \"pk\": \"name\",\n            \"replace\": True,\n        },\n        headers=_headers(token),\n    )\n    assert second_response.status_code == 400\n    assert second_response.json() == {\n        \"ok\": False,\n        \"errors\": [\"pk cannot be changed for existing table\"],\n    }\n\n\n@pytest.mark.asyncio\nasync def test_create_table_error_rows_twice_with_duplicates(ds_write):\n    # Error if you don't send ignore: True or replace: True\n    token = write_token(ds_write)\n    input = {\n        \"rows\": [{\"id\": 1, \"name\": \"Row 1\"}, {\"id\": 2, \"name\": \"Row 2\"}],\n        \"table\": \"test_create_twice\",\n        \"pk\": \"id\",\n    }\n    first_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=input,\n        headers=_headers(token),\n    )\n    assert first_response.status_code == 201\n    second_response = await ds_write.client.post(\n        \"/data/-/create\",\n        json=input,\n        headers=_headers(token),\n    )\n    assert second_response.status_code == 400\n    assert second_response.json() == {\n        \"ok\": False,\n        \"errors\": [\"UNIQUE constraint failed: test_create_twice.id\"],\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path\",\n    (\n        \"/data/-/create\",\n        \"/data/docs/-/drop\",\n        \"/data/docs/-/insert\",\n    ),\n)\nasync def test_method_not_allowed(ds_write, path):\n    response = await ds_write.client.get(\n        path,\n        headers={\n            \"Content-Type\": \"application/json\",\n        },\n    )\n    assert response.status_code == 405\n    assert response.json() == {\n        \"ok\": False,\n        \"error\": \"Method not allowed\",\n    }\n\n\n@pytest.mark.asyncio\nasync def test_create_uses_alter_by_default_for_new_table(ds_write):\n    ds_write._tracked_events = []\n    token = write_token(ds_write)\n    response = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"table\": \"new_table\",\n            \"rows\": [\n                {\n                    \"name\": \"Row 1\",\n                }\n            ]\n            * 100\n            + [\n                {\"name\": \"Row 2\", \"extra\": \"Extra\"},\n            ],\n            \"pk\": \"id\",\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n    event_names = [e.name for e in ds_write._tracked_events]\n    assert event_names == [\"create-table\", \"insert-rows\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"has_alter_permission\", (True, False))\nasync def test_create_using_alter_against_existing_table(\n    ds_write, has_alter_permission\n):\n    token = write_token(\n        ds_write, permissions=[\"ir\", \"ct\"] + ([\"at\"] if has_alter_permission else [])\n    )\n    # First create the table\n    response = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"table\": \"new_table\",\n            \"rows\": [\n                {\n                    \"name\": \"Row 1\",\n                }\n            ],\n            \"pk\": \"id\",\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n    ds_write._tracked_events = []\n    # Now try to insert more rows using /-/create with alter=True\n    response2 = await ds_write.client.post(\n        \"/data/-/create\",\n        json={\n            \"table\": \"new_table\",\n            \"rows\": [{\"name\": \"Row 2\", \"extra\": \"extra\"}],\n            \"pk\": \"id\",\n            \"alter\": True,\n        },\n        headers=_headers(token),\n    )\n    if not has_alter_permission:\n        assert response2.status_code == 403\n        assert response2.json() == {\n            \"ok\": False,\n            \"errors\": [\"Permission denied: need alter-table\"],\n        }\n    else:\n        assert response2.status_code == 201\n\n        event_names = [e.name for e in ds_write._tracked_events]\n        assert event_names == [\"alter-table\", \"insert-rows\"]\n\n        # It should have altered the table\n        alter_event = ds_write._tracked_events[0]\n        assert alter_event.name == \"alter-table\"\n        assert \"extra\" not in alter_event.before_schema\n        assert \"extra\" in alter_event.after_schema\n\n        insert_rows_event = ds_write._tracked_events[1]\n        assert insert_rows_event.name == \"insert-rows\"\n        assert insert_rows_event.num_rows == 1\n"
  },
  {
    "path": "tests/test_auth.py",
    "content": "from bs4 import BeautifulSoup as Soup\nfrom .utils import cookie_was_deleted, last_event\nfrom click.testing import CliRunner\nfrom datasette.utils import baseconv\nfrom datasette.cli import cli\nfrom datasette.resources import (\n    DatabaseResource,\n    TableResource,\n)\nimport pytest\nimport time\n\n\n@pytest.mark.asyncio\nasync def test_auth_token(ds_client):\n    \"\"\"The /-/auth-token endpoint sets the correct cookie\"\"\"\n    assert ds_client.ds._root_token is not None\n    path = f\"/-/auth-token?token={ds_client.ds._root_token}\"\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert \"/\" == response.headers[\"Location\"]\n    assert {\"a\": {\"id\": \"root\"}} == ds_client.ds.unsign(\n        response.cookies[\"ds_actor\"], \"actor\"\n    )\n    # Should have recorded a login event\n    event = last_event(ds_client.ds)\n    assert event.name == \"login\"\n    assert event.actor == {\"id\": \"root\"}\n    # Check that a second with same token fails\n    assert ds_client.ds._root_token is None\n    assert (await ds_client.get(path)).status_code == 403\n    # But attempting with same token while logged in as root should redirect to /\n    response = await ds_client.get(\n        path, cookies={\"ds_actor\": ds_client.actor_cookie({\"id\": \"root\"})}\n    )\n    assert response.status_code == 302\n    assert response.headers[\"Location\"] == \"/\"\n\n\n@pytest.mark.asyncio\nasync def test_actor_cookie(ds_client):\n    \"\"\"A valid actor cookie sets request.scope['actor']\"\"\"\n    cookie = ds_client.actor_cookie({\"id\": \"test\"})\n    await ds_client.get(\"/\", cookies={\"ds_actor\": cookie})\n    assert ds_client.ds._last_request.scope[\"actor\"] == {\"id\": \"test\"}\n\n\n@pytest.mark.asyncio\nasync def test_actor_cookie_invalid(ds_client):\n    cookie = ds_client.actor_cookie({\"id\": \"test\"})\n    # Break the signature\n    await ds_client.get(\"/\", cookies={\"ds_actor\": cookie[:-1] + \".\"})\n    assert ds_client.ds._last_request.scope[\"actor\"] is None\n    # Break the cookie format\n    cookie = ds_client.ds.sign({\"b\": {\"id\": \"test\"}}, \"actor\")\n    await ds_client.get(\"/\", cookies={\"ds_actor\": cookie})\n    assert ds_client.ds._last_request.scope[\"actor\"] is None\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"offset,expected\",\n    [\n        ((24 * 60 * 60), {\"id\": \"test\"}),\n        (-(24 * 60 * 60), None),\n    ],\n)\nasync def test_actor_cookie_that_expires(ds_client, offset, expected):\n    expires_at = int(time.time()) + offset\n    cookie = ds_client.ds.sign(\n        {\"a\": {\"id\": \"test\"}, \"e\": baseconv.base62.encode(expires_at)}, \"actor\"\n    )\n    await ds_client.get(\"/\", cookies={\"ds_actor\": cookie})\n    assert ds_client.ds._last_request.scope[\"actor\"] == expected\n\n\ndef test_logout(app_client):\n    # Keeping app_client for the moment because of csrftoken_from\n    response = app_client.get(\n        \"/-/logout\", cookies={\"ds_actor\": app_client.actor_cookie({\"id\": \"test\"})}\n    )\n    assert 200 == response.status\n    assert \"<p>You are logged in as <strong>test</strong></p>\" in response.text\n    # Actors without an id get full serialization\n    response2 = app_client.get(\n        \"/-/logout\", cookies={\"ds_actor\": app_client.actor_cookie({\"name2\": \"bob\"})}\n    )\n    assert 200 == response2.status\n    assert (\n        \"<p>You are logged in as <strong>{&#39;name2&#39;: &#39;bob&#39;}</strong></p>\"\n        in response2.text\n    )\n    # If logged out you get a redirect to /\n    response3 = app_client.get(\"/-/logout\")\n    assert 302 == response3.status\n    # A POST to that page should log the user out\n    response4 = app_client.post(\n        \"/-/logout\",\n        csrftoken_from=True,\n        cookies={\"ds_actor\": app_client.actor_cookie({\"id\": \"test\"})},\n    )\n    # Should have recorded a logout event\n    event = last_event(app_client.ds)\n    assert event.name == \"logout\"\n    assert event.actor == {\"id\": \"test\"}\n    # The ds_actor cookie should have been unset\n    assert cookie_was_deleted(response4, \"ds_actor\")\n    # Should also have set a message\n    messages = app_client.ds.unsign(response4.cookies[\"ds_messages\"], \"messages\")\n    assert [[\"You are now logged out\", 2]] == messages\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"path\", [\"/\", \"/fixtures\", \"/fixtures/facetable\"])\nasync def test_logout_button_in_navigation(ds_client, path):\n    response = await ds_client.get(\n        path, cookies={\"ds_actor\": ds_client.actor_cookie({\"id\": \"test\"})}\n    )\n    anon_response = await ds_client.get(path)\n    for fragment in (\n        \"<strong>test</strong>\",\n        '<form class=\"nav-menu-logout\" action=\"/-/logout\" method=\"post\">',\n    ):\n        assert fragment in response.text\n        assert fragment not in anon_response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"path\", [\"/\", \"/fixtures\", \"/fixtures/facetable\"])\nasync def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(ds_client, path):\n    response = await ds_client.get(path + \"?_bot=1\")\n    assert \"<strong>bot</strong>\" in response.text\n    assert (\n        '<form class=\"nav-menu-logout\" action=\"/-/logout\" method=\"post\">'\n        not in response.text\n    )\n\n\n@pytest.mark.parametrize(\n    \"post_data,errors,expected_duration,expected_r\",\n    (\n        ({\"expire_type\": \"\"}, [], None, None),\n        ({\"expire_type\": \"x\"}, [\"Invalid expire duration\"], None, None),\n        ({\"expire_type\": \"minutes\"}, [\"Invalid expire duration\"], None, None),\n        (\n            {\"expire_type\": \"minutes\", \"expire_duration\": \"x\"},\n            [\"Invalid expire duration\"],\n            None,\n            None,\n        ),\n        (\n            {\"expire_type\": \"minutes\", \"expire_duration\": \"-1\"},\n            [\"Invalid expire duration\"],\n            None,\n            None,\n        ),\n        (\n            {\"expire_type\": \"minutes\", \"expire_duration\": \"0\"},\n            [\"Invalid expire duration\"],\n            None,\n            None,\n        ),\n        ({\"expire_type\": \"minutes\", \"expire_duration\": \"10\"}, [], 600, None),\n        ({\"expire_type\": \"hours\", \"expire_duration\": \"10\"}, [], 10 * 60 * 60, None),\n        ({\"expire_type\": \"days\", \"expire_duration\": \"3\"}, [], 60 * 60 * 24 * 3, None),\n        # Token restrictions\n        ({\"all:view-instance\": \"on\"}, [], None, {\"a\": [\"vi\"]}),\n        ({\"database:fixtures:view-query\": \"on\"}, [], None, {\"d\": {\"fixtures\": [\"vq\"]}}),\n        (\n            {\"resource:fixtures:facetable:insert-row\": \"on\"},\n            [],\n            None,\n            {\"r\": {\"fixtures\": {\"facetable\": [\"ir\"]}}},\n        ),\n    ),\n)\ndef test_auth_create_token(\n    app_client, post_data, errors, expected_duration, expected_r\n):\n    assert app_client.get(\"/-/create-token\").status == 403\n    ds_actor = app_client.actor_cookie({\"id\": \"test\"})\n    response = app_client.get(\"/-/create-token\", cookies={\"ds_actor\": ds_actor})\n    assert response.status == 200\n    assert \">Create an API token<\" in response.text\n    # Confirm some aspects of expected set of checkboxes\n    soup = Soup(response.text, \"html.parser\")\n    checkbox_names = {el[\"name\"] for el in soup.select('input[type=\"checkbox\"]')}\n    assert checkbox_names.issuperset(\n        {\n            \"all:view-instance\",\n            \"all:view-query\",\n            \"database:fixtures:drop-table\",\n            \"resource:fixtures:foreign_key_references:insert-row\",\n            \"resource:fixtures:facetable:set-column-type\",\n        }\n    )\n    # Now try actually creating one\n    response2 = app_client.post(\n        \"/-/create-token\",\n        post_data,\n        csrftoken_from=True,\n        cookies={\"ds_actor\": ds_actor},\n    )\n    assert response2.status == 200\n    if errors:\n        for error in errors:\n            assert '<p class=\"message-error\">{}</p>'.format(error) in response2.text\n    else:\n        # Check create-token event\n        event = last_event(app_client.ds)\n        assert event.name == \"create-token\"\n        assert event.expires_after == expected_duration\n        assert isinstance(event.restrict_all, list)\n        assert isinstance(event.restrict_database, dict)\n        assert isinstance(event.restrict_resource, dict)\n        # Extract token from page\n        token = response2.text.split('value=\"dstok_')[1].split('\"')[0]\n        details = app_client.ds.unsign(token, \"token\")\n        if expected_r:\n            r = details.pop(\"_r\")\n            assert r == expected_r\n        assert details.keys() == {\"a\", \"t\", \"d\"} or details.keys() == {\"a\", \"t\"}\n        assert details[\"a\"] == \"test\"\n        if expected_duration is None:\n            assert \"d\" not in details\n        else:\n            assert details[\"d\"] == expected_duration\n        # And test that token\n        response3 = app_client.get(\n            \"/-/actor.json\",\n            headers={\"Authorization\": \"Bearer {}\".format(\"dstok_{}\".format(token))},\n        )\n        assert response3.status == 200\n        assert response3.json[\"actor\"][\"id\"] == \"test\"\n\n\n@pytest.mark.asyncio\nasync def test_auth_create_token_not_allowed_for_tokens(ds_client):\n    ds_tok = ds_client.ds.sign({\"a\": \"test\", \"token\": \"dstok\"}, \"token\")\n    response = await ds_client.get(\n        \"/-/create-token\",\n        headers={\"Authorization\": \"Bearer dstok_{}\".format(ds_tok)},\n    )\n    assert response.status_code == 403\n\n\n@pytest.mark.asyncio\nasync def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(ds_client):\n    ds_client.ds._settings[\"allow_signed_tokens\"] = False\n    try:\n        ds_actor = ds_client.actor_cookie({\"id\": \"test\"})\n        response = await ds_client.get(\n            \"/-/create-token\", cookies={\"ds_actor\": ds_actor}\n        )\n        assert response.status_code == 403\n    finally:\n        ds_client.ds._settings[\"allow_signed_tokens\"] = True\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"scenario,should_work\",\n    (\n        (\"allow_signed_tokens_off\", False),\n        (\"no_token\", False),\n        (\"no_timestamp\", False),\n        (\"invalid_token\", False),\n        (\"expired_token\", False),\n        (\"valid_unlimited_token\", True),\n        (\"valid_expiring_token\", True),\n    ),\n)\nasync def test_auth_with_dstok_token(ds_client, scenario, should_work):\n    token = None\n    _time = int(time.time())\n    if scenario in (\"valid_unlimited_token\", \"allow_signed_tokens_off\"):\n        token = ds_client.ds.sign({\"a\": \"test\", \"t\": _time}, \"token\")\n    elif scenario == \"valid_expiring_token\":\n        token = ds_client.ds.sign({\"a\": \"test\", \"t\": _time - 50, \"d\": 1000}, \"token\")\n    elif scenario == \"expired_token\":\n        token = ds_client.ds.sign({\"a\": \"test\", \"t\": _time - 2000, \"d\": 1000}, \"token\")\n    elif scenario == \"no_timestamp\":\n        token = ds_client.ds.sign({\"a\": \"test\"}, \"token\")\n    elif scenario == \"invalid_token\":\n        token = \"invalid\"\n    if token:\n        token = \"dstok_{}\".format(token)\n    if scenario == \"allow_signed_tokens_off\":\n        ds_client.ds._settings[\"allow_signed_tokens\"] = False\n    headers = {}\n    if token:\n        headers[\"Authorization\"] = \"Bearer {}\".format(token)\n    response = await ds_client.get(\"/-/actor.json\", headers=headers)\n    try:\n        if should_work:\n            data = response.json()\n            assert data.keys() == {\"actor\"}\n            actor = data[\"actor\"]\n            expected_keys = {\"id\", \"token\"}\n            if scenario != \"valid_unlimited_token\":\n                expected_keys.add(\"token_expires\")\n            assert actor.keys() == expected_keys\n            assert actor[\"id\"] == \"test\"\n            assert actor[\"token\"] == \"dstok\"\n            if scenario != \"valid_unlimited_token\":\n                assert isinstance(actor[\"token_expires\"], int)\n        else:\n            assert response.json() == {\"actor\": None}\n    finally:\n        ds_client.ds._settings[\"allow_signed_tokens\"] = True\n\n\n@pytest.mark.parametrize(\"expires\", (None, 1000, -1000))\ndef test_cli_create_token(app_client, expires):\n    secret = app_client.ds._secret\n    runner = CliRunner()\n    args = [\"create-token\", \"--secret\", secret, \"test\"]\n    if expires:\n        args += [\"--expires-after\", str(expires)]\n    result = runner.invoke(cli, args)\n    assert result.exit_code == 0\n    token = result.output.strip()\n    assert token.startswith(\"dstok_\")\n    details = app_client.ds.unsign(token[len(\"dstok_\") :], \"token\")\n    expected_keys = {\"a\", \"t\"}\n    if expires:\n        expected_keys.add(\"d\")\n    assert details.keys() == expected_keys\n    assert details[\"a\"] == \"test\"\n    response = app_client.get(\n        \"/-/actor.json\", headers={\"Authorization\": \"Bearer {}\".format(token)}\n    )\n    if expires is None or expires > 0:\n        expected_actor = {\n            \"id\": \"test\",\n            \"token\": \"dstok\",\n        }\n        if expires and expires > 0:\n            expected_actor[\"token_expires\"] = details[\"t\"] + expires\n        assert response.json == {\"actor\": expected_actor}\n    else:\n        expected_actor = None\n    assert response.json == {\"actor\": expected_actor}\n\n\n@pytest.mark.asyncio\nasync def test_root_with_root_enabled_gets_all_permissions(ds_client):\n    \"\"\"Root user with root_enabled=True gets all permissions\"\"\"\n    # Ensure catalog tables are populated\n    await ds_client.ds.invoke_startup()\n    await ds_client.ds._refresh_schemas()\n\n    # Set root_enabled to simulate --root flag\n    ds_client.ds.root_enabled = True\n\n    root_actor = {\"id\": \"root\"}\n\n    # Test instance-level permissions (no resource)\n    assert (\n        await ds_client.ds.allowed(action=\"permissions-debug\", actor=root_actor) is True\n    )\n    assert await ds_client.ds.allowed(action=\"debug-menu\", actor=root_actor) is True\n\n    # Test view permissions using the new ds.allowed() method\n    assert await ds_client.ds.allowed(action=\"view-instance\", actor=root_actor) is True\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"view-database\",\n            resource=DatabaseResource(\"fixtures\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"view-table\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    # Test write permissions using ds.allowed()\n    assert (\n        await ds_client.ds.allowed(\n            action=\"insert-row\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"delete-row\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"update-row\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"create-table\",\n            resource=DatabaseResource(\"fixtures\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"alter-table\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"set-column-type\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"drop-table\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is True\n    )\n\n\n@pytest.mark.asyncio\nasync def test_root_without_root_enabled_no_special_permissions(ds_client):\n    \"\"\"Root user without root_enabled doesn't get automatic permissions\"\"\"\n    # Ensure catalog tables are populated\n    await ds_client.ds.invoke_startup()\n    await ds_client.ds._refresh_schemas()\n\n    # Ensure root_enabled is NOT set (or is False)\n    ds_client.ds.root_enabled = False\n\n    root_actor = {\"id\": \"root\"}\n\n    # Test permissions that normally require special access\n    # Without root_enabled, root should follow normal permission rules\n\n    # View permissions should still work (default=True)\n    assert (\n        await ds_client.ds.allowed(action=\"view-instance\", actor=root_actor) is True\n    )  # Default permission\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"view-database\",\n            resource=DatabaseResource(\"fixtures\"),\n            actor=root_actor,\n        )\n        is True\n    )  # Default permission\n\n    # But restricted permissions should NOT automatically be granted\n    # Test with instance-level permission (no resource class)\n    result = await ds_client.ds.allowed(action=\"permissions-debug\", actor=root_actor)\n    assert (\n        result is not True\n    ), \"Root without root_enabled should not automatically get permissions-debug\"\n\n    # Test with resource-based permissions using ds.allowed()\n    assert (\n        await ds_client.ds.allowed(\n            action=\"create-table\",\n            resource=DatabaseResource(\"fixtures\"),\n            actor=root_actor,\n        )\n        is not True\n    ), \"Root without root_enabled should not automatically get create-table\"\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"drop-table\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is not True\n    ), \"Root without root_enabled should not automatically get drop-table\"\n\n    assert (\n        await ds_client.ds.allowed(\n            action=\"set-column-type\",\n            resource=TableResource(\"fixtures\", \"facetable\"),\n            actor=root_actor,\n        )\n        is not True\n    ), \"Root without root_enabled should not automatically get set-column-type\"\n"
  },
  {
    "path": "tests/test_base_view.py",
    "content": "from datasette.views.base import View\nfrom datasette import Request, Response\nfrom datasette.app import Datasette\nimport json\nimport pytest\n\n\nclass GetView(View):\n    async def get(self, request, datasette):\n        return Response.json(\n            {\n                \"absolute_url\": datasette.absolute_url(request, \"/\"),\n                \"request_path\": request.path,\n            }\n        )\n\n\nclass GetAndPostView(GetView):\n    async def post(self, request, datasette):\n        return Response.json(\n            {\n                \"method\": request.method,\n                \"absolute_url\": datasette.absolute_url(request, \"/\"),\n                \"request_path\": request.path,\n            }\n        )\n\n\n@pytest.mark.asyncio\nasync def test_get_view():\n    v = GetView()\n    datasette = Datasette()\n    response = await v(Request.fake(\"/foo\"), datasette)\n    assert json.loads(response.body) == {\n        \"absolute_url\": \"http://localhost/\",\n        \"request_path\": \"/foo\",\n    }\n    # Try a HEAD request\n    head_response = await v(Request.fake(\"/foo\", method=\"HEAD\"), datasette)\n    assert head_response.body == \"\"\n    assert head_response.status == 200\n    # And OPTIONS\n    options_response = await v(Request.fake(\"/foo\", method=\"OPTIONS\"), datasette)\n    assert options_response.body == \"ok\"\n    assert options_response.status == 200\n    assert options_response.headers[\"allow\"] == \"HEAD, GET\"\n    # And POST\n    post_response = await v(Request.fake(\"/foo\", method=\"POST\"), datasette)\n    assert post_response.body == \"Method not allowed\"\n    assert post_response.status == 405\n    # And POST with .json extension\n    post_json_response = await v(Request.fake(\"/foo.json\", method=\"POST\"), datasette)\n    assert json.loads(post_json_response.body) == {\n        \"ok\": False,\n        \"error\": \"Method not allowed\",\n    }\n    assert post_json_response.status == 405\n\n\n@pytest.mark.asyncio\nasync def test_post_view():\n    v = GetAndPostView()\n    datasette = Datasette()\n    response = await v(Request.fake(\"/foo\"), datasette)\n    assert json.loads(response.body) == {\n        \"absolute_url\": \"http://localhost/\",\n        \"request_path\": \"/foo\",\n    }\n    # Try a HEAD request\n    head_response = await v(Request.fake(\"/foo\", method=\"HEAD\"), datasette)\n    assert head_response.body == \"\"\n    assert head_response.status == 200\n    # And OPTIONS\n    options_response = await v(Request.fake(\"/foo\", method=\"OPTIONS\"), datasette)\n    assert options_response.body == \"ok\"\n    assert options_response.status == 200\n    assert options_response.headers[\"allow\"] == \"HEAD, GET, POST\"\n    # And POST\n    post_response = await v(Request.fake(\"/foo\", method=\"POST\"), datasette)\n    assert json.loads(post_response.body) == {\n        \"method\": \"POST\",\n        \"absolute_url\": \"http://localhost/\",\n        \"request_path\": \"/foo\",\n    }\n"
  },
  {
    "path": "tests/test_canned_queries.py",
    "content": "from bs4 import BeautifulSoup as Soup\nimport json\nimport pytest\nimport re\nfrom .fixtures import make_app_client\n\n\n@pytest.fixture\ndef canned_write_client(tmpdir):\n    template_dir = tmpdir / \"canned_write_templates\"\n    template_dir.mkdir()\n    (template_dir / \"query-data-update_name.html\").write_text(\n        \"\"\"\n    {% extends \"query.html\" %}\n    {% block content %}!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!{{ super() }}{% endblock %}\n    \"\"\",\n        \"utf-8\",\n    )\n    with make_app_client(\n        extra_databases={\"data.db\": \"create table names (name text)\"},\n        template_dir=str(template_dir),\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"queries\": {\n                        \"canned_read\": {\"sql\": \"select * from names\"},\n                        \"add_name\": {\n                            \"sql\": \"insert into names (name) values (:name)\",\n                            \"write\": True,\n                            \"on_success_redirect\": \"/data/add_name?success\",\n                        },\n                        \"add_name_specify_id\": {\n                            \"sql\": \"insert into names (rowid, name) values (:rowid, :name)\",\n                            \"on_success_message_sql\": \"select 'Name added: ' || :name || ' with rowid ' || :rowid\",\n                            \"write\": True,\n                            \"on_error_redirect\": \"/data/add_name_specify_id?error\",\n                        },\n                        \"add_name_specify_id_with_error_in_on_success_message_sql\": {\n                            \"sql\": \"insert into names (rowid, name) values (:rowid, :name)\",\n                            \"on_success_message_sql\": \"select this is bad SQL\",\n                            \"write\": True,\n                        },\n                        \"delete_name\": {\n                            \"sql\": \"delete from names where rowid = :rowid\",\n                            \"write\": True,\n                            \"on_success_message\": \"Name deleted\",\n                            \"allow\": {\"id\": \"root\"},\n                        },\n                        \"update_name\": {\n                            \"sql\": \"update names set name = :name where rowid = :rowid\",\n                            \"params\": [\"rowid\", \"name\", \"extra\"],\n                            \"write\": True,\n                        },\n                    }\n                }\n            }\n        },\n    ) as client:\n        yield client\n\n\n@pytest.fixture\ndef canned_write_immutable_client():\n    with make_app_client(\n        is_immutable=True,\n        config={\n            \"databases\": {\n                \"fixtures\": {\n                    \"queries\": {\n                        \"add\": {\n                            \"sql\": \"insert into sortable (text) values (:text)\",\n                            \"write\": True,\n                        },\n                    }\n                }\n            }\n        },\n    ) as client:\n        yield client\n\n\n@pytest.mark.asyncio\nasync def test_canned_query_with_named_parameter(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/neighborhood_search.json?text=town&_shape=arrays\"\n    )\n    assert response.json()[\"rows\"] == [\n        [\"Corktown\", \"Detroit\", \"MI\"],\n        [\"Downtown\", \"Los Angeles\", \"CA\"],\n        [\"Downtown\", \"Detroit\", \"MI\"],\n        [\"Greektown\", \"Detroit\", \"MI\"],\n        [\"Koreatown\", \"Los Angeles\", \"CA\"],\n        [\"Mexicantown\", \"Detroit\", \"MI\"],\n    ]\n\n\ndef test_insert(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/add_name\",\n        {\"name\": \"Hello\"},\n        csrftoken_from=True,\n        cookies={\"foo\": \"bar\"},\n    )\n    messages = canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n    assert messages == [[\"Query executed, 1 row affected\", 1]]\n    assert response.status == 302\n    assert response.headers[\"Location\"] == \"/data/add_name?success\"\n\n\n@pytest.mark.parametrize(\n    \"query_name,expect_csrf_hidden_field\",\n    [\n        (\"canned_read\", False),\n        (\"add_name_specify_id\", True),\n        (\"add_name\", True),\n    ],\n)\ndef test_canned_query_form_csrf_hidden_field(\n    canned_write_client, query_name, expect_csrf_hidden_field\n):\n    response = canned_write_client.get(f\"/data/{query_name}\")\n    html = response.text\n    fragment = '<input type=\"hidden\" name=\"csrftoken\" value=\"'\n    if expect_csrf_hidden_field:\n        assert fragment in html\n    else:\n        assert fragment not in html\n\n\ndef test_insert_with_cookies_requires_csrf(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/add_name\",\n        {\"name\": \"Hello\"},\n        cookies={\"foo\": \"bar\"},\n    )\n    assert 403 == response.status\n\n\ndef test_insert_no_cookies_no_csrf(canned_write_client):\n    response = canned_write_client.post(\"/data/add_name\", {\"name\": \"Hello\"})\n    assert 302 == response.status\n    assert \"/data/add_name?success\" == response.headers[\"Location\"]\n\n\ndef test_custom_success_message(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/delete_name\",\n        {\"rowid\": 1},\n        cookies={\"ds_actor\": canned_write_client.actor_cookie({\"id\": \"root\"})},\n        csrftoken_from=True,\n    )\n    assert 302 == response.status\n    messages = canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n    assert [[\"Name deleted\", 1]] == messages\n\n\ndef test_insert_error(canned_write_client):\n    canned_write_client.post(\"/data/add_name\", {\"name\": \"Hello\"}, csrftoken_from=True)\n    response = canned_write_client.post(\n        \"/data/add_name_specify_id\",\n        {\"rowid\": 1, \"name\": \"Should fail\"},\n        csrftoken_from=True,\n    )\n    assert 302 == response.status\n    assert \"/data/add_name_specify_id?error\" == response.headers[\"Location\"]\n    messages = canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n    assert [[\"UNIQUE constraint failed: names.rowid\", 3]] == messages\n    # How about with a custom error message?\n    canned_write_client.ds.config[\"databases\"][\"data\"][\"queries\"][\n        \"add_name_specify_id\"\n    ][\"on_error_message\"] = \"ERROR\"\n    response = canned_write_client.post(\n        \"/data/add_name_specify_id\",\n        {\"rowid\": 1, \"name\": \"Should fail\"},\n        csrftoken_from=True,\n    )\n    assert [[\"ERROR\", 3]] == canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n\n\ndef test_on_success_message_sql(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/add_name_specify_id\",\n        {\"rowid\": 5, \"name\": \"Should be OK\"},\n        csrftoken_from=True,\n    )\n    assert response.status == 302\n    assert response.headers[\"Location\"] == \"/data/add_name_specify_id\"\n    messages = canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n    assert messages == [[\"Name added: Should be OK with rowid 5\", 1]]\n\n\ndef test_error_in_on_success_message_sql(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/add_name_specify_id_with_error_in_on_success_message_sql\",\n        {\"rowid\": 1, \"name\": \"Should fail\"},\n        csrftoken_from=True,\n    )\n    messages = canned_write_client.ds.unsign(\n        response.cookies[\"ds_messages\"], \"messages\"\n    )\n    assert messages == [\n        [\"Error running on_success_message_sql: no such column: bad\", 3]\n    ]\n\n\ndef test_custom_params(canned_write_client):\n    response = canned_write_client.get(\"/data/update_name?extra=foo\")\n    assert '<input type=\"text\" id=\"qp3\" name=\"extra\" value=\"foo\">' in response.text\n\n\ndef test_vary_header(canned_write_client):\n    # These forms embed a csrftoken so they should be served with Vary: Cookie\n    assert \"vary\" not in canned_write_client.get(\"/data\").headers\n    assert \"Cookie\" == canned_write_client.get(\"/data/update_name\").headers[\"vary\"]\n\n\ndef test_json_post_body(canned_write_client):\n    response = canned_write_client.post(\n        \"/data/add_name\",\n        body=json.dumps({\"name\": [\"Hello\", \"there\"]}),\n    )\n    assert 302 == response.status\n    assert \"/data/add_name?success\" == response.headers[\"Location\"]\n    rows = canned_write_client.get(\"/data/names.json?_shape=array\").json\n    assert rows == [{\"rowid\": 1, \"name\": \"['Hello', 'there']\"}]\n\n\n@pytest.mark.parametrize(\n    \"headers,body,querystring\",\n    (\n        (None, \"name=NameGoesHere\", \"?_json=1\"),\n        ({\"Accept\": \"application/json\"}, \"name=NameGoesHere\", None),\n        (None, \"name=NameGoesHere&_json=1\", None),\n        (None, '{\"name\": \"NameGoesHere\", \"_json\": 1}', None),\n    ),\n)\ndef test_json_response(canned_write_client, headers, body, querystring):\n    response = canned_write_client.post(\n        \"/data/add_name\" + (querystring or \"\"),\n        body=body,\n        headers=headers,\n    )\n    assert 200 == response.status\n    assert response.headers[\"content-type\"] == \"application/json; charset=utf-8\"\n    assert response.json == {\n        \"ok\": True,\n        \"message\": \"Query executed, 1 row affected\",\n        \"redirect\": \"/data/add_name?success\",\n    }\n    rows = canned_write_client.get(\"/data/names.json?_shape=array\").json\n    assert rows == [{\"rowid\": 1, \"name\": \"NameGoesHere\"}]\n\n\ndef test_canned_query_permissions_on_database_page(canned_write_client):\n    # Without auth only shows three queries\n    query_names = {\n        q[\"name\"] for q in canned_write_client.get(\"/data.json\").json[\"queries\"]\n    }\n    assert query_names == {\n        \"add_name_specify_id_with_error_in_on_success_message_sql\",\n        \"from_hook\",\n        \"update_name\",\n        \"add_name_specify_id\",\n        \"from_async_hook\",\n        \"canned_read\",\n        \"add_name\",\n    }\n\n    # With auth shows four\n    response = canned_write_client.get(\n        \"/data.json\",\n        cookies={\"ds_actor\": canned_write_client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status == 200\n    query_names_and_private = sorted(\n        [\n            {\"name\": q[\"name\"], \"private\": q[\"private\"]}\n            for q in response.json[\"queries\"]\n        ],\n        key=lambda q: q[\"name\"],\n    )\n    assert query_names_and_private == [\n        {\"name\": \"add_name\", \"private\": False},\n        {\"name\": \"add_name_specify_id\", \"private\": False},\n        {\n            \"name\": \"add_name_specify_id_with_error_in_on_success_message_sql\",\n            \"private\": False,\n        },\n        {\"name\": \"canned_read\", \"private\": False},\n        {\"name\": \"delete_name\", \"private\": True},\n        {\"name\": \"from_async_hook\", \"private\": False},\n        {\"name\": \"from_hook\", \"private\": False},\n        {\"name\": \"update_name\", \"private\": False},\n    ]\n\n\ndef test_canned_query_permissions(canned_write_client):\n    assert 403 == canned_write_client.get(\"/data/delete_name\").status\n    assert 200 == canned_write_client.get(\"/data/update_name\").status\n    cookies = {\"ds_actor\": canned_write_client.actor_cookie({\"id\": \"root\"})}\n    assert 200 == canned_write_client.get(\"/data/delete_name\", cookies=cookies).status\n    assert 200 == canned_write_client.get(\"/data/update_name\", cookies=cookies).status\n\n\n@pytest.fixture(scope=\"session\")\ndef magic_parameters_client():\n    with make_app_client(\n        extra_databases={\"data.db\": \"create table logs (line text)\"},\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"queries\": {\n                        \"runme_post\": {\"sql\": \"\", \"write\": True},\n                        \"runme_get\": {\"sql\": \"\"},\n                    }\n                }\n            }\n        },\n    ) as client:\n        yield client\n\n\n@pytest.mark.parametrize(\n    \"magic_parameter,expected_re\",\n    [\n        (\"_actor_id\", \"root\"),\n        (\"_header_host\", \"localhost\"),\n        (\"_header_not_a_thing\", \"\"),\n        (\"_cookie_foo\", \"bar\"),\n        (\"_now_epoch\", r\"^\\d+$\"),\n        (\"_now_date_utc\", r\"^\\d{4}-\\d{2}-\\d{2}$\"),\n        (\"_now_datetime_utc\", r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$\"),\n        (\"_random_chars_1\", r\"^\\w$\"),\n        (\"_random_chars_10\", r\"^\\w{10}$\"),\n    ],\n)\ndef test_magic_parameters(magic_parameters_client, magic_parameter, expected_re):\n    magic_parameters_client.ds.config[\"databases\"][\"data\"][\"queries\"][\"runme_post\"][\n        \"sql\"\n    ] = f\"insert into logs (line) values (:{magic_parameter})\"\n    magic_parameters_client.ds.config[\"databases\"][\"data\"][\"queries\"][\"runme_get\"][\n        \"sql\"\n    ] = f\"select :{magic_parameter} as result\"\n    cookies = {\n        \"ds_actor\": magic_parameters_client.actor_cookie({\"id\": \"root\"}),\n        \"foo\": \"bar\",\n    }\n    # Test the GET version\n    get_response = magic_parameters_client.get(\n        \"/data/runme_get.json?_shape=array\", cookies=cookies\n    )\n    get_actual = get_response.json[0][\"result\"]\n    assert re.match(expected_re, str(get_actual))\n    # Test the form\n    form_response = magic_parameters_client.get(\"/data/runme_post\")\n    soup = Soup(form_response.body, \"html.parser\")\n    # The magic parameter should not be represented as a form field\n    assert None is soup.find(\"input\", {\"name\": magic_parameter})\n    # Submit the form to create a log line\n    response = magic_parameters_client.post(\n        \"/data/runme_post?_json=1\", {}, csrftoken_from=True, cookies=cookies\n    )\n    assert response.json == {\n        \"ok\": True,\n        \"message\": \"Query executed, 1 row affected\",\n        \"redirect\": None,\n    }\n    post_actual = magic_parameters_client.get(\n        \"/data/logs.json?_sort_desc=rowid&_shape=array\"\n    ).json[0][\"line\"]\n    assert re.match(expected_re, post_actual)\n\n\n@pytest.mark.parametrize(\"use_csrf\", [True, False])\n@pytest.mark.parametrize(\"return_json\", [True, False])\ndef test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_json):\n    magic_parameters_client.ds.config[\"databases\"][\"data\"][\"queries\"][\"runme_post\"][\n        \"sql\"\n    ] = \"insert into logs (line) values (:_header_host)\"\n    qs = \"\"\n    if return_json:\n        qs = \"?_json=1\"\n    response = magic_parameters_client.post(\n        f\"/data/runme_post{qs}\",\n        {},\n        csrftoken_from=use_csrf or None,\n    )\n    if return_json:\n        assert response.status == 200\n        assert response.json[\"ok\"], response.json\n    else:\n        assert response.status == 302\n        messages = magic_parameters_client.ds.unsign(\n            response.cookies[\"ds_messages\"], \"messages\"\n        )\n        assert [[\"Query executed, 1 row affected\", 1]] == messages\n    post_actual = magic_parameters_client.get(\n        \"/data/logs.json?_sort_desc=rowid&_shape=array\"\n    ).json[0][\"line\"]\n    assert post_actual == \"localhost\"\n\n\ndef test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_client):\n    response = magic_parameters_client.get(\n        \"/data/-/query.json?sql=select+:_header_host&_shape=array\"\n    )\n    assert 400 == response.status\n    assert response.json[\"error\"].startswith(\"You did not supply a value for binding\")\n\n\ndef test_canned_write_custom_template(canned_write_client):\n    response = canned_write_client.get(\"/data/update_name\")\n    assert response.status == 200\n    assert \"!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!\" in response.text\n    assert (\n        \"<!-- Templates considered: *query-data-update_name.html, query-data.html, query.html -->\"\n        in response.text\n    )\n    # And test for link rel=alternate while we're here:\n    assert (\n        '<link rel=\"alternate\" type=\"application/json+datasette\" href=\"http://localhost/data/update_name.json\">'\n        in response.text\n    )\n    assert (\n        response.headers[\"link\"]\n        == '<http://localhost/data/update_name.json>; rel=\"alternate\"; type=\"application/json+datasette\"'\n    )\n\n\ndef test_canned_write_query_disabled_for_immutable_database(\n    canned_write_immutable_client,\n):\n    response = canned_write_immutable_client.get(\"/fixtures/add\")\n    assert response.status == 200\n    assert (\n        \"This query cannot be executed because the database is immutable.\"\n        in response.text\n    )\n    assert '<input type=\"submit\" value=\"Run SQL\" disabled>' in response.text\n    # Submitting form should get a forbidden error\n    response = canned_write_immutable_client.post(\n        \"/fixtures/add\",\n        {\"text\": \"text\"},\n        csrftoken_from=True,\n    )\n    assert response.status == 403\n    assert \"Database is immutable\" in response.text\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "from .fixtures import (\n    make_app_client,\n    TestClient as _TestClient,\n    EXPECTED_PLUGINS,\n)\nfrom datasette.app import SETTINGS\nfrom datasette.plugins import DEFAULT_PLUGINS, pm\nfrom datasette.cli import cli, serve\nfrom datasette.version import __version__\nfrom datasette.utils import tilde_encode\nfrom datasette.utils.sqlite import sqlite3\nfrom click.testing import CliRunner\nimport io\nimport json\nimport pathlib\nimport pytest\nimport sys\nimport textwrap\nfrom unittest import mock\n\n\ndef test_inspect_cli(app_client):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"inspect\", \"fixtures.db\"])\n    data = json.loads(result.output)\n    assert [\"fixtures\"] == list(data.keys())\n    database = data[\"fixtures\"]\n    assert \"fixtures.db\" == database[\"file\"]\n    assert isinstance(database[\"hash\"], str)\n    assert 64 == len(database[\"hash\"])\n    for table_name, expected_count in {\n        \"Table With Space In Name\": 0,\n        \"facetable\": 15,\n    }.items():\n        assert expected_count == database[\"tables\"][table_name][\"count\"]\n\n\ndef test_inspect_cli_writes_to_file(app_client):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli, [\"inspect\", \"fixtures.db\", \"--inspect-file\", \"foo.json\"]\n    )\n    assert 0 == result.exit_code, result.output\n    with open(\"foo.json\") as fp:\n        data = json.load(fp)\n    assert [\"fixtures\"] == list(data.keys())\n\n\ndef test_serve_with_inspect_file_prepopulates_table_counts_cache():\n    inspect_data = {\"fixtures\": {\"tables\": {\"hithere\": {\"count\": 44}}}}\n    with make_app_client(inspect_data=inspect_data, is_immutable=True) as client:\n        assert inspect_data == client.ds.inspect_data\n        db = client.ds.databases[\"fixtures\"]\n        assert {\"hithere\": 44} == db.cached_table_counts\n\n\n@pytest.mark.parametrize(\n    \"spatialite_paths,should_suggest_load_extension\",\n    (\n        ([], False),\n        ([\"/tmp\"], True),\n    ),\n)\ndef test_spatialite_error_if_attempt_to_open_spatialite(\n    spatialite_paths, should_suggest_load_extension\n):\n    with mock.patch(\"datasette.utils.SPATIALITE_PATHS\", spatialite_paths):\n        runner = CliRunner()\n        result = runner.invoke(\n            cli, [\"serve\", str(pathlib.Path(__file__).parent / \"spatialite.db\")]\n        )\n        assert result.exit_code != 0\n        assert \"It looks like you're trying to load a SpatiaLite\" in result.output\n        suggestion = \"--load-extension=spatialite\"\n        if should_suggest_load_extension:\n            assert suggestion in result.output\n        else:\n            assert suggestion not in result.output\n\n\n@mock.patch(\"datasette.utils.SPATIALITE_PATHS\", [\"/does/not/exist\"])\ndef test_spatialite_error_if_cannot_find_load_extension_spatialite():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            str(pathlib.Path(__file__).parent / \"spatialite.db\"),\n            \"--load-extension\",\n            \"spatialite\",\n        ],\n    )\n    assert result.exit_code != 0\n    assert \"Could not find SpatiaLite extension\" in result.output\n\n\ndef test_plugins_cli(app_client):\n    runner = CliRunner()\n    result1 = runner.invoke(cli, [\"plugins\"])\n    actual_plugins = sorted(\n        [p for p in json.loads(result1.output) if p[\"name\"] != \"TrackEventPlugin\"],\n        key=lambda p: p[\"name\"],\n    )\n    assert actual_plugins == EXPECTED_PLUGINS\n    # Try with --all\n    result2 = runner.invoke(cli, [\"plugins\", \"--all\"])\n    names = [p[\"name\"] for p in json.loads(result2.output)]\n    # Should have all the EXPECTED_PLUGINS\n    assert set(names).issuperset({p[\"name\"] for p in EXPECTED_PLUGINS})\n    # And the following too:\n    assert set(names).issuperset(DEFAULT_PLUGINS)\n    # --requirements should be empty because there are no installed non-plugins-dir plugins\n    result3 = runner.invoke(cli, [\"plugins\", \"--requirements\"])\n    assert result3.output == \"\"\n\n\ndef test_metadata_yaml():\n    yaml_file = io.StringIO(textwrap.dedent(\"\"\"\n    title: Hello from YAML\n    \"\"\"))\n    # Annoyingly we have to provide all default arguments here:\n    ds = serve.callback(\n        [],\n        metadata=yaml_file,\n        immutable=[],\n        host=\"127.0.0.1\",\n        port=8001,\n        uds=None,\n        reload=False,\n        cors=False,\n        sqlite_extensions=[],\n        inspect_file=None,\n        template_dir=None,\n        plugins_dir=None,\n        static=[],\n        memory=False,\n        config=[],\n        settings=[],\n        secret=None,\n        root=False,\n        default_deny=False,\n        token=None,\n        actor=None,\n        version_note=None,\n        get=None,\n        headers=False,\n        help_settings=False,\n        pdb=False,\n        crossdb=False,\n        nolock=False,\n        open_browser=False,\n        create=False,\n        ssl_keyfile=None,\n        ssl_certfile=None,\n        return_instance=True,\n        internal=None,\n    )\n    client = _TestClient(ds)\n    response = client.get(\"/.json\")\n    assert {\"title\": \"Hello from YAML\"} == response.json[\"metadata\"]\n\n\n@mock.patch(\"datasette.cli.run_module\")\ndef test_install(run_module):\n    runner = CliRunner()\n    runner.invoke(cli, [\"install\", \"datasette-mock-plugin\", \"datasette-mock-plugin2\"])\n    run_module.assert_called_once_with(\"pip\", run_name=\"__main__\")\n    assert sys.argv == [\n        \"pip\",\n        \"install\",\n        \"datasette-mock-plugin\",\n        \"datasette-mock-plugin2\",\n    ]\n\n\n@pytest.mark.parametrize(\"flag\", [\"-U\", \"--upgrade\"])\n@mock.patch(\"datasette.cli.run_module\")\ndef test_install_upgrade(run_module, flag):\n    runner = CliRunner()\n    runner.invoke(cli, [\"install\", flag, \"datasette\"])\n    run_module.assert_called_once_with(\"pip\", run_name=\"__main__\")\n    assert sys.argv == [\"pip\", \"install\", \"--upgrade\", \"datasette\"]\n\n\n@mock.patch(\"datasette.cli.run_module\")\ndef test_install_requirements(run_module, tmpdir):\n    path = tmpdir.join(\"requirements.txt\")\n    path.write(\"datasette-mock-plugin\\ndatasette-plugin-2\")\n    runner = CliRunner()\n    runner.invoke(cli, [\"install\", \"-r\", str(path)])\n    run_module.assert_called_once_with(\"pip\", run_name=\"__main__\")\n    assert sys.argv == [\"pip\", \"install\", \"-r\", str(path)]\n\n\ndef test_install_error_if_no_packages():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"install\"])\n    assert result.exit_code == 2\n    assert \"Error: Please specify at least one package to install\" in result.output\n\n\n@mock.patch(\"datasette.cli.run_module\")\ndef test_uninstall(run_module):\n    runner = CliRunner()\n    runner.invoke(cli, [\"uninstall\", \"datasette-mock-plugin\", \"-y\"])\n    run_module.assert_called_once_with(\"pip\", run_name=\"__main__\")\n    assert sys.argv == [\"pip\", \"uninstall\", \"datasette-mock-plugin\", \"-y\"]\n\n\ndef test_version():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--version\"])\n    assert result.output == f\"cli, version {__version__}\\n\"\n\n\n@pytest.mark.parametrize(\"invalid_port\", [\"-1\", \"0.5\", \"dog\", \"65536\"])\ndef test_serve_invalid_ports(invalid_port):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--port\", invalid_port])\n    assert result.exit_code == 2\n    assert \"Invalid value for '-p'\" in result.stderr\n\n\n@pytest.mark.parametrize(\n    \"args\",\n    (\n        [\"--setting\", \"default_page_size\", \"5\"],\n        [\"--setting\", \"settings.default_page_size\", \"5\"],\n        [\"-s\", \"settings.default_page_size\", \"5\"],\n    ),\n)\ndef test_setting(args):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--get\", \"/-/settings.json\"] + args)\n    assert result.exit_code == 0, result.output\n    settings = json.loads(result.output)\n    assert settings[\"default_page_size\"] == 5\n\n\ndef test_setting_compatible_with_config(tmp_path):\n    # https://github.com/simonw/datasette/issues/2389\n    runner = CliRunner()\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(\n        '{\"settings\": {\"default_page_size\": 5, \"sql_time_limit_ms\": 50}}', \"utf-8\"\n    )\n    result = runner.invoke(\n        cli,\n        [\n            \"--get\",\n            \"/-/settings.json\",\n            \"--config\",\n            str(config_path),\n            \"--setting\",\n            \"default_page_size\",\n            \"10\",\n        ],\n    )\n    assert result.exit_code == 0, result.output\n    settings = json.loads(result.output)\n    assert settings[\"default_page_size\"] == 10\n    assert settings[\"sql_time_limit_ms\"] == 50\n\n\ndef test_plugin_s_overwrite():\n    runner = CliRunner()\n    plugins_dir = str(pathlib.Path(__file__).parent / \"plugins\")\n\n    result = runner.invoke(\n        cli,\n        [\n            \"--plugins-dir\",\n            plugins_dir,\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+prepare_connection_args()\",\n        ],\n    )\n    assert result.exit_code == 0, result.output\n    assert (\n        json.loads(result.output).get(\"rows\")[0].get(\"prepare_connection_args()\")\n        == 'database=_memory, datasette.plugin_config(\"name-of-plugin\")=None'\n    )\n\n    result = runner.invoke(\n        cli,\n        [\n            \"--plugins-dir\",\n            plugins_dir,\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+prepare_connection_args()\",\n            \"-s\",\n            \"plugins.name-of-plugin\",\n            \"OVERRIDE\",\n        ],\n    )\n    assert result.exit_code == 0, result.output\n    assert (\n        json.loads(result.output).get(\"rows\")[0].get(\"prepare_connection_args()\")\n        == 'database=_memory, datasette.plugin_config(\"name-of-plugin\")=OVERRIDE'\n    )\n\n\ndef test_startup_error_from_plugin_is_click_exception(tmp_path):\n    plugins_dir = tmp_path / \"plugins\"\n    plugins_dir.mkdir()\n    (plugins_dir / \"startup_error.py\").write_text(\n        \"from datasette import hookimpl\\n\"\n        \"from datasette.utils import StartupError\\n\"\n        \"\\n\"\n        \"@hookimpl\\n\"\n        \"def startup(datasette):\\n\"\n        '    raise StartupError(\"boom\")\\n',\n        \"utf-8\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"--plugins-dir\",\n            str(plugins_dir),\n            \"--get\",\n            \"/\",\n        ],\n    )\n    try:\n        assert result.exit_code == 1\n        assert \"Error: boom\" in result.output\n    finally:\n        # Cleanup: Unregister the plugin to avoid test isolation issues\n        to_unregister = [\n            p for p in pm.get_plugins() if p.__name__ == \"startup_error.py\"\n        ]\n        if to_unregister:\n            pm.unregister(to_unregister[0])\n\n\ndef test_setting_type_validation():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--setting\", \"default_page_size\", \"dog\"])\n    assert result.exit_code == 2\n    assert '\"settings.default_page_size\" should be an integer' in result.output\n\n\ndef test_setting_boolean_validation_invalid():\n    \"\"\"Test that invalid boolean values are rejected\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(\n        cli, [\"--setting\", \"default_allow_sql\", \"invalid\", \"--get\", \"/-/settings.json\"]\n    )\n    assert result.exit_code == 2\n    assert (\n        '\"settings.default_allow_sql\" should be on/off/true/false/1/0' in result.output\n    )\n\n\n@pytest.mark.parametrize(\"value\", (\"off\", \"false\", \"0\"))\ndef test_setting_boolean_validation_false_values(value):\n    \"\"\"Test that 'off', 'false', '0' work for boolean settings\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"--setting\",\n            \"default_allow_sql\",\n            value,\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+1\",\n        ],\n    )\n    # Should be forbidden (setting is false)\n    assert result.exit_code == 1, result.output\n    assert \"Forbidden\" in result.output\n\n\n@pytest.mark.parametrize(\"value\", (\"on\", \"true\", \"1\"))\ndef test_setting_boolean_validation_true_values(value):\n    \"\"\"Test that 'on', 'true', '1' work for boolean settings\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"--setting\",\n            \"default_allow_sql\",\n            value,\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+1&_shape=objects\",\n        ],\n    )\n    # Should succeed (setting is true)\n    assert result.exit_code == 0, result.output\n    assert json.loads(result.output)[\"rows\"][0] == {\"1\": 1}\n\n\n@pytest.mark.parametrize(\"default_allow_sql\", (True, False))\ndef test_setting_default_allow_sql(default_allow_sql):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"--setting\",\n            \"default_allow_sql\",\n            \"on\" if default_allow_sql else \"off\",\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+21&_shape=objects\",\n        ],\n    )\n    if default_allow_sql:\n        assert result.exit_code == 0, result.output\n        assert json.loads(result.output)[\"rows\"][0] == {\"21\": 21}\n    else:\n        assert result.exit_code == 1, result.output\n        # This isn't JSON at the moment, maybe it should be though\n        assert \"Forbidden\" in result.output\n\n\ndef test_sql_errors_logged_to_stderr():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--get\", \"/_memory/-/query.json?sql=select+blah\"])\n    assert result.exit_code == 1\n    assert \"sql = 'select blah', params = {}: no such column: blah\\n\" in result.stderr\n\n\ndef test_serve_create(tmpdir):\n    runner = CliRunner()\n    db_path = tmpdir / \"does_not_exist_yet.db\"\n    assert not db_path.exists()\n    result = runner.invoke(\n        cli, [str(db_path), \"--create\", \"--get\", \"/-/databases.json\"]\n    )\n    assert result.exit_code == 0, result.output\n    databases = json.loads(result.output)\n    assert {\n        \"name\": \"does_not_exist_yet\",\n        \"is_mutable\": True,\n        \"is_memory\": False,\n        \"hash\": None,\n    }.items() <= databases[0].items()\n    assert db_path.exists()\n\n\n@pytest.mark.parametrize(\"argument\", (\"-c\", \"--config\"))\n@pytest.mark.parametrize(\"format_\", (\"json\", \"yaml\"))\ndef test_serve_config(tmpdir, argument, format_):\n    config_path = tmpdir / \"datasette.{}\".format(format_)\n    config_path.write_text(\n        (\n            \"settings:\\n  default_page_size: 5\\n\"\n            if format_ == \"yaml\"\n            else '{\"settings\": {\"default_page_size\": 5}}'\n        ),\n        \"utf-8\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            argument,\n            str(config_path),\n            \"--get\",\n            \"/-/settings.json\",\n        ],\n    )\n    assert result.exit_code == 0, result.output\n    assert json.loads(result.output)[\"default_page_size\"] == 5\n\n\ndef test_serve_duplicate_database_names(tmpdir):\n    \"'datasette db.db nested/db.db' should attach two databases, /db and /db_2\"\n    runner = CliRunner()\n    db_1_path = str(tmpdir / \"db.db\")\n    nested = tmpdir / \"nested\"\n    nested.mkdir()\n    db_2_path = str(tmpdir / \"nested\" / \"db.db\")\n    for path in (db_1_path, db_2_path):\n        sqlite3.connect(path).execute(\"vacuum\")\n    result = runner.invoke(cli, [db_1_path, db_2_path, \"--get\", \"/-/databases.json\"])\n    assert result.exit_code == 0, result.output\n    databases = json.loads(result.output)\n    assert {db[\"name\"] for db in databases} == {\"db\", \"db_2\"}\n\n\n@pytest.mark.parametrize(\n    \"filename\", [\"test-database (1).sqlite\", \"database (1).sqlite\"]\n)\ndef test_weird_database_names(tmpdir, filename):\n    # https://github.com/simonw/datasette/issues/1181\n    runner = CliRunner()\n    db_path = str(tmpdir / filename)\n    sqlite3.connect(db_path).execute(\"vacuum\")\n    result1 = runner.invoke(cli, [db_path, \"--get\", \"/\"])\n    assert result1.exit_code == 0, result1.output\n    filename_no_stem = filename.rsplit(\".\", 1)[0]\n    expected_link = '<a href=\"/{}\">{}</a>'.format(\n        tilde_encode(filename_no_stem), filename_no_stem\n    )\n    assert expected_link in result1.output\n    # Now try hitting that database page\n    result2 = runner.invoke(\n        cli, [db_path, \"--get\", \"/{}\".format(tilde_encode(filename_no_stem))]\n    )\n    assert result2.exit_code == 0, result2.output\n\n\ndef test_help_settings():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--help-settings\"])\n    for setting in SETTINGS:\n        assert setting.name in result.output\n\n\ndef test_internal_db(tmpdir):\n    runner = CliRunner()\n    internal_path = tmpdir / \"internal.db\"\n    assert not internal_path.exists()\n    result = runner.invoke(\n        cli, [\"--memory\", \"--internal\", str(internal_path), \"--get\", \"/\"]\n    )\n    assert result.exit_code == 0\n    assert internal_path.exists()\n\n\ndef test_duplicate_database_files_error(tmpdir):\n    \"\"\"Test that passing the same database file multiple times raises an error\"\"\"\n    runner = CliRunner()\n    db_path = str(tmpdir / \"test.db\")\n    sqlite3.connect(db_path).execute(\"vacuum\")\n\n    # Test with exact duplicate\n    result = runner.invoke(cli, [\"serve\", db_path, db_path, \"--get\", \"/\"])\n    assert result.exit_code == 1\n    assert \"Duplicate database file\" in result.output\n    assert \"both refer to\" in result.output\n\n    # Test with different paths to same file (relative vs absolute)\n    result2 = runner.invoke(\n        cli, [\"serve\", db_path, str(pathlib.Path(db_path).resolve()), \"--get\", \"/\"]\n    )\n    assert result2.exit_code == 1\n    assert \"Duplicate database file\" in result2.output\n\n    # Test that a file in the config_dir can't also be passed explicitly\n    config_dir = tmpdir / \"config\"\n    config_dir.mkdir()\n    config_db_path = str(config_dir / \"data.db\")\n    sqlite3.connect(config_db_path).execute(\"vacuum\")\n\n    result3 = runner.invoke(\n        cli, [\"serve\", config_db_path, str(config_dir), \"--get\", \"/\"]\n    )\n    assert result3.exit_code == 1\n    assert \"Duplicate database file\" in result3.output\n    assert \"both refer to\" in result3.output\n\n    # Test that mixing a file NOT in the directory with a directory works fine\n    other_db_path = str(tmpdir / \"other.db\")\n    sqlite3.connect(other_db_path).execute(\"vacuum\")\n\n    result4 = runner.invoke(\n        cli, [\"serve\", other_db_path, str(config_dir), \"--get\", \"/-/databases.json\"]\n    )\n    assert result4.exit_code == 0\n    databases = json.loads(result4.output)\n    assert {db[\"name\"] for db in databases} == {\"other\", \"data\"}\n\n    # Test that multiple directories raise an error\n    config_dir2 = tmpdir / \"config2\"\n    config_dir2.mkdir()\n\n    result5 = runner.invoke(\n        cli, [\"serve\", str(config_dir), str(config_dir2), \"--get\", \"/\"]\n    )\n    assert result5.exit_code == 1\n    assert \"Cannot pass multiple directories\" in result5.output\n"
  },
  {
    "path": "tests/test_cli_serve_get.py",
    "content": "from datasette.cli import cli\nfrom datasette.plugins import pm\nfrom click.testing import CliRunner\nimport textwrap\nimport json\n\n\ndef test_serve_with_get(tmp_path_factory):\n    plugins_dir = tmp_path_factory.mktemp(\"plugins_for_serve_with_get\")\n    (plugins_dir / \"init_for_serve_with_get.py\").write_text(\n        textwrap.dedent(\n            \"\"\"\n        from datasette import hookimpl\n\n        @hookimpl\n        def startup(datasette):\n            with open(\"{}\", \"w\") as fp:\n                fp.write(\"hello\")\n    \"\"\".format(str(plugins_dir / \"hello.txt\")),\n        ),\n        \"utf-8\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--memory\",\n            \"--plugins-dir\",\n            str(plugins_dir),\n            \"--get\",\n            \"/_memory/-/query.json?sql=select+sqlite_version()\",\n        ],\n    )\n    assert result.exit_code == 0, result.output\n    data = json.loads(result.output)\n    # Should have a single row with a single column\n    assert len(data[\"rows\"]) == 1\n    assert list(data[\"rows\"][0].keys()) == [\"sqlite_version()\"]\n    assert set(data.keys()) == {\"rows\", \"ok\", \"truncated\"}\n\n    # The plugin should have created hello.txt\n    assert (plugins_dir / \"hello.txt\").read_text() == \"hello\"\n\n    # Annoyingly that new test plugin stays resident - we need\n    # to manually unregister it to avoid conflict with other tests\n    to_unregister = [\n        p for p in pm.get_plugins() if p.__name__ == \"init_for_serve_with_get.py\"\n    ][0]\n    pm.unregister(to_unregister)\n\n\ndef test_serve_with_get_headers():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--memory\",\n            \"--get\",\n            \"/_memory/\",\n            \"--headers\",\n        ],\n    )\n    # exit_code is 1 because it wasn't a 200 response\n    assert result.exit_code == 1, result.output\n    lines = result.output.splitlines()\n    assert lines and lines[0] == \"HTTP/1.1 302\"\n    assert \"location: /_memory\" in lines\n    assert \"content-type: text/html; charset=utf-8\" in lines\n\n\ndef test_serve_with_get_and_token():\n    runner = CliRunner()\n    result1 = runner.invoke(\n        cli,\n        [\n            \"create-token\",\n            \"--secret\",\n            \"sekrit\",\n            \"root\",\n        ],\n    )\n    token = result1.output.strip()\n    result2 = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--secret\",\n            \"sekrit\",\n            \"--get\",\n            \"/-/actor.json\",\n            \"--token\",\n            token,\n        ],\n    )\n    assert 0 == result2.exit_code, result2.output\n    assert json.loads(result2.output) == {\"actor\": {\"id\": \"root\", \"token\": \"dstok\"}}\n\n\ndef test_serve_with_get_exit_code_for_error():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--memory\",\n            \"--get\",\n            \"/this-is-404\",\n        ],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 1\n    assert \"404\" in result.output\n\n\ndef test_serve_get_actor():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--memory\",\n            \"--get\",\n            \"/-/actor.json\",\n            \"--actor\",\n            '{\"id\": \"root\", \"extra\": \"x\"}',\n        ],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert json.loads(result.output) == {\n        \"actor\": {\n            \"id\": \"root\",\n            \"extra\": \"x\",\n        }\n    }\n"
  },
  {
    "path": "tests/test_cli_serve_server.py",
    "content": "import httpx\nimport pytest\nimport socket\n\n\n@pytest.mark.serial\ndef test_serve_localhost_http(ds_localhost_http_server):\n    response = httpx.get(\"http://localhost:8041/_memory.json\")\n    assert {\n        \"database\": \"_memory\",\n        \"path\": \"/_memory\",\n        \"tables\": [],\n    }.items() <= response.json().items()\n\n\n@pytest.mark.serial\n@pytest.mark.skipif(\n    not hasattr(socket, \"AF_UNIX\"), reason=\"Requires socket.AF_UNIX support\"\n)\ndef test_serve_unix_domain_socket(ds_unix_domain_socket_server):\n    _, uds = ds_unix_domain_socket_server\n    transport = httpx.HTTPTransport(uds=uds)\n    client = httpx.Client(transport=transport)\n    response = client.get(\"http://localhost/_memory.json\")\n    assert {\n        \"database\": \"_memory\",\n        \"path\": \"/_memory\",\n        \"tables\": [],\n    }.items() <= response.json().items()\n"
  },
  {
    "path": "tests/test_column_types.py",
    "content": "import json\nimport logging\n\nfrom bs4 import BeautifulSoup as Soup\nfrom datasette.app import Datasette\nfrom datasette.column_types import (\n    ColumnType,\n    SQLiteType,\n)\nfrom datasette.hookspecs import hookimpl\nfrom datasette.plugins import pm\nfrom datasette.utils import sqlite3\nfrom datasette.utils import StartupError\nimport markupsafe\nimport pytest\nimport time\n\n\n@pytest.fixture\ndef ds_ct(tmp_path_factory):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\n        \"create table posts (id integer primary key, title text, body text, \"\n        \"author_email text, website text, metadata text)\"\n    )\n    db.execute(\n        \"insert into posts values (1, 'Hello', '# World', 'test@example.com', \"\n        \"'https://example.com', '{\\\"key\\\": \\\"value\\\"}')\"\n    )\n    db.commit()\n    ds = Datasette(\n        [db_path],\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"tables\": {\n                        \"posts\": {\n                            \"column_types\": {\n                                \"body\": \"markdown\",\n                                \"author_email\": \"email\",\n                                \"website\": \"url\",\n                                \"metadata\": \"json\",\n                            }\n                        }\n                    }\n                }\n            }\n        },\n    )\n    ds.root_enabled = True\n    yield ds\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\n@pytest.fixture\ndef ds_ct_editor_permission(tmp_path_factory):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\n        \"create table posts (id integer primary key, title text, body text, \"\n        \"author_email text, website text, metadata text)\"\n    )\n    db.execute(\n        \"insert into posts values (1, 'Hello', '# World', 'test@example.com', \"\n        \"'https://example.com', '{\\\"key\\\": \\\"value\\\"}')\"\n    )\n    db.commit()\n    ds = Datasette(\n        [db_path],\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"tables\": {\n                        \"posts\": {\n                            \"permissions\": {\"set-column-type\": {\"id\": \"editor\"}},\n                            \"column_types\": {\n                                \"body\": \"markdown\",\n                                \"author_email\": \"email\",\n                                \"website\": \"url\",\n                                \"metadata\": \"json\",\n                            },\n                        }\n                    }\n                }\n            }\n        },\n    )\n    ds.root_enabled = True\n    yield ds\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\ndef write_token(ds, actor_id=\"root\", permissions=None):\n    to_sign = {\"a\": actor_id, \"token\": \"dstok\", \"t\": int(time.time())}\n    if permissions:\n        to_sign[\"_r\"] = {\"a\": permissions}\n    return \"dstok_{}\".format(ds.sign(to_sign, namespace=\"token\"))\n\n\ndef _headers(token):\n    return {\n        \"Authorization\": \"Bearer {}\".format(token),\n        \"Content-Type\": \"application/json\",\n    }\n\n\ndef _window_data_from_html(html, variable_name):\n    soup = Soup(html, \"html.parser\")\n    scripts = soup.find_all(\"script\")\n    matching_scripts = [\n        script for script in scripts if variable_name in (script.string or \"\")\n    ]\n    assert len(matching_scripts) == 1\n    script_text = matching_scripts[0].string.strip()\n    prefix = f\"window.{variable_name} = \"\n    assert script_text.startswith(prefix)\n    return json.loads(script_text[len(prefix) :].rstrip(\";\"))\n\n\n# --- Internal DB and config loading ---\n\n\n@pytest.mark.asyncio\nasync def test_column_types_table_created(ds_ct):\n    await ds_ct.invoke_startup()\n    internal = ds_ct.get_internal_database()\n    result = await internal.execute(\n        \"SELECT name FROM sqlite_master WHERE type='table' AND name='column_types'\"\n    )\n    assert len(result.rows) == 1\n\n\n@pytest.mark.asyncio\nasync def test_config_loaded_into_internal_db(ds_ct):\n    await ds_ct.invoke_startup()\n    ct_map = await ds_ct.get_column_types(\"data\", \"posts\")\n    # \"markdown\" is not a registered type, so it won't appear\n    assert \"body\" not in ct_map\n    assert ct_map[\"author_email\"].name == \"email\"\n    assert ct_map[\"author_email\"].config is None\n    assert ct_map[\"website\"].name == \"url\"\n    assert ct_map[\"metadata\"].name == \"json\"\n\n\n@pytest.mark.asyncio\nasync def test_config_with_type_and_config(tmp_path_factory):\n    class PointColumnType(ColumnType):\n        name = \"point\"\n        description = \"Geographic point\"\n\n    class _Plugin:\n        @hookimpl\n        def register_column_types(self, datasette):\n            return [PointColumnType]\n\n    plugin = _Plugin()\n    pm.register(plugin, name=\"test_point_ct\")\n    try:\n        db_directory = tmp_path_factory.mktemp(\"dbs\")\n        db_path = str(db_directory / \"data.db\")\n        db = sqlite3.connect(str(db_path))\n        db.execute(\"vacuum\")\n        db.execute(\"create table geo (id integer primary key, location text)\")\n        ds = Datasette(\n            [db_path],\n            config={\n                \"databases\": {\n                    \"data\": {\n                        \"tables\": {\n                            \"geo\": {\n                                \"column_types\": {\n                                    \"location\": {\n                                        \"type\": \"point\",\n                                        \"config\": {\"srid\": 4326},\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n            },\n        )\n        await ds.invoke_startup()\n        ct = await ds.get_column_type(\"data\", \"geo\", \"location\")\n        assert ct.name == \"point\"\n        assert ct.config == {\"srid\": 4326}\n        db.close()\n        for database in ds.databases.values():\n            if not database.is_memory:\n                database.close()\n    finally:\n        pm.unregister(plugin, name=\"test_point_ct\")\n\n\n# --- Datasette API methods ---\n\n\n@pytest.mark.asyncio\nasync def test_get_column_type(ds_ct):\n    await ds_ct.invoke_startup()\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"author_email\")\n    assert isinstance(ct, ColumnType)\n    assert ct.name == \"email\"\n    assert ct.config is None\n\n\n@pytest.mark.asyncio\nasync def test_get_column_type_missing(ds_ct):\n    await ds_ct.invoke_startup()\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct is None\n\n\n@pytest.mark.asyncio\nasync def test_set_and_remove_column_type(ds_ct):\n    await ds_ct.invoke_startup()\n    await ds_ct.set_column_type(\"data\", \"posts\", \"title\", \"email\")\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct.name == \"email\"\n    assert ct.config is None\n\n    await ds_ct.remove_column_type(\"data\", \"posts\", \"title\")\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct is None\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_with_config(ds_ct):\n    await ds_ct.invoke_startup()\n    await ds_ct.set_column_type(\"data\", \"posts\", \"title\", \"url\", {\"max_length\": 200})\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct.name == \"url\"\n    assert ct.config == {\"max_length\": 200}\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_api(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct, permissions=[\"sct\"])\n    response = await ds_ct.client.post(\n        \"/data/posts/-/set-column-type\",\n        json={\"column\": \"title\", \"column_type\": {\"type\": \"email\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 200\n    assert response.json() == {\n        \"ok\": True,\n        \"database\": \"data\",\n        \"table\": \"posts\",\n        \"column\": \"title\",\n        \"column_type\": {\"type\": \"email\", \"config\": None},\n    }\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct.name == \"email\"\n    assert ct.config is None\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_api_with_config(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct, permissions=[\"sct\"])\n    response = await ds_ct.client.post(\n        \"/data/posts/-/set-column-type\",\n        json={\n            \"column\": \"title\",\n            \"column_type\": {\"type\": \"url\", \"config\": {\"max_length\": 200}},\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 200\n    assert response.json()[\"column_type\"] == {\n        \"type\": \"url\",\n        \"config\": {\"max_length\": 200},\n    }\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct.name == \"url\"\n    assert ct.config == {\"max_length\": 200}\n\n\n@pytest.mark.asyncio\nasync def test_clear_column_type_api(ds_ct):\n    await ds_ct.invoke_startup()\n    await ds_ct.set_column_type(\"data\", \"posts\", \"title\", \"email\")\n    token = write_token(ds_ct, permissions=[\"sct\"])\n    response = await ds_ct.client.post(\n        \"/data/posts/-/set-column-type\",\n        json={\"column\": \"title\", \"column_type\": None},\n        headers=_headers(token),\n    )\n    assert response.status_code == 200\n    assert response.json() == {\n        \"ok\": True,\n        \"database\": \"data\",\n        \"table\": \"posts\",\n        \"column\": \"title\",\n        \"column_type\": None,\n    }\n    ct = await ds_ct.get_column_type(\"data\", \"posts\", \"title\")\n    assert ct is None\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"body,special_case,expected_status,expected_errors\",\n    (\n        (\n            {\"column\": \"title\", \"column_type\": {\"type\": \"email\"}},\n            \"no_permission\",\n            403,\n            [\"Permission denied\"],\n        ),\n        (\n            None,\n            \"invalid_json\",\n            400,\n            [\n                \"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)\"\n            ],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": {\"type\": \"email\"}},\n            \"invalid_content_type\",\n            400,\n            [\"Invalid content-type, must be application/json\"],\n        ),\n        (\n            [],\n            None,\n            400,\n            [\"JSON must be a dictionary\"],\n        ),\n        (\n            {\"column_type\": {\"type\": \"email\"}},\n            None,\n            400,\n            ['\"column\" is required'],\n        ),\n        (\n            {\"column\": 1, \"column_type\": {\"type\": \"email\"}},\n            None,\n            400,\n            ['\"column\" must be a string'],\n        ),\n        (\n            {\"column\": \"not_a_column\", \"column_type\": {\"type\": \"email\"}},\n            None,\n            400,\n            [\"Column not found: not_a_column\"],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": \"email\"},\n            None,\n            400,\n            ['\"column_type\" must be an object or null'],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": {}},\n            None,\n            400,\n            ['\"column_type.type\" is required'],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": {\"type\": 1}},\n            None,\n            400,\n            ['\"column_type.type\" must be a string'],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": {\"type\": \"url\", \"config\": []}},\n            None,\n            400,\n            ['\"column_type.config\" must be a dictionary'],\n        ),\n        (\n            {\"column\": \"title\", \"column_type\": {\"type\": \"markdown\"}},\n            None,\n            400,\n            [\"Unknown column type: markdown\"],\n        ),\n        (\n            {\"column\": \"id\", \"column_type\": {\"type\": \"json\"}},\n            None,\n            400,\n            [\n                \"Column type 'json' is only applicable to SQLite types TEXT but data.posts.id has SQLite type INTEGER\"\n            ],\n        ),\n        (\n            {\n                \"column\": \"title\",\n                \"column_type\": {\"type\": \"email\"},\n                \"extra\": True,\n            },\n            None,\n            400,\n            ['Invalid parameter: \"extra\"'],\n        ),\n    ),\n)\nasync def test_set_column_type_api_errors(\n    ds_ct, body, special_case, expected_status, expected_errors\n):\n    await ds_ct.invoke_startup()\n    token = write_token(\n        ds_ct,\n        permissions=([\"sct\"] if special_case != \"no_permission\" else [\"vi\"]),\n    )\n    kwargs = {\n        \"headers\": {\n            \"Authorization\": f\"Bearer {token}\",\n            \"Content-Type\": (\n                \"text/plain\"\n                if special_case == \"invalid_content_type\"\n                else \"application/json\"\n            ),\n        }\n    }\n    if special_case == \"invalid_json\":\n        kwargs[\"content\"] = \"{bad json\"\n    else:\n        kwargs[\"json\"] = body\n    response = await ds_ct.client.post(\"/data/posts/-/set-column-type\", **kwargs)\n    assert response.status_code == expected_status\n    assert response.json() == {\"ok\": False, \"errors\": expected_errors}\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_api_works_for_immutable_database(tmp_path_factory):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"immutable.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table posts (id integer primary key, title text)\")\n    db.commit()\n    ds = Datasette([], immutables=[db_path])\n    ds.root_enabled = True\n    try:\n        await ds.invoke_startup()\n        token = write_token(ds, permissions=[\"sct\"])\n        response = await ds.client.post(\n            \"/immutable/posts/-/set-column-type\",\n            json={\"column\": \"title\", \"column_type\": {\"type\": \"email\"}},\n            headers=_headers(token),\n        )\n        assert response.status_code == 200\n        assert response.json()[\"column_type\"] == {\"type\": \"email\", \"config\": None}\n        ct = await ds.get_column_type(\"immutable\", \"posts\", \"title\")\n        assert ct.name == \"email\"\n    finally:\n        db.close()\n        for database in ds.databases.values():\n            if not database.is_memory:\n                database.close()\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_rejects_incompatible_sqlite_type(ds_ct):\n    await ds_ct.invoke_startup()\n    with pytest.raises(ValueError, match=\"only applicable to SQLite types TEXT\"):\n        await ds_ct.set_column_type(\"data\", \"posts\", \"id\", \"json\")\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_allows_varchar_for_text_only_type(tmp_path_factory):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table links (id integer primary key, url varchar(255))\")\n    db.commit()\n    ds = Datasette([db_path])\n    await ds.invoke_startup()\n    await ds.set_column_type(\"data\", \"links\", \"url\", \"url\")\n    ct = await ds.get_column_type(\"data\", \"links\", \"url\")\n    assert ct.name == \"url\"\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\n# --- Plugin registration ---\n\n\n@pytest.mark.asyncio\nasync def test_builtin_column_types_registered(ds_ct):\n    \"\"\"register_column_types returns classes; _column_types stores them by name.\"\"\"\n    await ds_ct.invoke_startup()\n    assert \"url\" in ds_ct._column_types\n    assert \"email\" in ds_ct._column_types\n    assert \"json\" in ds_ct._column_types\n    assert \"nonexistent\" not in ds_ct._column_types\n\n\n@pytest.mark.asyncio\nasync def test_column_type_class_attributes(ds_ct):\n    await ds_ct.invoke_startup()\n    url_cls = ds_ct._column_types[\"url\"]\n    assert url_cls.name == \"url\"\n    assert url_cls.description == \"URL\"\n    assert url_cls.sqlite_types == (SQLiteType.TEXT,)\n    email_cls = ds_ct._column_types[\"email\"]\n    assert email_cls.name == \"email\"\n    assert email_cls.description == \"Email address\"\n    assert email_cls.sqlite_types == (SQLiteType.TEXT,)\n    json_cls = ds_ct._column_types[\"json\"]\n    assert json_cls.sqlite_types == (SQLiteType.TEXT,)\n\n\ndef test_sqlite_type_from_declared_type():\n    assert SQLiteType.from_declared_type(\"text\") == SQLiteType.TEXT\n    assert SQLiteType.from_declared_type(\"varchar(255)\") == SQLiteType.TEXT\n    assert SQLiteType.from_declared_type(\"integer\") == SQLiteType.INTEGER\n    assert SQLiteType.from_declared_type(\"float\") == SQLiteType.REAL\n    assert SQLiteType.from_declared_type(\"blob\") == SQLiteType.BLOB\n    assert SQLiteType.from_declared_type(\"\") == SQLiteType.NULL\n    assert SQLiteType.from_declared_type(\"numeric\") is None\n\n\n# --- JSON API ---\n\n\n@pytest.mark.asyncio\nasync def test_column_types_extra(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=column_types\")\n    assert response.status_code == 200\n    data = response.json()\n    assert \"column_types\" in data\n    assert data[\"column_types\"][\"author_email\"] == {\"type\": \"email\", \"config\": None}\n    assert data[\"column_types\"][\"website\"] == {\"type\": \"url\", \"config\": None}\n    assert data[\"column_types\"][\"metadata\"] == {\"type\": \"json\", \"config\": None}\n    # \"markdown\" is not a registered type, so body should not appear\n    assert \"body\" not in data[\"column_types\"]\n    # title has no column type, should not appear\n    assert \"title\" not in data[\"column_types\"]\n\n\n@pytest.mark.asyncio\nasync def test_display_columns_include_column_type(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=display_columns\")\n    assert response.status_code == 200\n    data = response.json()\n    cols = {c[\"name\"]: c for c in data[\"display_columns\"]}\n    assert cols[\"author_email\"][\"column_type\"] == \"email\"\n    assert cols[\"author_email\"][\"column_type_config\"] is None\n    assert cols[\"website\"][\"column_type\"] == \"url\"\n    assert cols[\"title\"][\"column_type\"] is None\n\n\n# --- Rendering ---\n\n\n@pytest.mark.asyncio\nasync def test_url_render_cell(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=render_cell\")\n    assert response.status_code == 200\n    data = response.json()\n    rendered = data[\"render_cell\"][0]\n    assert \"href\" in rendered[\"website\"]\n    assert \"https://example.com\" in rendered[\"website\"]\n\n\n@pytest.mark.asyncio\nasync def test_email_render_cell(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=render_cell\")\n    assert response.status_code == 200\n    data = response.json()\n    rendered = data[\"render_cell\"][0]\n    assert \"mailto:\" in rendered[\"author_email\"]\n    assert \"test@example.com\" in rendered[\"author_email\"]\n\n\n@pytest.mark.asyncio\nasync def test_json_render_cell(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=render_cell\")\n    assert response.status_code == 200\n    data = response.json()\n    rendered = data[\"render_cell\"][0]\n    assert \"<pre>\" in rendered[\"metadata\"]\n\n\n# --- Validation ---\n\n\n@pytest.mark.asyncio\nasync def test_email_validation_on_insert(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"author_email\": \"not-an-email\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert \"author_email\" in response.json()[\"errors\"][0]\n\n\n@pytest.mark.asyncio\nasync def test_email_validation_passes_valid(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"author_email\": \"valid@example.com\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n\n@pytest.mark.asyncio\nasync def test_url_validation_on_insert(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"website\": \"not-a-url\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert \"website\" in response.json()[\"errors\"][0]\n\n\n@pytest.mark.asyncio\nasync def test_json_validation_on_insert(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"metadata\": \"not-json{\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert \"metadata\" in response.json()[\"errors\"][0]\n\n\n@pytest.mark.asyncio\nasync def test_validation_on_update(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/1/-/update\",\n        json={\"update\": {\"author_email\": \"invalid\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert \"author_email\" in response.json()[\"errors\"][0]\n\n\n@pytest.mark.asyncio\nasync def test_validation_allows_null(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"author_email\": None}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n\n@pytest.mark.asyncio\nasync def test_validation_allows_empty_string(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/insert\",\n        json={\"row\": {\"title\": \"Test\", \"author_email\": \"\"}},\n        headers=_headers(token),\n    )\n    assert response.status_code == 201\n\n\n# --- ColumnType base class ---\n\n\n@pytest.mark.asyncio\nasync def test_column_type_base_defaults():\n    class TestType(ColumnType):\n        name = \"test\"\n        description = \"Test type\"\n\n    ct = TestType()\n    assert ct.config is None\n    assert await ct.render_cell(\"val\", \"col\", \"tbl\", \"db\", None, None) is None\n    assert await ct.validate(\"val\", None) is None\n    assert await ct.transform_value(\"val\", None) == \"val\"\n\n\n# --- render_cell extra with column types ---\n\n\n@pytest.mark.asyncio\nasync def test_render_cell_extra_with_column_types(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts.json?_extra=render_cell\")\n    assert response.status_code == 200\n    data = response.json()\n    rendered = data[\"render_cell\"][0]\n    assert \"mailto:\" in rendered[\"author_email\"]\n    assert \"href\" in rendered[\"website\"]\n\n\n# --- Duplicate column type name ---\n\n\n@pytest.mark.asyncio\nasync def test_duplicate_column_type_name_raises_error():\n    class DuplicateUrlType(ColumnType):\n        name = \"url\"\n        description = \"Duplicate URL\"\n\n        async def render_cell(self, value, column, table, database, datasette, request):\n            return None\n\n    class _Plugin:\n        @hookimpl\n        def register_column_types(self, datasette):\n            return [DuplicateUrlType]\n\n    plugin = _Plugin()\n    pm.register(plugin, name=\"test_duplicate_ct\")\n    try:\n        ds = Datasette()\n        with pytest.raises(StartupError, match=\"Duplicate column type name: url\"):\n            await ds.invoke_startup()\n    finally:\n        pm.unregister(plugin, name=\"test_duplicate_ct\")\n\n\n# --- Row endpoint ---\n\n\n@pytest.mark.asyncio\nasync def test_row_endpoint_render_cell_with_column_types(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts/1.json?_extra=render_cell\")\n    assert response.status_code == 200\n    data = response.json()\n    rendered = data[\"render_cell\"][0]\n    assert \"mailto:\" in rendered[\"author_email\"]\n    assert \"href\" in rendered[\"website\"]\n\n\n# --- transform_value in JSON output ---\n\n\n@pytest.mark.asyncio\nasync def test_transform_value_in_json_output(tmp_path_factory):\n    \"\"\"A column type with transform_value should modify rows in JSON API.\"\"\"\n\n    class UpperColumnType(ColumnType):\n        name = \"upper\"\n        description = \"Uppercase\"\n\n        async def transform_value(self, value, datasette):\n            if isinstance(value, str):\n                return value.upper()\n            return value\n\n    class _Plugin:\n        @hookimpl\n        def register_column_types(self, datasette):\n            return [UpperColumnType]\n\n    plugin = _Plugin()\n    pm.register(plugin, name=\"test_transform_ct\")\n    try:\n        db_directory = tmp_path_factory.mktemp(\"dbs\")\n        db_path = str(db_directory / \"data.db\")\n        db = sqlite3.connect(str(db_path))\n        db.execute(\"vacuum\")\n        db.execute(\"create table t (id integer primary key, name text)\")\n        db.execute(\"insert into t values (1, 'hello')\")\n        db.commit()\n        ds = Datasette(\n            [db_path],\n            config={\n                \"databases\": {\n                    \"data\": {\"tables\": {\"t\": {\"column_types\": {\"name\": \"upper\"}}}}\n                }\n            },\n        )\n        await ds.invoke_startup()\n        response = await ds.client.get(\"/data/t.json\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"rows\"][0][\"name\"] == \"HELLO\"\n        db.close()\n        for database in ds.databases.values():\n            if not database.is_memory:\n                database.close()\n    finally:\n        pm.unregister(plugin, name=\"test_transform_ct\")\n\n\n# --- Column type priority over plugins ---\n\n\n@pytest.mark.asyncio\nasync def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factory):\n    \"\"\"Column type render_cell should take priority over render_cell plugin hook.\"\"\"\n\n    class PriorityColumnType(ColumnType):\n        name = \"priority_test\"\n        description = \"Priority test\"\n\n        async def render_cell(self, value, column, table, database, datasette, request):\n            if value is not None:\n                return markupsafe.Markup(\n                    f\"<b>COLUMN_TYPE:{markupsafe.escape(value)}</b>\"\n                )\n            return None\n\n    class _ColumnTypePlugin:\n        @hookimpl\n        def register_column_types(self, datasette):\n            return [PriorityColumnType]\n\n    class _RenderCellPlugin:\n        @hookimpl\n        def render_cell(\n            self,\n            row,\n            value,\n            column,\n            table,\n            pks,\n            database,\n            datasette,\n            request,\n            column_type,\n        ):\n            if column == \"name\":\n                return markupsafe.Markup(f\"<i>PLUGIN:{markupsafe.escape(value)}</i>\")\n\n    ct_plugin = _ColumnTypePlugin()\n    rc_plugin = _RenderCellPlugin()\n    pm.register(ct_plugin, name=\"test_priority_ct\")\n    pm.register(rc_plugin, name=\"test_priority_render\")\n    try:\n        db_directory = tmp_path_factory.mktemp(\"dbs\")\n        db_path = str(db_directory / \"data.db\")\n        db = sqlite3.connect(str(db_path))\n        db.execute(\"vacuum\")\n        db.execute(\"create table t (id integer primary key, name text)\")\n        db.execute(\"insert into t values (1, 'hello')\")\n        db.commit()\n        ds = Datasette(\n            [db_path],\n            config={\n                \"databases\": {\n                    \"data\": {\n                        \"tables\": {\"t\": {\"column_types\": {\"name\": \"priority_test\"}}}\n                    }\n                }\n            },\n        )\n        await ds.invoke_startup()\n        response = await ds.client.get(\"/data/t.json?_extra=render_cell\")\n        assert response.status_code == 200\n        data = response.json()\n        rendered = data[\"render_cell\"][0]\n        # Column type should win over the plugin\n        assert \"COLUMN_TYPE:\" in rendered[\"name\"]\n        assert \"PLUGIN:\" not in rendered[\"name\"]\n        db.close()\n        for database in ds.databases.values():\n            if not database.is_memory:\n                database.close()\n    finally:\n        pm.unregister(ct_plugin, name=\"test_priority_ct\")\n        pm.unregister(rc_plugin, name=\"test_priority_render\")\n\n\n# --- Row detail page rendering ---\n\n\n@pytest.mark.asyncio\nasync def test_row_detail_page_html_rendering(ds_ct):\n    \"\"\"Row detail HTML page should use column type rendering.\"\"\"\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts/1\")\n    assert response.status_code == 200\n    html = response.text\n    # The email column should be rendered with mailto: link\n    assert \"mailto:test@example.com\" in html\n    # The url column should be rendered with href\n    assert 'href=\"https://example.com\"' in html\n\n\n# --- HTML table page rendering ---\n\n\n@pytest.mark.asyncio\nasync def test_html_table_page_rendering(ds_ct):\n    \"\"\"HTML table page should use column type rendering.\"\"\"\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts\")\n    assert response.status_code == 200\n    html = response.text\n    assert \"mailto:test@example.com\" in html\n    assert 'href=\"https://example.com\"' in html\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_ui_data_hidden_without_permission(ds_ct):\n    await ds_ct.invoke_startup()\n    response = await ds_ct.client.get(\"/data/posts\")\n    assert response.status_code == 200\n    assert \"window._setColumnTypeData\" not in response.text\n\n\n@pytest.mark.asyncio\nasync def test_set_column_type_ui_data_includes_applicable_types(\n    ds_ct_editor_permission,\n):\n    await ds_ct_editor_permission.invoke_startup()\n    response = await ds_ct_editor_permission.client.get(\n        \"/data/posts\",\n        cookies={\n            \"ds_actor\": ds_ct_editor_permission.client.actor_cookie({\"id\": \"editor\"})\n        },\n    )\n    assert response.status_code == 200\n    data = _window_data_from_html(response.text, \"_setColumnTypeData\")\n    assert data[\"path\"] == \"/data/posts/-/set-column-type\"\n    assert data[\"columns\"][\"id\"] == {\n        \"current\": None,\n        \"options\": [],\n    }\n    assert data[\"columns\"][\"title\"] == {\n        \"current\": None,\n        \"options\": [\n            {\"name\": \"email\", \"description\": \"Email address\"},\n            {\"name\": \"json\", \"description\": \"JSON data\"},\n            {\"name\": \"url\", \"description\": \"URL\"},\n        ],\n    }\n    assert data[\"columns\"][\"author_email\"] == {\n        \"current\": {\"type\": \"email\", \"config\": None},\n        \"options\": [\n            {\"name\": \"email\", \"description\": \"Email address\"},\n            {\"name\": \"json\", \"description\": \"JSON data\"},\n            {\"name\": \"url\", \"description\": \"URL\"},\n        ],\n    }\n\n\n# --- Validation on upsert ---\n\n\n@pytest.mark.asyncio\nasync def test_validation_on_upsert(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/upsert\",\n        json={\n            \"rows\": [{\"id\": 1, \"title\": \"Updated\", \"author_email\": \"invalid\"}],\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 400\n    assert \"author_email\" in response.json()[\"errors\"][0]\n\n\n@pytest.mark.asyncio\nasync def test_validation_on_upsert_passes_valid(ds_ct):\n    await ds_ct.invoke_startup()\n    token = write_token(ds_ct)\n    response = await ds_ct.client.post(\n        \"/data/posts/-/upsert\",\n        json={\n            \"rows\": [{\"id\": 1, \"title\": \"Updated\", \"author_email\": \"valid@test.com\"}],\n        },\n        headers=_headers(token),\n    )\n    assert response.status_code == 200\n\n\n# --- Unknown type warning logged ---\n\n\n@pytest.mark.asyncio\nasync def test_unknown_type_warning_logged(tmp_path_factory, caplog):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table t (id integer primary key, col text)\")\n    db.commit()\n    ds = Datasette(\n        [db_path],\n        config={\n            \"databases\": {\n                \"data\": {\"tables\": {\"t\": {\"column_types\": {\"col\": \"nonexistent_type\"}}}}\n            }\n        },\n    )\n    with caplog.at_level(logging.WARNING):\n        await ds.invoke_startup()\n    assert \"unknown type\" in caplog.text.lower()\n    assert \"nonexistent_type\" in caplog.text\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\n@pytest.mark.asyncio\nasync def test_incompatible_sqlite_type_warning_logged(tmp_path_factory, caplog):\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table t (id integer primary key, col integer)\")\n    db.commit()\n    ds = Datasette(\n        [db_path],\n        config={\n            \"databases\": {\"data\": {\"tables\": {\"t\": {\"column_types\": {\"col\": \"json\"}}}}}\n        },\n    )\n    with caplog.at_level(logging.WARNING):\n        await ds.invoke_startup()\n    assert \"only applicable to sqlite types text\" in caplog.text.lower()\n    assert await ds.get_column_type(\"data\", \"t\", \"col\") is None\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\n# --- Config overwrites on restart ---\n\n\n@pytest.mark.asyncio\nasync def test_config_overwrites_on_restart(tmp_path_factory):\n    \"\"\"Config values should overwrite any existing column types in internal DB on startup.\"\"\"\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table t (id integer primary key, col text)\")\n    db.commit()\n    ds = Datasette(\n        [db_path],\n        config={\n            \"databases\": {\"data\": {\"tables\": {\"t\": {\"column_types\": {\"col\": \"email\"}}}}}\n        },\n    )\n    await ds.invoke_startup()\n    ct = await ds.get_column_type(\"data\", \"t\", \"col\")\n    assert ct.name == \"email\"\n\n    # Manually change the column type in the internal DB\n    await ds.set_column_type(\"data\", \"t\", \"col\", \"url\")\n    ct = await ds.get_column_type(\"data\", \"t\", \"col\")\n    assert ct.name == \"url\"\n\n    # Re-apply config (simulating what happens on restart)\n    await ds._apply_column_types_config()\n    ct = await ds.get_column_type(\"data\", \"t\", \"col\")\n    assert ct.name == \"email\"  # Config wins\n\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n\n\n# --- No column_types in config ---\n\n\n@pytest.mark.asyncio\nasync def test_no_column_types_in_config(tmp_path_factory):\n    \"\"\"Datasette should work fine without any column_types configuration.\"\"\"\n    db_directory = tmp_path_factory.mktemp(\"dbs\")\n    db_path = str(db_directory / \"data.db\")\n    db = sqlite3.connect(str(db_path))\n    db.execute(\"vacuum\")\n    db.execute(\"create table t (id integer primary key, col text)\")\n    db.execute(\"insert into t values (1, 'hello')\")\n    db.commit()\n    ds = Datasette([db_path])\n    await ds.invoke_startup()\n\n    # No column types assigned\n    ct_map = await ds.get_column_types(\"data\", \"t\")\n    assert ct_map == {}\n\n    # JSON endpoint should work without column_types extra\n    response = await ds.client.get(\"/data/t.json\")\n    assert response.status_code == 200\n    assert response.json()[\"rows\"][0][\"col\"] == \"hello\"\n\n    # column_types extra should return empty\n    response = await ds.client.get(\"/data/t.json?_extra=column_types\")\n    assert response.status_code == 200\n    assert response.json()[\"column_types\"] == {}\n\n    db.close()\n    for database in ds.databases.values():\n        if not database.is_memory:\n            database.close()\n"
  },
  {
    "path": "tests/test_config_dir.py",
    "content": "import json\nimport pathlib\nimport pytest\n\nfrom datasette.app import Datasette\nfrom datasette.utils.sqlite import sqlite3\nfrom datasette.utils import StartupError\nfrom .fixtures import TestClient as _TestClient\n\nPLUGIN = \"\"\"\nfrom datasette import hookimpl\n\n@hookimpl\ndef extra_template_vars():\n    return {\n        \"from_plugin\": \"hooray\"\n    }\n\"\"\"\nMETADATA = {\"title\": \"This is from metadata\"}\nCONFIG = {\n    \"settings\": {\n        \"default_cache_ttl\": 60,\n    }\n}\nCSS = \"\"\"\nbody { margin-top: 3em}\n\"\"\"\n\n\n@pytest.fixture(scope=\"session\")\ndef config_dir(tmp_path_factory):\n    config_dir = tmp_path_factory.mktemp(\"config-dir\")\n    plugins_dir = config_dir / \"plugins\"\n    plugins_dir.mkdir()\n    (plugins_dir / \"hooray.py\").write_text(PLUGIN, \"utf-8\")\n    (plugins_dir / \"non_py_file.txt\").write_text(PLUGIN, \"utf-8\")\n    (plugins_dir / \".mypy_cache\").mkdir()\n\n    templates_dir = config_dir / \"templates\"\n    templates_dir.mkdir()\n    (templates_dir / \"row.html\").write_text(\n        \"Show row here. Plugin says {{ from_plugin }}\", \"utf-8\"\n    )\n\n    static_dir = config_dir / \"static\"\n    static_dir.mkdir()\n    (static_dir / \"hello.css\").write_text(CSS, \"utf-8\")\n\n    (config_dir / \"metadata.json\").write_text(json.dumps(METADATA), \"utf-8\")\n    (config_dir / \"datasette.json\").write_text(json.dumps(CONFIG), \"utf-8\")\n\n    for dbname in (\"demo.db\", \"immutable.db\", \"j.sqlite3\", \"k.sqlite\"):\n        db = sqlite3.connect(str(config_dir / dbname))\n        db.executescript(\"\"\"\n        CREATE TABLE cities (\n            id integer primary key,\n            name text\n        );\n        INSERT INTO cities (id, name) VALUES\n            (1, 'San Francisco')\n        ;\n        \"\"\")\n\n    # Mark \"immutable.db\" as immutable\n    (config_dir / \"inspect-data.json\").write_text(\n        json.dumps(\n            {\n                \"immutable\": {\n                    \"hash\": \"hash\",\n                    \"size\": 8192,\n                    \"file\": \"immutable.db\",\n                    \"tables\": {\"cities\": {\"count\": 1}},\n                }\n            }\n        ),\n        \"utf-8\",\n    )\n    return config_dir\n\n\ndef test_invalid_settings(config_dir):\n    previous = (config_dir / \"datasette.json\").read_text(\"utf-8\")\n    (config_dir / \"datasette.json\").write_text(\n        json.dumps({\"settings\": {\"invalid\": \"invalid-setting\"}}), \"utf-8\"\n    )\n    try:\n        with pytest.raises(StartupError) as ex:\n            Datasette([], config_dir=config_dir)\n        assert ex.value.args[0] == \"Invalid setting 'invalid' in config file\"\n    finally:\n        (config_dir / \"datasette.json\").write_text(previous, \"utf-8\")\n\n\n@pytest.fixture(scope=\"session\")\ndef config_dir_client(config_dir):\n    ds = Datasette([], config_dir=config_dir)\n    yield _TestClient(ds)\n\n\ndef test_settings(config_dir_client):\n    response = config_dir_client.get(\"/-/settings.json\")\n    assert 200 == response.status\n    assert 60 == response.json[\"default_cache_ttl\"]\n\n\ndef test_plugins(config_dir_client):\n    response = config_dir_client.get(\"/-/plugins.json\")\n    assert 200 == response.status\n    assert \"hooray.py\" in {p[\"name\"] for p in response.json}\n    assert \"non_py_file.txt\" not in {p[\"name\"] for p in response.json}\n    assert \"mypy_cache\" not in {p[\"name\"] for p in response.json}\n\n\ndef test_templates_and_plugin(config_dir_client):\n    response = config_dir_client.get(\"/demo/cities/1\")\n    assert 200 == response.status\n    assert \"Show row here. Plugin says hooray\" == response.text\n\n\ndef test_static(config_dir_client):\n    response = config_dir_client.get(\"/static/hello.css\")\n    assert 200 == response.status\n    assert CSS == response.text\n    assert \"text/css\" == response.headers[\"content-type\"]\n\n\ndef test_static_directory_browsing_not_allowed(config_dir_client):\n    response = config_dir_client.get(\"/static/\")\n    assert 403 == response.status\n    assert \"403: Directory listing is not allowed\" == response.text\n\n\ndef test_databases(config_dir_client):\n    response = config_dir_client.get(\"/-/databases.json\")\n    assert 200 == response.status\n    databases = response.json\n    assert 4 == len(databases)\n    databases.sort(key=lambda d: d[\"name\"])\n    for db, expected_name in zip(databases, (\"demo\", \"immutable\", \"j\", \"k\")):\n        assert expected_name == db[\"name\"]\n        assert db[\"is_mutable\"] == (expected_name != \"immutable\")\n\n\ndef test_store_config_dir(config_dir_client):\n    ds = config_dir_client.ds\n\n    assert hasattr(ds, \"config_dir\")\n    assert ds.config_dir is not None\n    assert isinstance(ds.config_dir, pathlib.Path)\n"
  },
  {
    "path": "tests/test_config_permission_rules.py",
    "content": "import pytest\n\nfrom datasette.app import Datasette\nfrom datasette.database import Database\nfrom datasette.resources import DatabaseResource, TableResource\n\n\nasync def setup_datasette(config=None, databases=None):\n    ds = Datasette(memory=True, config=config)\n    for name in databases or []:\n        ds.add_database(Database(ds, memory_name=f\"{name}_memory\"), name=name)\n    await ds.invoke_startup()\n    await ds.refresh_schemas()\n    return ds\n\n\n@pytest.mark.asyncio\nasync def test_root_permissions_allow():\n    config = {\"permissions\": {\"execute-sql\": {\"id\": \"alice\"}}}\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"alice\"},\n    )\n    assert not await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"bob\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_database_permission():\n    config = {\n        \"databases\": {\n            \"content\": {\n                \"permissions\": {\n                    \"insert-row\": {\"id\": \"alice\"},\n                }\n            }\n        }\n    }\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"insert-row\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"alice\"},\n    )\n    assert not await ds.allowed(\n        action=\"insert-row\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"bob\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_table_permission():\n    config = {\n        \"databases\": {\n            \"content\": {\n                \"tables\": {\"repos\": {\"permissions\": {\"delete-row\": {\"id\": \"alice\"}}}}\n            }\n        }\n    }\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"delete-row\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"alice\"},\n    )\n    assert not await ds.allowed(\n        action=\"delete-row\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"bob\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_view_table_allow_block():\n    config = {\n        \"databases\": {\"content\": {\"tables\": {\"repos\": {\"allow\": {\"id\": \"alice\"}}}}}\n    }\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"alice\"},\n    )\n    assert not await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"bob\"},\n    )\n    assert await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(database=\"content\", table=\"other\"),\n        actor={\"id\": \"bob\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_view_table_allow_false_blocks():\n    config = {\"databases\": {\"content\": {\"tables\": {\"repos\": {\"allow\": False}}}}}\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert not await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(database=\"content\", table=\"repos\"),\n        actor={\"id\": \"alice\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_allow_sql_blocks():\n    config = {\"allow_sql\": {\"id\": \"alice\"}}\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"alice\"},\n    )\n    assert not await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"bob\"},\n    )\n\n    config = {\"databases\": {\"content\": {\"allow_sql\": {\"id\": \"bob\"}}}}\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n\n    assert await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"bob\"},\n    )\n    assert not await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"alice\"},\n    )\n\n    config = {\"allow_sql\": False}\n    ds = await setup_datasette(config=config, databases=[\"content\"])\n    assert not await ds.allowed(\n        action=\"execute-sql\",\n        resource=DatabaseResource(database=\"content\"),\n        actor={\"id\": \"alice\"},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_view_instance_allow_block():\n    config = {\"allow\": {\"id\": \"alice\"}}\n    ds = await setup_datasette(config=config)\n\n    assert await ds.allowed(action=\"view-instance\", actor={\"id\": \"alice\"})\n    assert not await ds.allowed(action=\"view-instance\", actor={\"id\": \"bob\"})\n"
  },
  {
    "path": "tests/test_crossdb.py",
    "content": "from datasette.cli import cli\nfrom click.testing import CliRunner\nimport urllib\nimport sqlite3\n\n\ndef test_crossdb_join(app_client_two_attached_databases_crossdb_enabled):\n    app_client = app_client_two_attached_databases_crossdb_enabled\n    sql = \"\"\"\n    select\n      'extra database' as db,\n      pk,\n      text1,\n      text2\n    from\n      [extra database].searchable\n    union all\n    select\n      'fixtures' as db,\n      pk,\n      text1,\n      text2\n    from\n      fixtures.searchable\n    \"\"\"\n    response = app_client.get(\n        \"/_memory/-/query.json?\"\n        + urllib.parse.urlencode({\"sql\": sql, \"_shape\": \"array\"})\n    )\n    assert response.status == 200\n    assert response.json == [\n        {\"db\": \"extra database\", \"pk\": 1, \"text1\": \"barry cat\", \"text2\": \"terry dog\"},\n        {\"db\": \"extra database\", \"pk\": 2, \"text1\": \"terry dog\", \"text2\": \"sara weasel\"},\n        {\"db\": \"fixtures\", \"pk\": 1, \"text1\": \"barry cat\", \"text2\": \"terry dog\"},\n        {\"db\": \"fixtures\", \"pk\": 2, \"text1\": \"terry dog\", \"text2\": \"sara weasel\"},\n    ]\n\n\ndef test_crossdb_warning_if_too_many_databases(tmp_path_factory):\n    db_dir = tmp_path_factory.mktemp(\"dbs\")\n    dbs = []\n    for i in range(11):\n        path = str(db_dir / \"db_{}.db\".format(i))\n        conn = sqlite3.connect(path)\n        conn.execute(\"vacuum\")\n        dbs.append(path)\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--crossdb\",\n            \"--get\",\n            \"/\",\n        ]\n        + dbs,\n        catch_exceptions=False,\n    )\n    assert (\n        \"Warning: --crossdb only works with the first 10 attached databases\"\n        in result.stderr\n    )\n\n\ndef test_crossdb_attached_database_list_display(\n    app_client_two_attached_databases_crossdb_enabled,\n):\n    app_client = app_client_two_attached_databases_crossdb_enabled\n    response = app_client.get(\"/_memory\")\n    app_client.get(\"/\")\n    for fragment in (\n        \"databases are attached to this connection\",\n        \"<li><strong>fixtures</strong> - \",\n        '<li><strong>extra database</strong> - <a href=\"/extra+database/-/query?sql=',\n    ):\n        assert fragment in response.text\n"
  },
  {
    "path": "tests/test_csv.py",
    "content": "from datasette.app import Datasette\nfrom bs4 import BeautifulSoup as Soup\nimport pytest\nimport urllib.parse\n\nEXPECTED_TABLE_CSV = \"\"\"id,content\n1,hello\n2,world\n3,\n4,RENDER_CELL_DEMO\n5,RENDER_CELL_ASYNC\n\"\"\".replace(\"\\n\", \"\\r\\n\")\n\nEXPECTED_CUSTOM_CSV = \"\"\"content\nhello\nworld\n\"\"\".replace(\"\\n\", \"\\r\\n\")\n\nEXPECTED_TABLE_WITH_LABELS_CSV = \"\"\"\npk,created,planet_int,on_earth,state,_city_id,_city_id_label,_neighborhood,tags,complex_array,distinct_some_null,n\n1,2019-01-14 08:00:00,1,1,CA,1,San Francisco,Mission,\"[\"\"tag1\"\", \"\"tag2\"\"]\",\"[{\"\"foo\"\": \"\"bar\"\"}]\",one,n1\n2,2019-01-14 08:00:00,1,1,CA,1,San Francisco,Dogpatch,\"[\"\"tag1\"\", \"\"tag3\"\"]\",[],two,n2\n3,2019-01-14 08:00:00,1,1,CA,1,San Francisco,SOMA,[],[],,\n4,2019-01-14 08:00:00,1,1,CA,1,San Francisco,Tenderloin,[],[],,\n5,2019-01-15 08:00:00,1,1,CA,1,San Francisco,Bernal Heights,[],[],,\n6,2019-01-15 08:00:00,1,1,CA,1,San Francisco,Hayes Valley,[],[],,\n7,2019-01-15 08:00:00,1,1,CA,2,Los Angeles,Hollywood,[],[],,\n8,2019-01-15 08:00:00,1,1,CA,2,Los Angeles,Downtown,[],[],,\n9,2019-01-16 08:00:00,1,1,CA,2,Los Angeles,Los Feliz,[],[],,\n10,2019-01-16 08:00:00,1,1,CA,2,Los Angeles,Koreatown,[],[],,\n11,2019-01-16 08:00:00,1,1,MI,3,Detroit,Downtown,[],[],,\n12,2019-01-17 08:00:00,1,1,MI,3,Detroit,Greektown,[],[],,\n13,2019-01-17 08:00:00,1,1,MI,3,Detroit,Corktown,[],[],,\n14,2019-01-17 08:00:00,1,1,MI,3,Detroit,Mexicantown,[],[],,\n15,2019-01-17 08:00:00,2,0,MC,4,Memnonia,Arcadia Planitia,[],[],,\n\"\"\".lstrip().replace(\"\\n\", \"\\r\\n\")\n\nEXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV = \"\"\"\npk,foreign_key_with_label,foreign_key_with_label_label,foreign_key_with_blank_label,foreign_key_with_blank_label_label,foreign_key_with_no_label,foreign_key_with_no_label_label,foreign_key_compound_pk1,foreign_key_compound_pk2\n1,1,hello,3,,1,1,a,b\n2,,,,,,,,\n\"\"\".lstrip().replace(\"\\n\", \"\\r\\n\")\n\n\n@pytest.mark.asyncio\nasync def test_table_csv(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.csv?_oh=1\")\n    assert response.status_code == 200\n    assert not response.headers.get(\"Access-Control-Allow-Origin\")\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == EXPECTED_TABLE_CSV\n\n\ndef test_table_csv_cors_headers(app_client_with_cors):\n    response = app_client_with_cors.get(\"/fixtures/simple_primary_key.csv\")\n    assert response.status == 200\n    assert response.headers[\"Access-Control-Allow-Origin\"] == \"*\"\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_no_header(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.csv?_header=off\")\n    assert response.status_code == 200\n    assert not response.headers.get(\"Access-Control-Allow-Origin\")\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == EXPECTED_TABLE_CSV.split(\"\\r\\n\", 1)[1]\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_with_labels(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.csv?_labels=1\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == EXPECTED_TABLE_WITH_LABELS_CSV\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_with_nullable_labels(ds_client):\n    response = await ds_client.get(\"/fixtures/foreign_key_references.csv?_labels=1\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == EXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_with_invalid_labels():\n    # https://github.com/simonw/datasette/issues/2214\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"db_2214\": {\n                    \"tables\": {\n                        \"t2\": {\n                            \"label_column\": \"name\",\n                        }\n                    }\n                }\n            }\n        }\n    )\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"db_2214\")\n    await db.execute_write_script(\"\"\"\n        create table t1 (id integer primary key, name text);\n        insert into t1 (id, name) values (1, 'one');\n        insert into t1 (id, name) values (2, 'two');\n        create table t2 (textid text primary key, name text);\n        insert into t2 (textid, name) values ('a', 'alpha');\n        insert into t2 (textid, name) values ('b', 'beta');\n        create table if not exists maintable (\n            id integer primary key,\n            fk_integer integer references t1(id),\n            fk_text text references t2(textid)\n        );\n        insert into maintable (id, fk_integer, fk_text) values (1, 1, 'a');\n        insert into maintable (id, fk_integer, fk_text) values (2, 3, 'b'); -- invalid fk_integer\n        insert into maintable (id, fk_integer, fk_text) values (3, 2, 'c'); -- invalid fk_text\n    \"\"\")\n    response = await ds.client.get(\"/db_2214/maintable.csv?_labels=1\")\n    assert response.status_code == 200\n    assert response.text == (\n        \"id,fk_integer,fk_integer_label,fk_text,fk_text_label\\r\\n\"\n        \"1,1,one,a,alpha\\r\\n\"\n        \"2,3,,b,beta\\r\\n\"\n        \"3,2,two,c,\\r\\n\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_blob_columns(ds_client):\n    response = await ds_client.get(\"/fixtures/binary_data.csv\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == (\n        \"rowid,data\\r\\n\"\n        \"1,http://localhost/fixtures/binary_data/1.blob?_blob_column=data\\r\\n\"\n        \"2,http://localhost/fixtures/binary_data/2.blob?_blob_column=data\\r\\n\"\n        \"3,\\r\\n\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_custom_sql_csv_blob_columns(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.csv?sql=select+rowid,+data+from+binary_data\"\n    )\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == (\n        \"rowid,data\\r\\n\"\n        '1,\"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d\"\\r\\n'\n        '2,\"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724\"\\r\\n'\n        \"3,\\r\\n\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_custom_sql_csv(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.csv?sql=select+content+from+simple_primary_key+limit+2\"\n    )\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == EXPECTED_CUSTOM_CSV\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_download(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.csv?_dl=1\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/csv; charset=utf-8\"\n    assert (\n        response.headers[\"content-disposition\"]\n        == 'attachment; filename=\"simple_primary_key.csv\"'\n    )\n\n\n@pytest.mark.asyncio\nasync def test_csv_with_non_ascii_characters(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.csv?sql=select%0D%0A++%27%F0%9D%90%9C%F0%9D%90%A2%F0%9D%90%AD%F0%9D%90%A2%F0%9D%90%9E%F0%9D%90%AC%27+as+text%2C%0D%0A++1+as+number%0D%0Aunion%0D%0Aselect%0D%0A++%27bob%27+as+text%2C%0D%0A++2+as+number%0D%0Aorder+by%0D%0A++number\"\n    )\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"] == \"text/plain; charset=utf-8\"\n    assert response.text == \"text,number\\r\\n𝐜𝐢𝐭𝐢𝐞𝐬,1\\r\\nbob,2\\r\\n\"\n\n\n@pytest.mark.xfail(reason=\"Flaky, see https://github.com/simonw/datasette/issues/2355\")\ndef test_max_csv_mb(app_client_csv_max_mb_one):\n    # This query deliberately generates a really long string\n    # should be 100*100*100*2 = roughly 2MB\n    response = app_client_csv_max_mb_one.get(\n        \"/fixtures.csv?\"\n        + urllib.parse.urlencode(\n            {\n                \"sql\": \"\"\"\n            select group_concat('ab', '')\n            from json_each(json_array({lots})),\n                json_each(json_array({lots})),\n                json_each(json_array({lots}))\n            \"\"\".format(\n                    lots=\", \".join(str(i) for i in range(100))\n                ),\n                \"_stream\": 1,\n                \"_size\": \"max\",\n            }\n        ),\n    )\n    # It's a 200 because we started streaming before we knew the error\n    assert response.status == 200\n    # Last line should be an error message\n    last_line = [line for line in response.body.split(b\"\\r\\n\") if line][-1]\n    assert last_line.startswith(b\"CSV contains more than\")\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_stream(ds_client):\n    # Without _stream should return header + 100 rows:\n    response = await ds_client.get(\n        \"/fixtures/compound_three_primary_keys.csv?_size=max\"\n    )\n    assert len([b for b in response.content.split(b\"\\r\\n\") if b]) == 101\n    # With _stream=1 should return header + 1001 rows\n    response = await ds_client.get(\n        \"/fixtures/compound_three_primary_keys.csv?_stream=1\"\n    )\n    assert len([b for b in response.content.split(b\"\\r\\n\") if b]) == 1002\n\n\ndef test_csv_trace(app_client_with_trace):\n    response = app_client_with_trace.get(\"/fixtures/simple_primary_key.csv?_trace=1\")\n    assert response.headers[\"content-type\"] == \"text/html; charset=utf-8\"\n    soup = Soup(response.text, \"html.parser\")\n    assert (\n        soup.find(\"textarea\").text\n        == \"id,content\\r\\n1,hello\\r\\n2,world\\r\\n3,\\r\\n4,RENDER_CELL_DEMO\\r\\n5,RENDER_CELL_ASYNC\\r\\n\"\n    )\n    assert \"select id, content from simple_primary_key\" in soup.find(\"pre\").text\n\n\ndef test_table_csv_stream_does_not_calculate_facets(app_client_with_trace):\n    response = app_client_with_trace.get(\"/fixtures/simple_primary_key.csv?_trace=1\")\n    soup = Soup(response.text, \"html.parser\")\n    assert \"select content, count(*) as n\" not in soup.find(\"pre\").text\n\n\ndef test_table_csv_stream_does_not_calculate_counts(app_client_with_trace):\n    response = app_client_with_trace.get(\"/fixtures/simple_primary_key.csv?_trace=1\")\n    soup = Soup(response.text, \"html.parser\")\n    assert \"select count(*)\" not in soup.find(\"pre\").text\n"
  },
  {
    "path": "tests/test_custom_pages.py",
    "content": "import pathlib\nimport pytest\nfrom .fixtures import make_app_client\n\nTEST_TEMPLATE_DIRS = str(pathlib.Path(__file__).parent / \"test_templates\")\n\n\n@pytest.fixture(scope=\"session\")\ndef custom_pages_client():\n    with make_app_client(template_dir=TEST_TEMPLATE_DIRS) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef custom_pages_client_with_base_url():\n    with make_app_client(\n        template_dir=TEST_TEMPLATE_DIRS, settings={\"base_url\": \"/prefix/\"}\n    ) as client:\n        yield client\n\n\ndef test_custom_pages_view_name(custom_pages_client):\n    response = custom_pages_client.get(\"/about\")\n    assert response.status == 200\n    assert response.text == \"ABOUT! view_name:page\"\n\n\ndef test_request_is_available(custom_pages_client):\n    response = custom_pages_client.get(\"/request\")\n    assert response.status == 200\n    assert response.text == \"path:/request\"\n\n\ndef test_custom_pages_with_base_url(custom_pages_client_with_base_url):\n    response = custom_pages_client_with_base_url.get(\"/prefix/request\")\n    assert response.status == 200\n    assert response.text == \"path:/prefix/request\"\n\n\ndef test_custom_pages_nested(custom_pages_client):\n    response = custom_pages_client.get(\"/nested/nest\")\n    assert response.status == 200\n    assert response.text == \"Nest!\"\n    response = custom_pages_client.get(\"/nested/nest2\")\n    assert response.status == 404\n\n\ndef test_custom_status(custom_pages_client):\n    response = custom_pages_client.get(\"/202\")\n    assert response.status == 202\n    assert response.text == \"202!\"\n\n\ndef test_custom_headers(custom_pages_client):\n    response = custom_pages_client.get(\"/headers\")\n    assert response.status == 200\n    assert response.headers[\"x-this-is-foo\"] == \"foo\"\n    assert response.headers[\"x-this-is-bar\"] == \"bar\"\n    assert response.text == \"FOOBAR\"\n\n\ndef test_custom_content_type(custom_pages_client):\n    response = custom_pages_client.get(\"/atom\")\n    assert response.status == 200\n    assert response.headers[\"content-type\"] == \"application/xml\"\n    assert response.text == \"<?xml ...>\"\n\n\ndef test_redirect(custom_pages_client):\n    response = custom_pages_client.get(\"/redirect\")\n    assert response.status == 302\n    assert response.headers[\"Location\"] == \"/example\"\n\n\ndef test_redirect2(custom_pages_client):\n    response = custom_pages_client.get(\"/redirect2\")\n    assert response.status == 301\n    assert response.headers[\"Location\"] == \"/example\"\n\n\n@pytest.mark.parametrize(\n    \"path,expected\",\n    [\n        (\"/route_Sally\", \"<p>Hello from Sally</p>\"),\n        (\"/topic_python\", \"Topic page for python\"),\n        (\"/topic_python/info\", \"Slug: info, Topic: python\"),\n    ],\n)\ndef test_custom_route_pattern(custom_pages_client, path, expected):\n    response = custom_pages_client.get(path)\n    assert response.status == 200\n    assert response.text.strip() == expected\n\n\ndef test_custom_route_pattern_404(custom_pages_client):\n    response = custom_pages_client.get(\"/route_OhNo\")\n    assert response.status == 404\n    assert \"<h1>Error 404</h1>\" in response.text\n    assert \">Oh no</\" in response.text\n\n\ndef test_custom_route_pattern_with_slash_slash_302(custom_pages_client):\n    # https://github.com/simonw/datasette/issues/2429\n    response = custom_pages_client.get(\"//example.com/\")\n    assert response.status == 302\n    assert response.headers[\"location\"] == \"/example.com\"\n"
  },
  {
    "path": "tests/test_datasette_https_server.sh",
    "content": "#!/bin/bash\n\n# Generate certificates\npython -m trustme\n# This creates server.pem, server.key, client.pem\n\ncleanup () {\n    rm server.pem server.key client.pem\n}\n\n# Start the server in the background\ndatasette --memory \\\n    --ssl-keyfile=server.key \\\n    --ssl-certfile=server.pem \\\n    -p 8152 &\n\n# Store the background process ID in a variable\nserver_pid=$!\n\ntest_url='https://localhost:8152/_memory.json'\n\n# Wait for the server to start\n\n# h/t https://github.com/pouchdb/pouchdb/blob/25db22fb0ff025b8d2c698da30c6c409066baa0c/bin/run-test.sh#L102-L113\nwaiting=0\nuntil $(curl --output /dev/null --silent --insecure --head --fail --max-time 2 $test_url); do\n    if [ $waiting -eq 4 ]; then\n        echo \"$test_url can not be reached, server failed to start\"\n        cleanup\n        exit 1\n    fi\n    let waiting=waiting+1\n    sleep 1\ndone\n\n# Make a test request using curl\ncurl -f --cacert client.pem $test_url\n\n# Save curl's exit code (-f option causes it to return one on HTTP errors)\ncurl_exit_code=$?\n\n# Shut down the server\nkill $server_pid\nwaiting=0\n#         show all pids\n#         |       find just the $server_pid\n#         |       |                  don’t match on the previous grep\n#         |       |                  |            we don’t need the output\n#         |       |                  |            |\nuntil ( ! ps ax | grep $server_pid | grep -v grep > /dev/null ); do\n    if [ $waiting -eq 4 ]; then\n        echo \"$server_pid does still exist, server failed to stop\"\n        cleanup\n        exit 1\n    fi\n    let waiting=waiting+1\n    sleep 1\ndone\n\n# Clean up the certificates\ncleanup\n\necho $curl_exit_code\nexit $curl_exit_code\n"
  },
  {
    "path": "tests/test_default_deny.py",
    "content": "import pytest\nfrom datasette.app import Datasette\nfrom datasette.resources import DatabaseResource, TableResource\n\n\n@pytest.mark.asyncio\nasync def test_default_deny_denies_default_permissions():\n    \"\"\"Test that default_deny=True denies default permissions\"\"\"\n    # Without default_deny, anonymous users can view instance/database/tables\n    ds_normal = Datasette()\n    await ds_normal.invoke_startup()\n\n    # Add a test database\n    db = ds_normal.add_memory_database(\"test_db_normal\")\n    await db.execute_write(\"create table test_table (id integer primary key)\")\n    await ds_normal._refresh_schemas()  # Trigger catalog refresh\n\n    # Test default behavior - anonymous user should be able to view\n    response = await ds_normal.client.get(\"/\")\n    assert response.status_code == 200\n\n    response = await ds_normal.client.get(\"/test_db_normal\")\n    assert response.status_code == 200\n\n    response = await ds_normal.client.get(\"/test_db_normal/test_table\")\n    assert response.status_code == 200\n\n    # With default_deny=True, anonymous users should be denied\n    ds_deny = Datasette(default_deny=True)\n    await ds_deny.invoke_startup()\n\n    # Add the same test database\n    db = ds_deny.add_memory_database(\"test_db_deny\")\n    await db.execute_write(\"create table test_table (id integer primary key)\")\n    await ds_deny._refresh_schemas()  # Trigger catalog refresh\n\n    # Anonymous user should be denied\n    response = await ds_deny.client.get(\"/\")\n    assert response.status_code == 403\n\n    response = await ds_deny.client.get(\"/test_db_deny\")\n    assert response.status_code == 403\n\n    response = await ds_deny.client.get(\"/test_db_deny/test_table\")\n    assert response.status_code == 403\n\n\n@pytest.mark.asyncio\nasync def test_default_deny_with_root_user():\n    \"\"\"Test that root user still has access when default_deny=True\"\"\"\n    ds = Datasette(default_deny=True)\n    ds.root_enabled = True\n    await ds.invoke_startup()\n\n    root_actor = {\"id\": \"root\"}\n\n    # Root user should have all permissions even with default_deny\n    assert await ds.allowed(action=\"view-instance\", actor=root_actor) is True\n    assert (\n        await ds.allowed(\n            action=\"view-database\",\n            actor=root_actor,\n            resource=DatabaseResource(\"test_db\"),\n        )\n        is True\n    )\n    assert (\n        await ds.allowed(\n            action=\"view-table\",\n            actor=root_actor,\n            resource=TableResource(\"test_db\", \"test_table\"),\n        )\n        is True\n    )\n    assert (\n        await ds.allowed(\n            action=\"execute-sql\", actor=root_actor, resource=DatabaseResource(\"test_db\")\n        )\n        is True\n    )\n\n\n@pytest.mark.asyncio\nasync def test_default_deny_with_config_allow():\n    \"\"\"Test that config allow rules still work with default_deny=True\"\"\"\n    ds = Datasette(default_deny=True, config={\"allow\": {\"id\": \"user1\"}})\n    await ds.invoke_startup()\n\n    # Anonymous user should be denied\n    assert await ds.allowed(action=\"view-instance\", actor=None) is False\n\n    # Authenticated user with explicit permission should have access\n    assert await ds.allowed(action=\"view-instance\", actor={\"id\": \"user1\"}) is True\n\n    # Different user should be denied\n    assert await ds.allowed(action=\"view-instance\", actor={\"id\": \"user2\"}) is False\n\n\n@pytest.mark.asyncio\nasync def test_default_deny_basic_permissions():\n    \"\"\"Test that default_deny=True denies basic permissions\"\"\"\n    ds = Datasette(default_deny=True)\n    await ds.invoke_startup()\n\n    # Anonymous user should be denied all default permissions\n    assert await ds.allowed(action=\"view-instance\", actor=None) is False\n    assert (\n        await ds.allowed(\n            action=\"view-database\", actor=None, resource=DatabaseResource(\"test_db\")\n        )\n        is False\n    )\n    assert (\n        await ds.allowed(\n            action=\"view-table\",\n            actor=None,\n            resource=TableResource(\"test_db\", \"test_table\"),\n        )\n        is False\n    )\n    assert (\n        await ds.allowed(\n            action=\"execute-sql\", actor=None, resource=DatabaseResource(\"test_db\")\n        )\n        is False\n    )\n\n    # Authenticated user without explicit permission should also be denied\n    assert await ds.allowed(action=\"view-instance\", actor={\"id\": \"user\"}) is False\n"
  },
  {
    "path": "tests/test_docs.py",
    "content": "\"\"\"\nTests to ensure certain things are documented.\n\"\"\"\n\nfrom datasette import app, utils\nfrom datasette.app import Datasette\nfrom datasette.filters import Filters\nfrom pathlib import Path\nimport pytest\nimport re\n\ndocs_path = Path(__file__).parent.parent / \"docs\"\nlabel_re = re.compile(r\"\\.\\. _([^\\s:]+):\")\n\n\ndef get_headings(content, underline=\"-\"):\n    heading_re = re.compile(r\"(\\w+)(\\([^)]*\\))?\\n\\{}+\\n\".format(underline))\n    return {h[0] for h in heading_re.findall(content)}\n\n\ndef get_labels(filename):\n    content = (docs_path / filename).read_text()\n    return set(label_re.findall(content))\n\n\n@pytest.fixture(scope=\"session\")\ndef settings_headings():\n    return get_headings((docs_path / \"settings.rst\").read_text(), \"~\")\n\n\ndef test_settings_are_documented(settings_headings, subtests):\n    for setting in app.SETTINGS:\n        with subtests.test(setting=setting.name):\n            assert setting.name in settings_headings\n\n\n@pytest.fixture(scope=\"session\")\ndef plugin_hooks_content():\n    return (docs_path / \"plugin_hooks.rst\").read_text()\n\n\ndef test_plugin_hooks_are_documented(plugin_hooks_content, subtests):\n    headings = set()\n    headings.update(get_headings(plugin_hooks_content, \"-\"))\n    headings.update(get_headings(plugin_hooks_content, \"~\"))\n    plugins = [name for name in dir(app.pm.hook) if not name.startswith(\"_\")]\n    for plugin in plugins:\n        with subtests.test(plugin=plugin):\n            assert plugin in headings\n            hook_caller = getattr(app.pm.hook, plugin)\n            arg_names = [a for a in hook_caller.spec.argnames if a != \"__multicall__\"]\n            # Check for plugin_name(arg1, arg2, arg3)\n            expected = f\"{plugin}({', '.join(arg_names)})\"\n            assert (\n                expected in plugin_hooks_content\n            ), f\"Missing from plugin hook documentation: {expected}\"\n\n\n@pytest.fixture(scope=\"session\")\ndef documented_views():\n    view_labels = set()\n    for filename in docs_path.glob(\"*.rst\"):\n        for label in get_labels(filename):\n            first_word = label.split(\"_\")[0]\n            if first_word.endswith(\"View\"):\n                view_labels.add(first_word)\n    # We deliberately don't document these:\n    view_labels.update((\"PatternPortfolioView\", \"AuthTokenView\", \"ApiExplorerView\"))\n    return view_labels\n\n\ndef test_view_classes_are_documented(documented_views, subtests):\n    view_classes = [v for v in dir(app) if v.endswith(\"View\")]\n    for view_class in view_classes:\n        with subtests.test(view_class=view_class):\n            assert view_class in documented_views\n\n\n@pytest.fixture(scope=\"session\")\ndef documented_table_filters():\n    json_api_rst = (docs_path / \"json_api.rst\").read_text()\n    section = json_api_rst.split(\".. _table_arguments:\")[-1]\n    # Lines starting with ``?column__exact= are docs for filters\n    return {\n        line.split(\"__\")[1].split(\"=\")[0]\n        for line in section.split(\"\\n\")\n        if line.startswith(\"``?column__\")\n    }\n\n\ndef test_table_filters_are_documented(documented_table_filters, subtests):\n    for f in Filters._filters:\n        with subtests.test(filter=f.key):\n            assert f.key in documented_table_filters\n\n\n@pytest.fixture(scope=\"session\")\ndef documented_fns():\n    internals_rst = (docs_path / \"internals.rst\").read_text()\n    # Any line that starts .. _internals_utils_X\n    lines = internals_rst.split(\"\\n\")\n    prefix = \".. _internals_utils_\"\n    return {\n        line.split(prefix)[1].split(\":\")[0] for line in lines if line.startswith(prefix)\n    }\n\n\ndef test_functions_marked_with_documented_are_documented(documented_fns, subtests):\n    for fn in utils.functions_marked_as_documented:\n        with subtests.test(fn=fn.__name__):\n            assert fn.__name__ in documented_fns\n\n\ndef test_rst_heading_underlines_match_title_length():\n    \"\"\"Test that RST heading underlines are the same length as their titles.\"\"\"\n    # Common RST underline characters\n    underline_chars = [\"-\", \"=\", \"~\", \"^\", \"+\", \"*\", \"#\"]\n\n    errors = []\n\n    for rst_file in docs_path.glob(\"*.rst\"):\n        content = rst_file.read_text()\n        lines = content.split(\"\\n\")\n\n        for i in range(len(lines) - 1):\n            current_line = lines[i]\n            next_line = lines[i + 1]\n\n            # Check if next line is entirely made of a single underline character\n            # and is at least 5 characters long (to avoid false positives)\n            if (\n                next_line\n                and len(next_line) >= 5\n                and len(set(next_line)) == 1\n                and next_line[0] in underline_chars\n            ):\n                # Skip if the previous line is empty (blank line before underline)\n                if not current_line:\n                    continue\n\n                # Check if this is an overline+underline style heading\n                # Look at the line before current_line to see if it's also an underline\n                if i > 0:\n                    prev_line = lines[i - 1]\n                    if (\n                        prev_line\n                        and len(prev_line) >= 5\n                        and len(set(prev_line)) == 1\n                        and prev_line[0] in underline_chars\n                        and len(prev_line) == len(next_line)\n                    ):\n                        # This is overline+underline style, skip it\n                        continue\n\n                # This is a heading underline\n                title_length = len(current_line)\n                underline_length = len(next_line)\n\n                if title_length != underline_length:\n                    errors.append(\n                        f\"{rst_file.name}:{i+1}: Title length {title_length} != underline length {underline_length}\\n\"\n                        f\"  Title: {current_line!r}\\n\"\n                        f\"  Underline: {next_line!r}\"\n                    )\n\n    if errors:\n        raise AssertionError(\n            f\"Found {len(errors)} RST heading(s) with mismatched underline length:\\n\\n\"\n            + \"\\n\\n\".join(errors)\n        )\n\n\n# Tests for testing_plugins.rst documentation\n\n# fmt: off\n# -- start test_homepage --\n@pytest.mark.asyncio\nasync def test_homepage():\n    ds = Datasette(memory=True)\n    response = await ds.client.get(\"/\")\n    html = response.text\n    assert \"<h1>\" in html\n# -- end test_homepage --\n\n\n# -- start test_actor_is_null --\n@pytest.mark.asyncio\nasync def test_actor_is_null():\n    ds = Datasette(memory=True)\n    response = await ds.client.get(\"/-/actor.json\")\n    assert response.json() == {\"actor\": None}\n# -- end test_actor_is_null --\n\n\n# -- start test_signed_cookie_actor --\n@pytest.mark.asyncio\nasync def test_signed_cookie_actor():\n    ds = Datasette(memory=True)\n    cookies = {\"ds_actor\": ds.client.actor_cookie({\"id\": \"root\"})}\n    response = await ds.client.get(\"/-/actor.json\", cookies=cookies)\n    assert response.json() == {\"actor\": {\"id\": \"root\"}}\n# -- end test_signed_cookie_actor --\n"
  },
  {
    "path": "tests/test_docs_plugins.py",
    "content": "# fmt: off\n# -- start datasette_with_plugin_fixture --\nfrom datasette import hookimpl\nfrom datasette.app import Datasette\nimport pytest\nimport pytest_asyncio\n\n\n@pytest_asyncio.fixture\nasync def datasette_with_plugin():\n    class TestPlugin:\n        __name__ = \"TestPlugin\"\n\n        @hookimpl\n        def register_routes(self):\n            return [\n                (r\"^/error$\", lambda: 1 / 0),\n            ]\n\n    datasette = Datasette()\n    datasette.pm.register(TestPlugin(), name=\"undo\")\n    try:\n        yield datasette\n    finally:\n        datasette.pm.unregister(name=\"undo\")\n# -- end datasette_with_plugin_fixture --\n\n\n# -- start datasette_with_plugin_test --\n@pytest.mark.asyncio\nasync def test_error(datasette_with_plugin):\n    response = await datasette_with_plugin.client.get(\"/error\")\n    assert response.status_code == 500\n# -- end datasette_with_plugin_test --\n"
  },
  {
    "path": "tests/test_facets.py",
    "content": "from datasette.app import Datasette\nfrom datasette.database import Database\nfrom datasette.facets import Facet, ColumnFacet, ArrayFacet, DateFacet\nfrom datasette.utils.asgi import Request\nfrom datasette.utils import detect_json1\nfrom .fixtures import make_app_client\nimport json\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_suggest(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    suggestions = await facet.suggest()\n    assert [\n        {\"name\": \"created\", \"toggle_url\": \"http://localhost/?_facet=created\"},\n        {\"name\": \"planet_int\", \"toggle_url\": \"http://localhost/?_facet=planet_int\"},\n        {\"name\": \"on_earth\", \"toggle_url\": \"http://localhost/?_facet=on_earth\"},\n        {\"name\": \"state\", \"toggle_url\": \"http://localhost/?_facet=state\"},\n        {\"name\": \"_city_id\", \"toggle_url\": \"http://localhost/?_facet=_city_id\"},\n        {\n            \"name\": \"_neighborhood\",\n            \"toggle_url\": \"http://localhost/?_facet=_neighborhood\",\n        },\n        {\"name\": \"tags\", \"toggle_url\": \"http://localhost/?_facet=tags\"},\n        {\n            \"name\": \"complex_array\",\n            \"toggle_url\": \"http://localhost/?_facet=complex_array\",\n        },\n    ] == suggestions\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_suggest_skip_if_already_selected(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/?_facet=planet_int&_facet=on_earth\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    suggestions = await facet.suggest()\n    assert [\n        {\n            \"name\": \"created\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=created\",\n        },\n        {\n            \"name\": \"state\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=state\",\n        },\n        {\n            \"name\": \"_city_id\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=_city_id\",\n        },\n        {\n            \"name\": \"_neighborhood\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=_neighborhood\",\n        },\n        {\n            \"name\": \"tags\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=tags\",\n        },\n        {\n            \"name\": \"complex_array\",\n            \"toggle_url\": \"http://localhost/?_facet=planet_int&_facet=on_earth&_facet=complex_array\",\n        },\n    ] == suggestions\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_suggest_skip_if_enabled_by_metadata(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n        table_config={\"facets\": [\"_city_id\"]},\n    )\n    suggestions = [s[\"name\"] for s in await facet.suggest()]\n    assert [\n        \"created\",\n        \"planet_int\",\n        \"on_earth\",\n        \"state\",\n        \"_neighborhood\",\n        \"tags\",\n        \"complex_array\",\n    ] == suggestions\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_results(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/?_facet=_city_id\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    buckets, timed_out = await facet.facet_results()\n    assert [] == timed_out\n    assert [\n        {\n            \"name\": \"_city_id\",\n            \"type\": \"column\",\n            \"hideable\": True,\n            \"toggle_url\": \"/\",\n            \"results\": [\n                {\n                    \"value\": 1,\n                    \"label\": \"San Francisco\",\n                    \"count\": 6,\n                    \"toggle_url\": \"http://localhost/?_facet=_city_id&_city_id__exact=1\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 2,\n                    \"label\": \"Los Angeles\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_facet=_city_id&_city_id__exact=2\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 3,\n                    \"label\": \"Detroit\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_facet=_city_id&_city_id__exact=3\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 4,\n                    \"label\": \"Memnonia\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_city_id&_city_id__exact=4\",\n                    \"selected\": False,\n                },\n            ],\n            \"truncated\": False,\n        }\n    ] == buckets\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_results_column_starts_with_underscore(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/?_facet=_neighborhood\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    buckets, timed_out = await facet.facet_results()\n    assert [] == timed_out\n    assert buckets == [\n        {\n            \"name\": \"_neighborhood\",\n            \"type\": \"column\",\n            \"hideable\": True,\n            \"toggle_url\": \"/\",\n            \"results\": [\n                {\n                    \"value\": \"Downtown\",\n                    \"label\": \"Downtown\",\n                    \"count\": 2,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Downtown\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Arcadia Planitia\",\n                    \"label\": \"Arcadia Planitia\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Arcadia+Planitia\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Bernal Heights\",\n                    \"label\": \"Bernal Heights\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Bernal+Heights\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Corktown\",\n                    \"label\": \"Corktown\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Corktown\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Dogpatch\",\n                    \"label\": \"Dogpatch\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Dogpatch\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Greektown\",\n                    \"label\": \"Greektown\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Greektown\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Hayes Valley\",\n                    \"label\": \"Hayes Valley\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Hayes+Valley\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Hollywood\",\n                    \"label\": \"Hollywood\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Hollywood\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Koreatown\",\n                    \"label\": \"Koreatown\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Koreatown\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Los Feliz\",\n                    \"label\": \"Los Feliz\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Los+Feliz\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Mexicantown\",\n                    \"label\": \"Mexicantown\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Mexicantown\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Mission\",\n                    \"label\": \"Mission\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Mission\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"SOMA\",\n                    \"label\": \"SOMA\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=SOMA\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"Tenderloin\",\n                    \"label\": \"Tenderloin\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet=_neighborhood&_neighborhood__exact=Tenderloin\",\n                    \"selected\": False,\n                },\n            ],\n            \"truncated\": False,\n        }\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_column_facet_from_metadata_cannot_be_hidden(ds_client):\n    facet = ColumnFacet(\n        ds_client.ds,\n        Request.fake(\"/\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n        table_config={\"facets\": [\"_city_id\"]},\n    )\n    buckets, timed_out = await facet.facet_results()\n    assert [] == timed_out\n    assert [\n        {\n            \"name\": \"_city_id\",\n            \"type\": \"column\",\n            \"hideable\": False,\n            \"toggle_url\": \"/\",\n            \"results\": [\n                {\n                    \"value\": 1,\n                    \"label\": \"San Francisco\",\n                    \"count\": 6,\n                    \"toggle_url\": \"http://localhost/?_city_id__exact=1\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 2,\n                    \"label\": \"Los Angeles\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_city_id__exact=2\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 3,\n                    \"label\": \"Detroit\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_city_id__exact=3\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": 4,\n                    \"label\": \"Memnonia\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_city_id__exact=4\",\n                    \"selected\": False,\n                },\n            ],\n            \"truncated\": False,\n        }\n    ] == buckets\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\nasync def test_array_facet_suggest(ds_client):\n    facet = ArrayFacet(\n        ds_client.ds,\n        Request.fake(\"/\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    suggestions = await facet.suggest()\n    assert [\n        {\n            \"name\": \"tags\",\n            \"type\": \"array\",\n            \"toggle_url\": \"http://localhost/?_facet_array=tags\",\n        }\n    ] == suggestions\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\nasync def test_array_facet_suggest_not_if_all_empty_arrays(ds_client):\n    facet = ArrayFacet(\n        ds_client.ds,\n        Request.fake(\"/\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable where tags = '[]'\",\n        table=\"facetable\",\n    )\n    suggestions = await facet.suggest()\n    assert [] == suggestions\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\nasync def test_array_facet_results(ds_client):\n    facet = ArrayFacet(\n        ds_client.ds,\n        Request.fake(\"/?_facet_array=tags\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    buckets, timed_out = await facet.facet_results()\n    assert [] == timed_out\n    assert [\n        {\n            \"name\": \"tags\",\n            \"type\": \"array\",\n            \"results\": [\n                {\n                    \"value\": \"tag1\",\n                    \"label\": \"tag1\",\n                    \"count\": 2,\n                    \"toggle_url\": \"http://localhost/?_facet_array=tags&tags__arraycontains=tag1\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"tag2\",\n                    \"label\": \"tag2\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet_array=tags&tags__arraycontains=tag2\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"tag3\",\n                    \"label\": \"tag3\",\n                    \"count\": 1,\n                    \"toggle_url\": \"http://localhost/?_facet_array=tags&tags__arraycontains=tag3\",\n                    \"selected\": False,\n                },\n            ],\n            \"hideable\": True,\n            \"toggle_url\": \"/\",\n            \"truncated\": False,\n        }\n    ] == buckets\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\nasync def test_array_facet_handle_duplicate_tags():\n    ds = Datasette([], memory=True)\n    db = ds.add_database(Database(ds, memory_name=\"test_array_facet\"))\n    await db.execute_write(\"create table otters(name text, tags text)\")\n    for name, tags in (\n        (\"Charles\", [\"friendly\", \"cunning\", \"friendly\"]),\n        (\"Shaun\", [\"cunning\", \"empathetic\", \"friendly\"]),\n        (\"Tracy\", [\"empathetic\", \"eager\"]),\n    ):\n        await db.execute_write(\n            \"insert into otters (name, tags) values (?, ?)\", [name, json.dumps(tags)]\n        )\n\n    response = await ds.client.get(\"/test_array_facet/otters.json?_facet_array=tags\")\n    assert response.json()[\"facet_results\"][\"results\"][\"tags\"] == {\n        \"name\": \"tags\",\n        \"type\": \"array\",\n        \"results\": [\n            {\n                \"value\": \"cunning\",\n                \"label\": \"cunning\",\n                \"count\": 2,\n                \"toggle_url\": \"http://localhost/test_array_facet/otters.json?_facet_array=tags&tags__arraycontains=cunning\",\n                \"selected\": False,\n            },\n            {\n                \"value\": \"empathetic\",\n                \"label\": \"empathetic\",\n                \"count\": 2,\n                \"toggle_url\": \"http://localhost/test_array_facet/otters.json?_facet_array=tags&tags__arraycontains=empathetic\",\n                \"selected\": False,\n            },\n            {\n                \"value\": \"friendly\",\n                \"label\": \"friendly\",\n                \"count\": 2,\n                \"toggle_url\": \"http://localhost/test_array_facet/otters.json?_facet_array=tags&tags__arraycontains=friendly\",\n                \"selected\": False,\n            },\n            {\n                \"value\": \"eager\",\n                \"label\": \"eager\",\n                \"count\": 1,\n                \"toggle_url\": \"http://localhost/test_array_facet/otters.json?_facet_array=tags&tags__arraycontains=eager\",\n                \"selected\": False,\n            },\n        ],\n        \"hideable\": True,\n        \"toggle_url\": \"/test_array_facet/otters.json\",\n        \"truncated\": False,\n    }\n\n\n@pytest.mark.asyncio\nasync def test_date_facet_results(ds_client):\n    facet = DateFacet(\n        ds_client.ds,\n        Request.fake(\"/?_facet_date=created\"),\n        database=\"fixtures\",\n        sql=\"select * from facetable\",\n        table=\"facetable\",\n    )\n    buckets, timed_out = await facet.facet_results()\n    assert [] == timed_out\n    assert [\n        {\n            \"name\": \"created\",\n            \"type\": \"date\",\n            \"results\": [\n                {\n                    \"value\": \"2019-01-14\",\n                    \"label\": \"2019-01-14\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_facet_date=created&created__date=2019-01-14\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"2019-01-15\",\n                    \"label\": \"2019-01-15\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_facet_date=created&created__date=2019-01-15\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"2019-01-17\",\n                    \"label\": \"2019-01-17\",\n                    \"count\": 4,\n                    \"toggle_url\": \"http://localhost/?_facet_date=created&created__date=2019-01-17\",\n                    \"selected\": False,\n                },\n                {\n                    \"value\": \"2019-01-16\",\n                    \"label\": \"2019-01-16\",\n                    \"count\": 3,\n                    \"toggle_url\": \"http://localhost/?_facet_date=created&created__date=2019-01-16\",\n                    \"selected\": False,\n                },\n            ],\n            \"hideable\": True,\n            \"toggle_url\": \"/\",\n            \"truncated\": False,\n        }\n    ] == buckets\n\n\n@pytest.mark.asyncio\nasync def test_json_array_with_blanks_and_nulls():\n    ds = Datasette([], memory=True)\n    db = ds.add_database(Database(ds, memory_name=\"test_json_array\"))\n    await db.execute_write(\"create table foo(json_column text)\")\n    for value in ('[\"a\", \"b\", \"c\"]', '[\"a\", \"b\"]', \"\", None):\n        await db.execute_write(\"insert into foo (json_column) values (?)\", [value])\n    response = await ds.client.get(\"/test_json_array/foo.json?_extra=suggested_facets\")\n    data = response.json()\n    assert data[\"suggested_facets\"] == [\n        {\n            \"name\": \"json_column\",\n            \"type\": \"array\",\n            \"toggle_url\": \"http://localhost/test_json_array/foo.json?_extra=suggested_facets&_facet_array=json_column\",\n        }\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_facet_size():\n    ds = Datasette([], memory=True, settings={\"max_returned_rows\": 50})\n    db = ds.add_database(Database(ds, memory_name=\"test_facet_size\"))\n    await db.execute_write(\"create table neighbourhoods(city text, neighbourhood text)\")\n    for i in range(1, 51):\n        for j in range(1, 4):\n            await db.execute_write(\n                \"insert into neighbourhoods (city, neighbourhood) values (?, ?)\",\n                [\"City {}\".format(i), \"Neighbourhood {}\".format(j)],\n            )\n    response = await ds.client.get(\n        \"/test_facet_size/neighbourhoods.json?_extra=suggested_facets\"\n    )\n    data = response.json()\n    assert data[\"suggested_facets\"] == [\n        {\n            \"name\": \"neighbourhood\",\n            \"toggle_url\": \"http://localhost/test_facet_size/neighbourhoods.json?_extra=suggested_facets&_facet=neighbourhood\",\n        }\n    ]\n    # Bump up _facet_size= to suggest city too\n    response2 = await ds.client.get(\n        \"/test_facet_size/neighbourhoods.json?_facet_size=50&_extra=suggested_facets\"\n    )\n    data2 = response2.json()\n    assert sorted(data2[\"suggested_facets\"], key=lambda f: f[\"name\"]) == [\n        {\n            \"name\": \"city\",\n            \"toggle_url\": \"http://localhost/test_facet_size/neighbourhoods.json?_facet_size=50&_extra=suggested_facets&_facet=city\",\n        },\n        {\n            \"name\": \"neighbourhood\",\n            \"toggle_url\": \"http://localhost/test_facet_size/neighbourhoods.json?_facet_size=50&_extra=suggested_facets&_facet=neighbourhood\",\n        },\n    ]\n    # Facet by city should return expected number of results\n    response3 = await ds.client.get(\n        \"/test_facet_size/neighbourhoods.json?_facet_size=50&_facet=city\"\n    )\n    data3 = response3.json()\n    assert len(data3[\"facet_results\"][\"results\"][\"city\"][\"results\"]) == 50\n    # Reduce max_returned_rows and check that it's respected\n    ds._settings[\"max_returned_rows\"] = 20\n    response4 = await ds.client.get(\n        \"/test_facet_size/neighbourhoods.json?_facet_size=50&_facet=city\"\n    )\n    data4 = response4.json()\n    assert len(data4[\"facet_results\"][\"results\"][\"city\"][\"results\"]) == 20\n    # Test _facet_size=max\n    response5 = await ds.client.get(\n        \"/test_facet_size/neighbourhoods.json?_facet_size=max&_facet=city\"\n    )\n    data5 = response5.json()\n    assert len(data5[\"facet_results\"][\"results\"][\"city\"][\"results\"]) == 20\n    # Now try messing with facet_size in the table metadata\n    orig_config = ds.config\n    try:\n        ds.config = {\n            \"databases\": {\n                \"test_facet_size\": {\"tables\": {\"neighbourhoods\": {\"facet_size\": 6}}}\n            }\n        }\n        response6 = await ds.client.get(\n            \"/test_facet_size/neighbourhoods.json?_facet=city\"\n        )\n        data6 = response6.json()\n        assert len(data6[\"facet_results\"][\"results\"][\"city\"][\"results\"]) == 6\n        # Setting it to max bumps it up to 50 again\n        ds.config[\"databases\"][\"test_facet_size\"][\"tables\"][\"neighbourhoods\"][\n            \"facet_size\"\n        ] = \"max\"\n        data7 = (\n            await ds.client.get(\"/test_facet_size/neighbourhoods.json?_facet=city\")\n        ).json()\n        assert len(data7[\"facet_results\"][\"results\"][\"city\"][\"results\"]) == 20\n    finally:\n        ds.config = orig_config\n\n\ndef test_other_types_of_facet_in_metadata():\n    with make_app_client(\n        metadata={\n            \"databases\": {\n                \"fixtures\": {\n                    \"tables\": {\n                        \"facetable\": {\n                            \"facets\": [\"state\", {\"array\": \"tags\"}, {\"date\": \"created\"}]\n                        }\n                    }\n                }\n            }\n        }\n    ) as client:\n        response = client.get(\"/fixtures/facetable\")\n        fragments = (\n            \"<strong>state\\n\",\n            \"<strong>tags (array)\\n\",\n            \"<strong>created (date)\\n\",\n        )\n        for fragment in fragments:\n            assert fragment in response.text\n        # Verify they appear in the metadata-defined order\n        positions = [response.text.index(f) for f in fragments]\n        assert positions == sorted(\n            positions\n        ), \"Facets should appear in metadata-defined order\"\n\n\ndef test_metadata_facet_ordering():\n    with make_app_client(\n        metadata={\n            \"databases\": {\n                \"fixtures\": {\n                    \"tables\": {\n                        \"facetable\": {\n                            \"facets\": [\"state\", {\"array\": \"tags\"}, {\"date\": \"created\"}]\n                        }\n                    }\n                }\n            }\n        }\n    ) as client:\n        # JSON response should have facets in the metadata-defined order\n        response = client.get(\"/fixtures/facetable.json?_extra=sorted_facet_results\")\n        data = response.json\n        facet_names = [f[\"name\"] for f in data[\"sorted_facet_results\"]]\n        assert facet_names == [\"state\", \"tags\", \"created\"]\n\n        # With an additional request-based facet, metadata facets come first\n        # in their defined order, followed by request-based facets\n        response2 = client.get(\n            \"/fixtures/facetable.json?_extra=sorted_facet_results&_facet=_city_id\"\n        )\n        data2 = response2.json\n        facet_names2 = [f[\"name\"] for f in data2[\"sorted_facet_results\"]]\n        assert facet_names2 == [\"state\", \"tags\", \"created\", \"_city_id\"]\n\n\n@pytest.mark.asyncio\nasync def test_conflicting_facet_names_json(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_facet=created&_facet_date=created\"\n        \"&_facet=tags&_facet_array=tags\"\n    )\n    assert set(response.json()[\"facet_results\"][\"results\"].keys()) == {\n        \"created\",\n        \"tags\",\n        \"created_2\",\n        \"tags_2\",\n    }\n\n\n@pytest.mark.asyncio\nasync def test_facet_against_in_memory_database():\n    ds = Datasette()\n    db = ds.add_memory_database(\"mem\")\n    await db.execute_write(\n        \"create table t (id integer primary key, name text, name2 text)\"\n    )\n    to_insert = [{\"name\": \"one\", \"name2\": \"1\"} for _ in range(800)] + [\n        {\"name\": \"two\", \"name2\": \"2\"} for _ in range(300)\n    ]\n    await db.execute_write_many(\n        \"insert into t (name, name2) values (:name, :name2)\", to_insert\n    )\n    response1 = await ds.client.get(\"/mem/t\")\n    assert response1.status_code == 200\n    response2 = await ds.client.get(\"/mem/t?_facet=name&_facet=name2\")\n    assert response2.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_facet_only_considers_first_x_rows():\n    # This test works by manually fiddling with Facet.suggest_consider\n    ds = Datasette()\n    original_suggest_consider = Facet.suggest_consider\n    try:\n        Facet.suggest_consider = 40\n        db = ds.add_memory_database(\"test_facet_only_x_rows\")\n        await db.execute_write(\"create table t (id integer primary key, col text)\")\n        # First 50 rows make it look like col and col_json should be faceted\n        to_insert = [{\"col\": \"one\" if i % 2 else \"two\"} for i in range(50)]\n        await db.execute_write_many(\"insert into t (col) values (:col)\", to_insert)\n        # Next 50 break that assumption\n        to_insert2 = [{\"col\": f\"x{i}\"} for i in range(50)]\n        await db.execute_write_many(\"insert into t (col) values (:col)\", to_insert2)\n        response = await ds.client.get(\n            \"/test_facet_only_x_rows/t.json?_extra=suggested_facets\"\n        )\n        data = response.json()\n        assert data[\"suggested_facets\"] == [\n            {\n                \"name\": \"col\",\n                \"toggle_url\": \"http://localhost/test_facet_only_x_rows/t.json?_extra=suggested_facets&_facet=col\",\n            }\n        ]\n        # But if we set suggest_consider to 100 they are not suggested\n        Facet.suggest_consider = 100\n        response2 = await ds.client.get(\n            \"/test_facet_only_x_rows/t.json?_extra=suggested_facets\"\n        )\n        data2 = response2.json()\n        assert data2[\"suggested_facets\"] == []\n    finally:\n        Facet.suggest_consider = original_suggest_consider\n"
  },
  {
    "path": "tests/test_filters.py",
    "content": "from datasette.filters import Filters, through_filters, where_filters, search_filters\nfrom datasette.utils.asgi import Request\nimport pytest\n\n\n@pytest.mark.parametrize(\n    \"args,expected_where,expected_params\",\n    [\n        (((\"name_english__contains\", \"foo\"),), ['\"name_english\" like :p0'], [\"%foo%\"]),\n        (\n            ((\"name_english__notcontains\", \"foo\"),),\n            ['\"name_english\" not like :p0'],\n            [\"%foo%\"],\n        ),\n        (\n            ((\"foo\", \"bar\"), (\"bar__contains\", \"baz\")),\n            ['\"bar\" like :p0', '\"foo\" = :p1'],\n            [\"%baz%\", \"bar\"],\n        ),\n        (\n            ((\"foo__startswith\", \"bar\"), (\"bar__endswith\", \"baz\")),\n            ['\"bar\" like :p0', '\"foo\" like :p1'],\n            [\"%baz\", \"bar%\"],\n        ),\n        (\n            ((\"foo__lt\", \"1\"), (\"bar__gt\", \"2\"), (\"baz__gte\", \"3\"), (\"bax__lte\", \"4\")),\n            ['\"bar\" > :p0', '\"bax\" <= :p1', '\"baz\" >= :p2', '\"foo\" < :p3'],\n            [2, 4, 3, 1],\n        ),\n        (\n            ((\"foo__like\", \"2%2\"), (\"zax__glob\", \"3*\")),\n            ['\"foo\" like :p0', '\"zax\" glob :p1'],\n            [\"2%2\", \"3*\"],\n        ),\n        # Multiple like arguments:\n        (\n            ((\"foo__like\", \"2%2\"), (\"foo__like\", \"3%3\")),\n            ['\"foo\" like :p0', '\"foo\" like :p1'],\n            [\"2%2\", \"3%3\"],\n        ),\n        # notlike:\n        (\n            ((\"foo__notlike\", \"2%2\"),),\n            ['\"foo\" not like :p0'],\n            [\"2%2\"],\n        ),\n        (\n            ((\"foo__isnull\", \"1\"), (\"baz__isnull\", \"1\"), (\"bar__gt\", \"10\")),\n            ['\"bar\" > :p0', '\"baz\" is null', '\"foo\" is null'],\n            [10],\n        ),\n        (((\"foo__in\", \"1,2,3\"),), [\"foo in (:p0, :p1, :p2)\"], [\"1\", \"2\", \"3\"]),\n        # date\n        (((\"foo__date\", \"1988-01-01\"),), ['date(\"foo\") = :p0'], [\"1988-01-01\"]),\n        # JSON array variants of __in (useful for unexpected characters)\n        (((\"foo__in\", \"[1,2,3]\"),), [\"foo in (:p0, :p1, :p2)\"], [1, 2, 3]),\n        (\n            ((\"foo__in\", '[\"dog,cat\", \"cat[dog]\"]'),),\n            [\"foo in (:p0, :p1)\"],\n            [\"dog,cat\", \"cat[dog]\"],\n        ),\n        # Not in, and JSON array not in\n        (((\"foo__notin\", \"1,2,3\"),), [\"foo not in (:p0, :p1, :p2)\"], [\"1\", \"2\", \"3\"]),\n        (((\"foo__notin\", \"[1,2,3]\"),), [\"foo not in (:p0, :p1, :p2)\"], [1, 2, 3]),\n        # JSON arraycontains, arraynotcontains\n        (\n            ((\"Availability+Info__arraycontains\", \"yes\"),),\n            [\":p0 in (select value from json_each([table].[Availability+Info]))\"],\n            [\"yes\"],\n        ),\n        (\n            ((\"Availability+Info__arraynotcontains\", \"yes\"),),\n            [\":p0 not in (select value from json_each([table].[Availability+Info]))\"],\n            [\"yes\"],\n        ),\n    ],\n)\ndef test_build_where(args, expected_where, expected_params):\n    f = Filters(sorted(args))\n    sql_bits, actual_params = f.build_where_clauses(\"table\")\n    assert expected_where == sql_bits\n    assert {f\"p{i}\": param for i, param in enumerate(expected_params)} == actual_params\n\n\n@pytest.mark.asyncio\nasync def test_through_filters_from_request(ds_client):\n    request = Request.fake(\n        '/?_through={\"table\":\"roadside_attraction_characteristics\",\"column\":\"characteristic_id\",\"value\":\"1\"}'\n    )\n    filter_args = await through_filters(\n        request=request,\n        datasette=ds_client.ds,\n        table=\"roadside_attractions\",\n        database=\"fixtures\",\n    )()\n    assert filter_args.where_clauses == [\n        \"pk in (select attraction_id from roadside_attraction_characteristics where characteristic_id = :p0)\"\n    ]\n    assert filter_args.params == {\"p0\": \"1\"}\n    assert filter_args.human_descriptions == [\n        'roadside_attraction_characteristics.characteristic_id = \"1\"'\n    ]\n    assert filter_args.extra_context == {}\n\n\n@pytest.mark.asyncio\nasync def test_where_filters_from_request(ds_client):\n    await ds_client.ds.invoke_startup()\n    request = Request.fake(\"/?_where=pk+>+3\")\n    filter_args = await where_filters(\n        request=request,\n        datasette=ds_client.ds,\n        database=\"fixtures\",\n    )()\n    assert filter_args.where_clauses == [\"pk > 3\"]\n    assert filter_args.params == {}\n    assert filter_args.human_descriptions == []\n    assert filter_args.extra_context == {\n        \"extra_wheres_for_ui\": [{\"text\": \"pk > 3\", \"remove_url\": \"/\"}]\n    }\n\n\n@pytest.mark.asyncio\nasync def test_search_filters_from_request(ds_client):\n    request = Request.fake(\"/?_search=bobcat\")\n    filter_args = await search_filters(\n        request=request,\n        datasette=ds_client.ds,\n        database=\"fixtures\",\n        table=\"searchable\",\n    )()\n    assert filter_args.where_clauses == [\n        \"rowid in (select rowid from searchable_fts where searchable_fts match escape_fts(:search))\"\n    ]\n    assert filter_args.params == {\"search\": \"bobcat\"}\n    assert filter_args.human_descriptions == ['search matches \"bobcat\"']\n    assert filter_args.extra_context == {\"supports_search\": True, \"search\": \"bobcat\"}\n"
  },
  {
    "path": "tests/test_html.py",
    "content": "from bs4 import BeautifulSoup as Soup\nfrom datasette.app import Datasette\nfrom datasette.utils import allowed_pragmas\nfrom .fixtures import make_app_client\nfrom .utils import assert_footer_links, inner_html\nimport copy\nimport json\nimport pathlib\nimport pytest\nimport re\nimport urllib.parse\n\n\ndef test_homepage(app_client_two_attached_databases):\n    response = app_client_two_attached_databases.get(\"/\")\n    assert response.status_code == 200\n    assert \"text/html; charset=utf-8\" == response.headers[\"content-type\"]\n    # Should have a html lang=\"en\" attribute\n    assert '<html lang=\"en\">' in response.text\n    soup = Soup(response.content, \"html.parser\")\n    assert \"Datasette Fixtures\" == soup.find(\"h1\").text\n    assert (\n        \"An example SQLite database demonstrating Datasette. Sign in as root user\"\n        == soup.select(\".metadata-description\")[0].text.strip()\n    )\n    # Should be two attached databases\n    assert [\n        {\"href\": \"/extra+database\", \"text\": \"extra database\"},\n        {\"href\": \"/fixtures\", \"text\": \"fixtures\"},\n    ] == [{\"href\": a[\"href\"], \"text\": a.text.strip()} for a in soup.select(\"h2 a\")]\n    # Database should show count text and attached tables\n    h2 = soup.select(\"h2\")[0]\n    assert \"extra database\" == h2.text.strip()\n    counts_p, links_p = h2.find_all_next(\"p\")[:2]\n    assert (\n        \"2 rows in 1 table, 5 rows in 4 hidden tables, 1 view\" == counts_p.text.strip()\n    )\n    # We should only show visible, not hidden tables here:\n    table_links = [\n        {\"href\": a[\"href\"], \"text\": a.text.strip()} for a in links_p.find_all(\"a\")\n    ]\n    assert [\n        {\"href\": r\"/extra+database/searchable\", \"text\": \"searchable\"},\n        {\"href\": r\"/extra+database/searchable_view\", \"text\": \"searchable_view\"},\n    ] == table_links\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"path\", (\"/\", \"/-/\"))\nasync def test_homepage_alternative_location(path, tmp_path_factory):\n    template_dir = tmp_path_factory.mktemp(\"templates\")\n    (template_dir / \"index.html\").write_text(\"Custom homepage\", \"utf-8\")\n    datasette = Datasette(template_dir=str(template_dir))\n    response = await datasette.client.get(path)\n    assert response.status_code == 200\n    html = response.text\n    if path == \"/\":\n        assert html == \"Custom homepage\"\n    else:\n        assert '<meta name=\"robots\" content=\"noindex\">' in html\n\n\n@pytest.mark.asyncio\nasync def test_homepage_alternative_redirect(ds_client):\n    response = await ds_client.get(\"/-\")\n    assert response.status_code == 301\n\n\n@pytest.mark.asyncio\nasync def test_http_head(ds_client):\n    response = await ds_client.head(\"/\")\n    assert response.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_homepage_options(ds_client):\n    response = await ds_client.options(\"/\")\n    assert response.status_code == 200\n    assert response.text == \"ok\"\n\n\n@pytest.mark.asyncio\nasync def test_favicon(ds_client):\n    response = await ds_client.get(\"/favicon.ico\")\n    assert response.status_code == 200\n    assert response.headers[\"cache-control\"] == \"max-age=3600, immutable, public\"\n    assert int(response.headers[\"content-length\"]) > 100\n    assert response.headers[\"content-type\"] == \"image/png\"\n\n\n@pytest.mark.asyncio\nasync def test_static(ds_client):\n    response = await ds_client.get(\"/-/static/app2.css\")\n    assert response.status_code == 404\n    response = await ds_client.get(\"/-/static/app.css\")\n    assert response.status_code == 200\n    assert \"text/css\" == response.headers[\"content-type\"]\n    assert \"etag\" in response.headers\n    etag = response.headers.get(\"etag\")\n    response = await ds_client.get(\"/-/static/app.css\", headers={\"if-none-match\": etag})\n    assert response.status_code == 304\n\n\ndef test_static_mounts():\n    with make_app_client(\n        static_mounts=[(\"custom-static\", str(pathlib.Path(__file__).parent))]\n    ) as client:\n        response = client.get(\"/custom-static/test_html.py\")\n        assert response.status_code == 200\n        response = client.get(\"/custom-static/not_exists.py\")\n        assert response.status_code == 404\n        response = client.get(\"/custom-static/../LICENSE\")\n        assert response.status_code == 404\n\n\ndef test_memory_database_page():\n    with make_app_client(memory=True) as client:\n        response = client.get(\"/_memory\")\n        assert response.status_code == 200\n\n\ndef test_not_allowed_methods():\n    with make_app_client(memory=True) as client:\n        for method in (\"post\", \"put\", \"patch\", \"delete\"):\n            response = client.request(path=\"/_memory\", method=method.upper())\n            assert response.status_code == 405\n\n\n@pytest.mark.asyncio\nasync def test_database_page(ds_client):\n    response = await ds_client.get(\"/fixtures\")\n    soup = Soup(response.text, \"html.parser\")\n    # Should have a <textarea> for executing SQL\n    assert \"<textarea\" in response.text\n\n    # And a list of tables\n    for fragment in (\n        '<h2 id=\"tables\">Tables',\n        '<h3><a href=\"/fixtures/sortable\">sortable</a></h3>',\n        \"<p><em>pk, foreign_key_with_label, foreign_key_with_blank_label, \",\n    ):\n        assert fragment in response.text\n\n    # And views\n    views_ul = soup.find(\"h2\", string=\"Views\").find_next_sibling(\"ul\")\n    assert views_ul is not None\n    assert [\n        (\"/fixtures/paginated_view\", \"paginated_view\"),\n        (\"/fixtures/searchable_view\", \"searchable_view\"),\n        (\n            \"/fixtures/searchable_view_configured_by_metadata\",\n            \"searchable_view_configured_by_metadata\",\n        ),\n        (\"/fixtures/simple_view\", \"simple_view\"),\n    ] == sorted([(a[\"href\"], a.text) for a in views_ul.find_all(\"a\")])\n\n    # And a list of canned queries\n    queries_ul = soup.find(\"h2\", string=\"Queries\").find_next_sibling(\"ul\")\n    assert queries_ul is not None\n    assert [\n        (\"/fixtures/from_async_hook\", \"from_async_hook\"),\n        (\"/fixtures/from_hook\", \"from_hook\"),\n        (\"/fixtures/magic_parameters\", \"magic_parameters\"),\n        (\"/fixtures/neighborhood_search#fragment-goes-here\", \"Search neighborhoods\"),\n        (\"/fixtures/pragma_cache_size\", \"pragma_cache_size\"),\n        (\n            \"/fixtures/~F0~9D~90~9C~F0~9D~90~A2~F0~9D~90~AD~F0~9D~90~A2~F0~9D~90~9E~F0~9D~90~AC\",\n            \"𝐜𝐢𝐭𝐢𝐞𝐬\",\n        ),\n    ] == sorted(\n        [(a[\"href\"], a.text) for a in queries_ul.find_all(\"a\")], key=lambda p: p[0]\n    )\n\n\n@pytest.mark.asyncio\nasync def test_invalid_custom_sql(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=.schema\")\n    assert response.status_code == 400\n    assert \"Statement must be a SELECT\" in response.text\n\n\n@pytest.mark.asyncio\nasync def test_disallowed_custom_sql_pragma(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query?sql=SELECT+*+FROM+pragma_not_on_allow_list('idx52')\"\n    )\n    assert response.status_code == 400\n    pragmas = \", \".join(\"pragma_{}()\".format(pragma) for pragma in allowed_pragmas)\n    assert (\n        \"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}\".format(\n            pragmas\n        )\n        in response.text\n    )\n\n\n@pytest.mark.xfail(reason=\"Sometimes flaky in CI due to timing issues\")\ndef test_sql_time_limit(app_client_shorter_time_limit):\n    response = app_client_shorter_time_limit.get(\n        \"/fixtures/-/query?sql=select+sleep(0.5)\"\n    )\n    assert 400 == response.status\n    expected_html_fragments = [\n        \"\"\"\n        <a href=\"https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms\">sql_time_limit_ms</a>\n    \"\"\".strip(),\n        '<textarea style=\"width: 90%\">select sleep(0.5)</textarea>',\n    ]\n    for expected_html_fragment in expected_html_fragments:\n        assert expected_html_fragment in response.text\n\n\ndef test_row_page_does_not_truncate():\n    with make_app_client(settings={\"truncate_cells_html\": 5}) as client:\n        response = client.get(\"/fixtures/facetable/1\")\n        assert response.status_code == 200\n        table = Soup(response.content, \"html.parser\").find(\"table\")\n        assert table[\"class\"] == [\"rows-and-columns\"]\n        assert [\"Mission\"] == [\n            td.string\n            for td in table.find_all(\"td\", {\"class\": \"col-neighborhood-b352a7\"})\n        ]\n\n\ndef test_query_page_truncates():\n    with make_app_client(settings={\"truncate_cells_html\": 5}) as client:\n        response = client.get(\n            \"/fixtures/-/query?\"\n            + urllib.parse.urlencode(\n                {\n                    \"sql\": \"select 'this is longer than 5' as a, 'https://example.com/' as b\"\n                }\n            )\n        )\n        assert response.status_code == 200\n        table = Soup(response.content, \"html.parser\").find(\"table\")\n        tds = table.find_all(\"td\")\n        assert [str(td) for td in tds] == [\n            '<td class=\"col-a\">this …</td>',\n            '<td class=\"col-b\"><a href=\"https://example.com/\">http…</a></td>',\n        ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_classes\",\n    [\n        (\"/\", [\"index\"]),\n        (\"/fixtures\", [\"db\", \"db-fixtures\"]),\n        (\"/fixtures/-/query?sql=select+1\", [\"query\", \"db-fixtures\"]),\n        (\n            \"/fixtures/simple_primary_key\",\n            [\"table\", \"db-fixtures\", \"table-simple_primary_key\"],\n        ),\n        (\n            \"/fixtures/neighborhood_search\",\n            [\"query\", \"db-fixtures\", \"query-neighborhood_search\"],\n        ),\n        (\n            \"/fixtures/table~2Fwith~2Fslashes~2Ecsv\",\n            [\"table\", \"db-fixtures\", \"table-tablewithslashescsv-fa7563\"],\n        ),\n        (\n            \"/fixtures/simple_primary_key/1\",\n            [\"row\", \"db-fixtures\", \"table-simple_primary_key\"],\n        ),\n    ],\n)\nasync def test_css_classes_on_body(ds_client, path, expected_classes):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    classes = re.search(r'<body class=\"(.*)\">', response.text).group(1).split()\n    assert classes == expected_classes\n\n\ntemplates_considered_re = re.compile(r\"<!-- Templates considered: (.*?) -->\")\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_considered\",\n    [\n        (\"/\", \"*index.html\"),\n        (\"/fixtures\", \"database-fixtures.html, *database.html\"),\n        (\n            \"/fixtures/simple_primary_key\",\n            \"table-fixtures-simple_primary_key.html, *table.html\",\n        ),\n        (\n            \"/fixtures/table~2Fwith~2Fslashes~2Ecsv\",\n            \"table-fixtures-tablewithslashescsv-fa7563.html, *table.html\",\n        ),\n        (\n            \"/fixtures/simple_primary_key/1\",\n            \"row-fixtures-simple_primary_key.html, *row.html\",\n        ),\n    ],\n)\nasync def test_templates_considered(ds_client, path, expected_considered):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    match = templates_considered_re.search(response.text)\n    assert match, \"No templates considered comment found\"\n    actual_considered = match.group(1)\n    assert actual_considered == expected_considered\n\n\n@pytest.mark.asyncio\nasync def test_row_json_export_link(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key/1\")\n    assert response.status_code == 200\n    assert '<a href=\"/fixtures/simple_primary_key/1.json\">json</a>' in response.text\n\n\n@pytest.mark.asyncio\nasync def test_query_json_csv_export_links(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=select+1\")\n    assert response.status_code == 200\n    assert '<a href=\"/fixtures/-/query.json?sql=select+1\">json</a>' in response.text\n    assert (\n        '<a href=\"/fixtures/-/query.csv?sql=select+1&amp;_size=max\">CSV</a>'\n        in response.text\n    )\n\n\n@pytest.mark.asyncio\nasync def test_query_parameter_form_fields(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=select+:name\")\n    assert response.status_code == 200\n    assert (\n        '<label for=\"qp1\">name</label> <input type=\"text\" id=\"qp1\" name=\"name\" value=\"\">'\n        in response.text\n    )\n    response2 = await ds_client.get(\"/fixtures/-/query?sql=select+:name&name=hello\")\n    assert response2.status_code == 200\n    assert (\n        '<label for=\"qp1\">name</label> <input type=\"text\" id=\"qp1\" name=\"name\" value=\"hello\">'\n        in response2.text\n    )\n\n\n@pytest.mark.asyncio\nasync def test_row_html_simple_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key/1\")\n    assert response.status_code == 200\n    table = Soup(response.content, \"html.parser\").find(\"table\")\n    assert [\"id\", \"content\"] == [th.string.strip() for th in table.select(\"thead th\")]\n    assert [\n        [\n            '<td class=\"col-id type-int\"><strong>1</strong></td>',\n            '<td class=\"col-content type-str\">hello</td>',\n        ]\n    ] == [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n\n\n@pytest.mark.asyncio\nasync def test_row_html_no_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/no_primary_key/1\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert [\"rowid\", \"content\", \"a\", \"b\", \"c\"] == [\n        th.string.strip() for th in table.select(\"thead th\")\n    ]\n    expected = [\n        [\n            '<td class=\"col-rowid type-int\"><strong>1</strong></td>',\n            '<td class=\"col-content type-str\">1</td>',\n            '<td class=\"col-a type-str\">a1</td>',\n            '<td class=\"col-b type-str\">b1</td>',\n            '<td class=\"col-c type-str\">c1</td>',\n        ]\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_text,expected_link\",\n    (\n        (\n            \"/fixtures/facet_cities/1\",\n            \"6 rows from _city_id in facetable\",\n            \"/fixtures/facetable?_city_id__exact=1\",\n        ),\n        (\n            \"/fixtures/attraction_characteristic/2\",\n            \"3 rows from characteristic_id in roadside_attraction_characteristics\",\n            \"/fixtures/roadside_attraction_characteristics?characteristic_id=2\",\n        ),\n    ),\n)\nasync def test_row_links_from_other_tables(\n    ds_client, path, expected_text, expected_link\n):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    h2 = soup.find(\"h2\")\n    assert h2.text == \"Links from other tables\"\n    li = h2.find_next(\"ul\").find(\"li\")\n    text = re.sub(r\"\\s+\", \" \", li.text.strip())\n    assert text == expected_text\n    link = li.find(\"a\")[\"href\"]\n    assert link == expected_link\n\n\n@pytest.mark.asyncio\nasync def test_row_foreign_key_links(ds_client):\n    # Row detail page should render foreign key values as hyperlinks\n    response = await ds_client.get(\"/fixtures/foreign_key_references/1\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # foreign_key_with_label=1 references simple_primary_key(id=1, content=\"hello\")\n    td = soup.find(\"td\", {\"class\": \"col-foreign_key_with_label\"})\n    a = td.find(\"a\")\n    assert a is not None, \"Expected foreign key value to be a hyperlink\"\n    assert a[\"href\"] == \"/fixtures/simple_primary_key/1\"\n    assert a.text == \"hello\"\n    # Primary key column should be first and bold\n    table = soup.find(\"table\")\n    headers = [th.text.strip() for th in table.select(\"thead th\")]\n    assert headers[0] == \"pk\"\n    first_td = table.select(\"tbody tr td\")[0]\n    assert first_td.find(\"strong\") is not None, \"PK value should be bold\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected\",\n    (\n        (\n            \"/fixtures/compound_primary_key/a,b\",\n            [\n                [\n                    '<td class=\"col-pk1 type-str\"><strong>a</strong></td>',\n                    '<td class=\"col-pk2 type-str\"><strong>b</strong></td>',\n                    '<td class=\"col-content type-str\">c</td>',\n                ]\n            ],\n        ),\n        (\n            \"/fixtures/compound_primary_key/a~2Fb,~2Ec~2Dd\",\n            [\n                [\n                    '<td class=\"col-pk1 type-str\"><strong>a/b</strong></td>',\n                    '<td class=\"col-pk2 type-str\"><strong>.c-d</strong></td>',\n                    '<td class=\"col-content type-str\">c</td>',\n                ]\n            ],\n        ),\n    ),\n)\nasync def test_row_html_compound_primary_key(ds_client, path, expected):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert [\"pk1\", \"pk2\", \"content\"] == [\n        th.string.strip() for th in table.select(\"thead th\")\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_index_metadata(ds_client):\n    response = await ds_client.get(\"/\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    assert \"Datasette Fixtures\" == soup.find(\"h1\").text\n    assert (\n        'An example SQLite database demonstrating Datasette. <a href=\"/login-as-root\">Sign in as root user</a>'\n        == inner_html(soup.find(\"div\", {\"class\": \"metadata-description\"}))\n    )\n    assert_footer_links(soup)\n\n\n@pytest.mark.asyncio\nasync def test_database_metadata(ds_client):\n    response = await ds_client.get(\"/fixtures\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # Page title should be the default\n    assert \"fixtures\" == soup.find(\"h1\").text\n    # Description should be custom\n    assert \"Test tables description\" == inner_html(\n        soup.find(\"div\", {\"class\": \"metadata-description\"})\n    )\n    # The source/license should be inherited\n    # assert_footer_links(soup) TODO(alex) ensure\n\n\n@pytest.mark.asyncio\nasync def test_database_metadata_with_custom_sql(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query?sql=select+*+from+simple_primary_key\"\n    )\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # Page title should be the default\n    assert \"fixtures\" == soup.find(\"h1\").text\n    # Description should be custom\n    assert \"Custom SQL query returning\" in soup.find(\"h3\").text\n    # The source/license should be inherited\n    # assert_footer_links(soup)TODO(alex) ensure\n\n\ndef test_database_download_for_immutable():\n    with make_app_client(is_immutable=True) as client:\n        assert not client.ds.databases[\"fixtures\"].is_mutable\n        # Regular page should have a download link\n        response = client.get(\"/fixtures\")\n        soup = Soup(response.content, \"html.parser\")\n        assert len(soup.find_all(\"a\", {\"href\": re.compile(r\"\\.db$\")}))\n        # Check we can actually download it\n        download_response = client.get(\"/fixtures.db\")\n        assert download_response.status_code == 200\n        # Check the content-length header exists\n        assert \"content-length\" in download_response.headers\n        content_length = download_response.headers[\"content-length\"]\n        assert content_length.isdigit()\n        assert int(content_length) > 100\n        assert \"content-disposition\" in download_response.headers\n        assert (\n            download_response.headers[\"content-disposition\"]\n            == 'attachment; filename=\"fixtures.db\"'\n        )\n        assert download_response.headers[\"transfer-encoding\"] == \"chunked\"\n        # ETag header should be present and match db.hash\n        assert \"etag\" in download_response.headers\n        etag = download_response.headers[\"etag\"]\n        assert etag == '\"{}\"'.format(client.ds.databases[\"fixtures\"].hash)\n        # Try a second download with If-None-Match: current-etag\n        download_response2 = client.get(\"/fixtures.db\", if_none_match=etag)\n        assert download_response2.body == b\"\"\n        assert download_response2.status == 304\n\n\ndef test_database_download_disallowed_for_mutable(app_client):\n    # Use app_client because we need a file database, not in-memory\n    response = app_client.get(\"/fixtures\")\n    soup = Soup(response.content, \"html.parser\")\n    assert len(soup.find_all(\"a\", {\"href\": re.compile(r\"\\.db$\")})) == 0\n    assert app_client.get(\"/fixtures.db\").status_code == 403\n\n\ndef test_database_download_disallowed_for_memory():\n    with make_app_client(memory=True) as client:\n        # Memory page should NOT have a download link\n        response = client.get(\"/_memory\")\n        soup = Soup(response.content, \"html.parser\")\n        assert 0 == len(soup.find_all(\"a\", {\"href\": re.compile(r\"\\.db$\")}))\n        assert 404 == client.get(\"/_memory.db\").status\n\n\ndef test_allow_download_off():\n    with make_app_client(\n        is_immutable=True, settings={\"allow_download\": False}\n    ) as client:\n        response = client.get(\"/fixtures\")\n        soup = Soup(response.content, \"html.parser\")\n        assert not len(soup.find_all(\"a\", {\"href\": re.compile(r\"\\.db$\")}))\n        # Accessing URL directly should 403\n        response = client.get(\"/fixtures.db\")\n        assert 403 == response.status\n\n\ndef test_allow_sql_off():\n    with make_app_client(config={\"allow_sql\": {}}) as client:\n        response = client.get(\"/fixtures\")\n        soup = Soup(response.content, \"html.parser\")\n        assert not len(soup.find_all(\"textarea\", {\"name\": \"sql\"}))\n        # The table page should no longer show \"View and edit SQL\"\n        response = client.get(\"/fixtures/sortable\")\n        assert b\"View and edit SQL\" not in response.content\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"path\", [\"/404\", \"/fixtures/404\"])\nasync def test_404(ds_client, path):\n    response = await ds_client.get(path)\n    assert response.status_code == 404\n    assert (\n        f'<link rel=\"stylesheet\" href=\"/-/static/app.css?{ds_client.ds.app_css_hash()}'\n        in response.text\n    )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_redirect\",\n    [(\"/fixtures/\", \"/fixtures\"), (\"/fixtures/simple_view/\", \"/fixtures/simple_view\")],\n)\nasync def test_404_trailing_slash_redirect(ds_client, path, expected_redirect):\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"] == expected_redirect\n\n\n@pytest.mark.asyncio\nasync def test_404_content_type(ds_client):\n    response = await ds_client.get(\"/404\")\n    assert response.status_code == 404\n    assert \"text/html; charset=utf-8\" == response.headers[\"content-type\"]\n\n\n@pytest.mark.asyncio\nasync def test_canned_query_default_title(ds_client):\n    response = await ds_client.get(\"/fixtures/magic_parameters\")\n    assert response.status_code == 200\n    soup = Soup(response.content, \"html.parser\")\n    assert \"fixtures: magic_parameters\" == soup.find(\"h1\").text\n\n\n@pytest.mark.asyncio\nasync def test_canned_query_with_custom_metadata(ds_client):\n    response = await ds_client.get(\"/fixtures/neighborhood_search?text=town\")\n    assert response.status_code == 200\n    soup = Soup(response.content, \"html.parser\")\n    assert \"Search neighborhoods\" == soup.find(\"h1\").text\n    assert (\n        \"\"\"\n<div class=\"metadata-description\">\n <b>\n  Demonstrating\n </b>\n simple like search\n</div>\"\"\".strip()\n        == soup.find(\"div\", {\"class\": \"metadata-description\"}).prettify().strip()\n    )\n\n\n@pytest.mark.asyncio\nasync def test_urlify_custom_queries(ds_client):\n    path = \"/fixtures/-/query?\" + urllib.parse.urlencode(\n        {\"sql\": \"select ('https://twitter.com/' || 'simonw') as user_url;\"}\n    )\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    soup = Soup(response.content, \"html.parser\")\n    assert \"\"\"<td class=\"col-user_url\">\n <a href=\"https://twitter.com/simonw\">\n  https://twitter.com/simonw\n </a>\n</td>\"\"\" == soup.find(\"td\", {\"class\": \"col-user_url\"}).prettify().strip()\n\n\n@pytest.mark.asyncio\nasync def test_show_hide_sql_query(ds_client):\n    path = \"/fixtures/-/query?\" + urllib.parse.urlencode(\n        {\"sql\": \"select ('https://twitter.com/' || 'simonw') as user_url;\"}\n    )\n    response = await ds_client.get(path)\n    soup = Soup(response.content, \"html.parser\")\n    span = soup.select(\".show-hide-sql\")[0]\n    assert span.find(\"a\")[\"href\"].endswith(\"&_hide_sql=1\")\n    assert \"(hide)\" == span.getText()\n    assert soup.find(\"textarea\") is not None\n    # Now follow the link to hide it\n    response = await ds_client.get(span.find(\"a\")[\"href\"])\n    soup = Soup(response.content, \"html.parser\")\n    span = soup.select(\".show-hide-sql\")[0]\n    assert not span.find(\"a\")[\"href\"].endswith(\"&_hide_sql=1\")\n    assert \"(show)\" == span.getText()\n    assert soup.find(\"textarea\") is None\n    # The SQL should still be there in a hidden form field\n    hiddens = soup.find(\"form\").select(\"input[type=hidden]\")\n    assert [\n        (\"sql\", \"select ('https://twitter.com/' || 'simonw') as user_url;\"),\n        (\"_hide_sql\", \"1\"),\n    ] == [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens]\n\n\n@pytest.mark.asyncio\nasync def test_canned_query_with_hide_has_no_hidden_sql(ds_client):\n    # For a canned query the show/hide should NOT have a hidden SQL field\n    # https://github.com/simonw/datasette/issues/1411\n    response = await ds_client.get(\"/fixtures/pragma_cache_size?_hide_sql=1\")\n    soup = Soup(response.content, \"html.parser\")\n    hiddens = soup.find(\"form\").select(\"input[type=hidden]\")\n    assert [\n        (\"_hide_sql\", \"1\"),\n    ] == [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens]\n\n\n@pytest.mark.parametrize(\n    \"hide_sql,querystring,expected_hidden,expected_show_hide_link,expected_show_hide_text\",\n    (\n        (False, \"\", None, \"/_memory/one?_hide_sql=1\", \"hide\"),\n        (False, \"?_hide_sql=1\", \"_hide_sql\", \"/_memory/one\", \"show\"),\n        (True, \"\", None, \"/_memory/one?_show_sql=1\", \"show\"),\n        (True, \"?_show_sql=1\", \"_show_sql\", \"/_memory/one\", \"hide\"),\n    ),\n)\ndef test_canned_query_show_hide_metadata_option(\n    hide_sql,\n    querystring,\n    expected_hidden,\n    expected_show_hide_link,\n    expected_show_hide_text,\n):\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"_memory\": {\n                    \"queries\": {\n                        \"one\": {\n                            \"sql\": \"select 1 + 1\",\n                            \"hide_sql\": hide_sql,\n                        }\n                    }\n                }\n            }\n        },\n        memory=True,\n    ) as client:\n        expected_show_hide_fragment = '(<a href=\"{}\">{}</a>)'.format(\n            expected_show_hide_link, expected_show_hide_text\n        )\n        response = client.get(\"/_memory/one\" + querystring)\n        html = response.text\n        show_hide_fragment = html.split('<span class=\"show-hide-sql\">')[1].split(\n            \"</span>\"\n        )[0]\n        assert show_hide_fragment == expected_show_hide_fragment\n        if expected_hidden:\n            assert (\n                '<input type=\"hidden\" name=\"{}\" value=\"1\">'.format(expected_hidden)\n                in html\n            )\n        else:\n            assert '<input type=\"hidden\" ' not in html\n\n\n@pytest.mark.asyncio\nasync def test_binary_data_display_in_query(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=select+*+from+binary_data\")\n    assert response.status_code == 200\n    table = Soup(response.content, \"html.parser\").find(\"table\")\n    expected_tds = [\n        [\n            '<td class=\"col-data\"><a class=\"blob-download\" href=\"/fixtures/-/query.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d\">&lt;Binary:\\xa07\\xa0bytes&gt;</a></td>'\n        ],\n        [\n            '<td class=\"col-data\"><a class=\"blob-download\" href=\"/fixtures/-/query.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724\">&lt;Binary:\\xa07\\xa0bytes&gt;</a></td>'\n        ],\n        ['<td class=\"col-data\">\\xa0</td>'],\n    ]\n    assert expected_tds == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_filename\",\n    [\n        (\"/fixtures/binary_data/1.blob?_blob_column=data\", \"binary_data-1-data.blob\"),\n        (\n            \"/fixtures/-/query.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d\",\n            \"data-f30889.blob\",\n        ),\n    ],\n)\nasync def test_blob_download(ds_client, path, expected_filename):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    assert response.content == b\"\\x15\\x1c\\x02\\xc7\\xad\\x05\\xfe\"\n    assert response.headers[\"x-content-type-options\"] == \"nosniff\"\n    assert (\n        response.headers[\"content-disposition\"]\n        == f'attachment; filename=\"{expected_filename}\"'\n    )\n    assert response.headers[\"content-type\"] == \"application/binary\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_message\",\n    [\n        (\"/fixtures/binary_data/1.blob\", \"?_blob_column= is required\"),\n        (\"/fixtures/binary_data/1.blob?_blob_column=foo\", \"foo is not a valid column\"),\n        (\n            \"/fixtures/binary_data/1.blob?_blob_column=data&_blob_hash=x\",\n            \"Link has expired - the requested binary content has changed or could not be found.\",\n        ),\n    ],\n)\nasync def test_blob_download_invalid_messages(ds_client, path, expected_message):\n    response = await ds_client.get(path)\n    assert response.status_code == 400\n    assert expected_message in response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path\",\n    [\n        \"/fixtures/-/query?sql=select+*+from+[123_starts_with_digits]\",\n        \"/fixtures/123_starts_with_digits\",\n    ],\n)\nasync def test_zero_results(ds_client, path):\n    response = await ds_client.get(path)\n    soup = Soup(response.text, \"html.parser\")\n    assert 0 == len(soup.select(\"table\"))\n    assert 1 == len(soup.select(\"p.zero-results\"))\n\n\n@pytest.mark.asyncio\nasync def test_query_error(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=select+*+from+notatable\")\n    html = response.text\n    assert '<p class=\"message-error\">no such table: notatable</p>' in html\n    assert '<textarea id=\"sql-editor\" name=\"sql\" style=\"height: 3em' in html\n    assert \">select * from notatable</textarea>\" in html\n    assert \"0 results\" not in html\n\n\ndef test_config_template_debug_on():\n    with make_app_client(settings={\"template_debug\": True}) as client:\n        response = client.get(\"/fixtures/facetable?_context=1\")\n        assert response.status_code == 200\n        assert response.text.startswith(\"<pre>{\")\n\n\n@pytest.mark.asyncio\nasync def test_config_template_debug_off(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable?_context=1\")\n    assert response.status_code == 200\n    assert not response.text.startswith(\"<pre>{\")\n\n\ndef test_debug_context_includes_extra_template_vars():\n    # https://github.com/simonw/datasette/issues/693\n    with make_app_client(settings={\"template_debug\": True}) as client:\n        response = client.get(\"/fixtures/facetable?_context=1\")\n        # scope_path is added by PLUGIN1\n        assert \"scope_path\" in response.text\n\n\n@pytest.mark.parametrize(\n    \"path\",\n    [\n        \"/\",\n        \"/fixtures\",\n        \"/fixtures/compound_three_primary_keys\",\n        \"/fixtures/compound_three_primary_keys/a,a,a\",\n        \"/fixtures/paginated_view\",\n        \"/fixtures/facetable\",\n        \"/fixtures/facetable?_facet=state\",\n        \"/fixtures/-/query?sql=select+1\",\n    ],\n)\n@pytest.mark.parametrize(\"use_prefix\", (True, False))\ndef test_base_url_config(app_client_base_url_prefix, path, use_prefix):\n    client = app_client_base_url_prefix\n    path_to_get = path\n    if use_prefix:\n        path_to_get = \"/prefix/\" + path.lstrip(\"/\")\n    response = client.get(path_to_get)\n    soup = Soup(response.content, \"html.parser\")\n    for form in soup.select(\"form\"):\n        action = form.get(\"action\")\n        if action is None:\n            assert form.get(\"method\") == \"dialog\", json.dumps(\n                {\n                    \"path\": path,\n                    \"path_to_get\": path_to_get,\n                    \"form\": str(form),\n                },\n                indent=4,\n                default=repr,\n            )\n            continue\n        assert action.startswith(\"/prefix\"), json.dumps(\n            {\n                \"path\": path,\n                \"path_to_get\": path_to_get,\n                \"action\": action,\n                \"form\": str(form),\n            },\n            indent=4,\n            default=repr,\n        )\n    for el in soup.find_all([\"a\", \"link\", \"script\"]):\n        if \"href\" in el.attrs:\n            href = el[\"href\"]\n        elif \"src\" in el.attrs:\n            href = el[\"src\"]\n        else:\n            continue  # Could be a <script>...</script>\n        if (\n            not href.startswith(\"#\")\n            and href\n            not in {\n                \"https://datasette.io/\",\n                \"https://github.com/simonw/datasette\",\n                \"https://github.com/simonw/datasette/blob/main/LICENSE\",\n                \"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\",\n                \"/login-as-root\",  # Only used for the latest.datasette.io demo\n            }\n            and not href.startswith(\"https://plugin-example.datasette.io/\")\n        ):\n            # If this has been made absolute it may start http://localhost/\n            if href.startswith(\"http://localhost/\"):\n                href = href[len(\"http://localost/\") :]\n            assert href.startswith(\"/prefix/\"), json.dumps(\n                {\n                    \"path\": path,\n                    \"path_to_get\": path_to_get,\n                    \"href_or_src\": href,\n                    \"element_parent\": str(el.parent),\n                },\n                indent=4,\n                default=repr,\n            )\n\n\ndef test_base_url_affects_filter_redirects(app_client_base_url_prefix):\n    path = \"/fixtures/binary_data?_filter_column=rowid&_filter_op=exact&_filter_value=1&_sort=rowid\"\n    response = app_client_base_url_prefix.get(path)\n    assert response.status_code == 302\n    assert (\n        response.headers[\"location\"]\n        == \"/prefix/fixtures/binary_data?_sort=rowid&rowid__exact=1\"\n    )\n\n\ndef test_base_url_affects_metadata_extra_css_urls(app_client_base_url_prefix):\n    html = app_client_base_url_prefix.get(\"/\").text\n    assert '<link rel=\"stylesheet\" href=\"/prefix/static/extra-css-urls.css\">' in html\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected\",\n    [\n        (\n            \"/fixtures/neighborhood_search\",\n            \"/fixtures/-/query?sql=%0Aselect+_neighborhood%2C+facet_cities.name%2C+state%0Afrom+facetable%0A++++join+facet_cities%0A++++++++on+facetable._city_id+%3D+facet_cities.id%0Awhere+_neighborhood+like+%27%25%27+%7C%7C+%3Atext+%7C%7C+%27%25%27%0Aorder+by+_neighborhood%3B%0A&amp;text=\",\n        ),\n        (\n            \"/fixtures/neighborhood_search?text=ber\",\n            \"/fixtures/-/query?sql=%0Aselect+_neighborhood%2C+facet_cities.name%2C+state%0Afrom+facetable%0A++++join+facet_cities%0A++++++++on+facetable._city_id+%3D+facet_cities.id%0Awhere+_neighborhood+like+%27%25%27+%7C%7C+%3Atext+%7C%7C+%27%25%27%0Aorder+by+_neighborhood%3B%0A&amp;text=ber\",\n        ),\n        (\"/fixtures/pragma_cache_size\", None),\n        (\n            # /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬\n            \"/fixtures/~F0~9D~90~9C~F0~9D~90~A2~F0~9D~90~AD~F0~9D~90~A2~F0~9D~90~9E~F0~9D~90~AC\",\n            \"/fixtures/-/query?sql=select+id%2C+name+from+facet_cities+order+by+id+limit+1%3B\",\n        ),\n        (\"/fixtures/magic_parameters\", None),\n    ],\n)\nasync def test_edit_sql_link_on_canned_queries(ds_client, path, expected):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    expected_link = f'<a href=\"{expected}\" class=\"canned-query-edit-sql\">Edit SQL</a>'\n    if expected:\n        assert expected_link in response.text\n    else:\n        assert \"Edit SQL\" not in response.text\n\n\n@pytest.mark.parametrize(\n    \"has_permission\",\n    [\n        pytest.param(\n            True,\n        ),\n        False,\n    ],\n)\ndef test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission):\n    with make_app_client(\n        config={\n            \"allow_sql\": None if has_permission else {\"id\": \"not-you\"},\n            \"databases\": {\"fixtures\": {\"queries\": {\"simple\": \"select 1 + 1\"}}},\n        }\n    ) as client:\n        response = client.get(\"/fixtures/simple\")\n        if has_permission:\n            assert \"Edit SQL\" in response.text\n        else:\n            assert \"Edit SQL\" not in response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"actor_id,should_have_links,should_not_have_links\",\n    [\n        (None, None, None),\n        (\"test\", None, [\"/-/permissions\"]),\n        (\"root\", [\"/-/permissions\", \"/-/allow-debug\"], None),\n    ],\n)\nasync def test_navigation_menu_links(\n    ds_client, actor_id, should_have_links, should_not_have_links\n):\n    # Enable root user if testing with root actor\n    if actor_id == \"root\":\n        ds_client.ds.root_enabled = True\n    cookies = {}\n    if actor_id:\n        cookies = {\"ds_actor\": ds_client.actor_cookie({\"id\": actor_id})}\n    html = (await ds_client.get(\"/\", cookies=cookies)).text\n    soup = Soup(html, \"html.parser\")\n    details = soup.find(\"nav\").find(\"details\")\n    if not actor_id:\n        # Should not show a menu\n        assert details is None\n        return\n    # They are logged in: should show a menu\n    assert details is not None\n    # And a logout form\n    assert details.find(\"form\") is not None\n    if should_have_links:\n        for link in should_have_links:\n            assert (\n                details.find(\"a\", {\"href\": link}) is not None\n            ), f\"{link} expected but missing from nav menu\"\n\n    if should_not_have_links:\n        for link in should_not_have_links:\n            assert (\n                details.find(\"a\", {\"href\": link}) is None\n            ), f\"{link} found but should not have been in nav menu\"\n\n\n@pytest.mark.asyncio\nasync def test_trace_correctly_escaped(ds_client):\n    response = await ds_client.get(\"/fixtures/-/query?sql=select+'<h1>Hello'&_trace=1\")\n    assert \"select '<h1>Hello\" not in response.text\n    assert \"select &#39;&lt;h1&gt;Hello\" in response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected\",\n    (\n        # Instance index page\n        (\"/\", \"http://localhost/.json\"),\n        # Table page\n        (\"/fixtures/facetable\", \"http://localhost/fixtures/facetable.json\"),\n        (\n            \"/fixtures/table~2Fwith~2Fslashes~2Ecsv\",\n            \"http://localhost/fixtures/table~2Fwith~2Fslashes~2Ecsv.json\",\n        ),\n        # Row page\n        (\n            \"/fixtures/no_primary_key/1\",\n            \"http://localhost/fixtures/no_primary_key/1.json\",\n        ),\n        # Database index page\n        (\n            \"/fixtures\",\n            \"http://localhost/fixtures.json\",\n        ),\n        # Custom query page\n        (\n            \"/fixtures/-/query?sql=select+*+from+facetable\",\n            \"http://localhost/fixtures/-/query.json?sql=select+*+from+facetable\",\n        ),\n        # Canned query page\n        (\n            \"/fixtures/neighborhood_search?text=town\",\n            \"http://localhost/fixtures/neighborhood_search.json?text=town\",\n        ),\n        # /-/ pages\n        (\n            \"/-/plugins\",\n            \"http://localhost/-/plugins.json\",\n        ),\n    ),\n)\nasync def test_alternate_url_json(ds_client, path, expected):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    link = response.headers[\"link\"]\n    assert link == '<{}>; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\n        expected\n    )\n    assert (\n        '<link rel=\"alternate\" type=\"application/json+datasette\" href=\"{}\">'.format(\n            expected\n        )\n        in response.text\n    )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path\",\n    (\"/-/patterns\", \"/-/messages\", \"/-/allow-debug\", \"/fixtures.db\"),\n)\nasync def test_no_alternate_url_json(ds_client, path):\n    response = await ds_client.get(path)\n    assert \"link\" not in response.headers\n    assert (\n        '<link rel=\"alternate\" type=\"application/json+datasette\"' not in response.text\n    )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected\",\n    (\n        (\n            \"/fivethirtyeight/twitter-ratio%2Fsenators\",\n            \"/fivethirtyeight/twitter-ratio~2Fsenators\",\n        ),\n        (\n            \"/fixtures/table%2Fwith%2Fslashes.csv\",\n            \"/fixtures/table~2Fwith~2Fslashes~2Ecsv\",\n        ),\n        # query string should be preserved\n        (\"/foo/bar%2Fbaz?id=5\", \"/foo/bar~2Fbaz?id=5\"),\n    ),\n)\nasync def test_redirect_percent_encoding_to_tilde_encoding(ds_client, path, expected):\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"location\"] == expected\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,config,expected_links\",\n    (\n        (\"/fixtures\", {}, [(\"/\", \"home\"), (\"/fixtures\", \"fixtures\")]),\n        (\n            \"/fixtures\",\n            {\"allow\": False, \"databases\": {\"fixtures\": {\"allow\": True}}},\n            [(\"/fixtures\", \"fixtures\")],\n        ),\n        (\n            \"/fixtures/facetable\",\n            {\"allow\": False, \"databases\": {\"fixtures\": {\"allow\": True}}},\n            [(\"/fixtures\", \"fixtures\"), (\"/fixtures/facetable\", \"facetable\")],\n        ),\n        (\n            \"/fixtures/facetable/1\",\n            {},\n            [\n                (\"/\", \"home\"),\n                (\"/fixtures\", \"fixtures\"),\n                (\"/fixtures/facetable\", \"facetable\"),\n            ],\n        ),\n        (\n            \"/fixtures/facetable/1\",\n            {\"allow\": False, \"databases\": {\"fixtures\": {\"allow\": True}}},\n            [(\"/fixtures\", \"fixtures\"), (\"/fixtures/facetable\", \"facetable\")],\n        ),\n    ),\n)\nasync def test_breadcrumbs_respect_permissions(ds_client, path, config, expected_links):\n    previous_config = ds_client.ds.config\n    updated_config = copy.deepcopy(previous_config)\n    updated_config.update(config)\n    ds_client.ds.config = updated_config\n\n    try:\n        response = await ds_client.ds.client.get(path)\n        soup = Soup(response.text, \"html.parser\")\n        breadcrumbs = soup.select(\"p.crumbs a\")\n        actual = [(a[\"href\"], a.text) for a in breadcrumbs]\n        assert actual == expected_links\n    finally:\n        ds_client.ds.config = previous_config\n\n\n@pytest.mark.asyncio\nasync def test_database_color(ds_client):\n    expected_color = ds_client.ds.get_database(\"fixtures\").color\n    # Should be something like #9403e5\n    expected_fragments = (\n        \"10px solid #{}\".format(expected_color),\n        \"border-color: #{}\".format(expected_color),\n    )\n    assert len(expected_color) == 6\n    for path in (\n        \"/\",\n        \"/fixtures\",\n        \"/fixtures/facetable\",\n        \"/fixtures/paginated_view\",\n        \"/fixtures/pragma_cache_size\",\n    ):\n        response = await ds_client.get(path)\n        assert any(\n            fragment in response.text for fragment in expected_fragments\n        ), f\"Color fragments not found in {path}. Expected: {expected_fragments}\"\n\n\n@pytest.mark.asyncio\nasync def test_custom_csrf_error(ds_client):\n    response = await ds_client.post(\n        \"/-/messages\",\n        data={\n            \"message\": \"A message\",\n        },\n        cookies={\"csrftoken\": \"x\"},\n    )\n    assert response.status_code == 403\n    assert response.headers[\"content-type\"] == \"text/html; charset=utf-8\"\n    assert \"Error code is FORM_URLENCODED_MISMATCH.\" in response.text\n\n\n@pytest.mark.asyncio\nasync def test_actions_page(ds_client):\n    original_root_enabled = ds_client.ds.root_enabled\n    try:\n        ds_client.ds.root_enabled = True\n        cookies = {\"ds_actor\": ds_client.actor_cookie({\"id\": \"root\"})}\n        response = await ds_client.get(\"/-/actions\", cookies=cookies)\n        assert response.status_code == 200\n        assert \"Registered actions\" in response.text\n        assert \"<th>Name</th>\" in response.text\n        assert \"view-instance\" in response.text\n        assert \"view-database\" in response.text\n    finally:\n        ds_client.ds.root_enabled = original_root_enabled\n\n\n@pytest.mark.asyncio\nasync def test_actions_page_does_not_display_none_string(ds_client):\n    \"\"\"Ensure the Resource column doesn't display the string 'None' for null values.\"\"\"\n    # https://github.com/simonw/datasette/issues/2599\n    original_root_enabled = ds_client.ds.root_enabled\n    try:\n        ds_client.ds.root_enabled = True\n        cookies = {\"ds_actor\": ds_client.actor_cookie({\"id\": \"root\"})}\n        response = await ds_client.get(\"/-/actions\", cookies=cookies)\n        assert response.status_code == 200\n        assert \"<code>None</code>\" not in response.text\n    finally:\n        ds_client.ds.root_enabled = original_root_enabled\n\n\n@pytest.mark.asyncio\nasync def test_permission_debug_tabs_with_query_string(ds_client):\n    \"\"\"Test that navigation tabs persist query strings across Check, Allowed, and Rules pages\"\"\"\n    original_root_enabled = ds_client.ds.root_enabled\n    try:\n        ds_client.ds.root_enabled = True\n        cookies = {\"ds_actor\": ds_client.actor_cookie({\"id\": \"root\"})}\n\n        # Test /-/allowed with query string\n        response = await ds_client.get(\n            \"/-/allowed?action=view-table&page_size=50\", cookies=cookies\n        )\n        assert response.status_code == 200\n        # Check that Rules and Check tabs have the query string\n        assert 'href=\"/-/rules?action=view-table&amp;page_size=50\"' in response.text\n        assert 'href=\"/-/check?action=view-table&amp;page_size=50\"' in response.text\n        # Playground and Actions should not have query string\n        assert 'href=\"/-/permissions\"' in response.text\n        assert 'href=\"/-/actions\"' in response.text\n\n        # Test /-/rules with query string\n        response = await ds_client.get(\n            \"/-/rules?action=view-database&parent=test\", cookies=cookies\n        )\n        assert response.status_code == 200\n        # Check that Allowed and Check tabs have the query string\n        assert 'href=\"/-/allowed?action=view-database&amp;parent=test\"' in response.text\n        assert 'href=\"/-/check?action=view-database&amp;parent=test\"' in response.text\n\n        # Test /-/check with query string\n        response = await ds_client.get(\"/-/check?action=execute-sql\", cookies=cookies)\n        assert response.status_code == 200\n        # Check that Allowed and Rules tabs have the query string\n        assert 'href=\"/-/allowed?action=execute-sql\"' in response.text\n        assert 'href=\"/-/rules?action=execute-sql\"' in response.text\n    finally:\n        ds_client.ds.root_enabled = original_root_enabled\n"
  },
  {
    "path": "tests/test_internal_db.py",
    "content": "import pytest\nimport sqlite_utils\n\n\n# ensure refresh_schemas() gets called before interacting with internal_db\nasync def ensure_internal(ds_client):\n    await ds_client.get(\"/fixtures.json?sql=select+1\")\n    return ds_client.ds.get_internal_database()\n\n\n@pytest.mark.asyncio\nasync def test_internal_databases(ds_client):\n    internal_db = await ensure_internal(ds_client)\n    databases = await internal_db.execute(\"select * from catalog_databases\")\n    assert len(databases) == 1\n    assert databases.rows[0][\"database_name\"] == \"fixtures\"\n\n\n@pytest.mark.asyncio\nasync def test_internal_tables(ds_client):\n    internal_db = await ensure_internal(ds_client)\n    tables = await internal_db.execute(\"select * from catalog_tables\")\n    assert len(tables) > 5\n    table = tables.rows[0]\n    assert set(table.keys()) == {\"rootpage\", \"table_name\", \"database_name\", \"sql\"}\n\n\n@pytest.mark.asyncio\nasync def test_internal_views(ds_client):\n    internal_db = await ensure_internal(ds_client)\n    views = await internal_db.execute(\"select * from catalog_views\")\n    assert len(views) >= 4\n    view = views.rows[0]\n    assert set(view.keys()) == {\"rootpage\", \"view_name\", \"database_name\", \"sql\"}\n\n\n@pytest.mark.asyncio\nasync def test_internal_indexes(ds_client):\n    internal_db = await ensure_internal(ds_client)\n    indexes = await internal_db.execute(\"select * from catalog_indexes\")\n    assert len(indexes) > 5\n    index = indexes.rows[0]\n    assert set(index.keys()) == {\n        \"partial\",\n        \"name\",\n        \"table_name\",\n        \"unique\",\n        \"seq\",\n        \"database_name\",\n        \"origin\",\n    }\n\n\n@pytest.mark.asyncio\nasync def test_internal_foreign_keys(ds_client):\n    internal_db = await ensure_internal(ds_client)\n    foreign_keys = await internal_db.execute(\"select * from catalog_foreign_keys\")\n    assert len(foreign_keys) > 5\n    foreign_key = foreign_keys.rows[0]\n    assert set(foreign_key.keys()) == {\n        \"table\",\n        \"seq\",\n        \"on_update\",\n        \"on_delete\",\n        \"to\",\n        \"id\",\n        \"match\",\n        \"database_name\",\n        \"table_name\",\n        \"from\",\n    }\n\n\n@pytest.mark.asyncio\nasync def test_internal_foreign_key_references(ds_client):\n    internal_db = await ensure_internal(ds_client)\n\n    def inner(conn):\n        db = sqlite_utils.Database(conn)\n        table_names = db.table_names()\n        for table in db.tables:\n            for fk in table.foreign_keys:\n                other_table = fk.other_table\n                other_column = fk.other_column\n                message = 'Column \"{}.{}\" references other column \"{}.{}\" which does not exist'.format(\n                    table.name, fk.column, other_table, other_column\n                )\n                assert other_table in table_names, message + \" (bad table)\"\n                assert other_column in db[other_table].columns_dict, (\n                    message + \" (bad column)\"\n                )\n\n    await internal_db.execute_fn(inner)\n\n\n@pytest.mark.asyncio\nasync def test_stale_catalog_entry_database_fix(tmp_path):\n    \"\"\"\n    Test for https://github.com/simonw/datasette/issues/2605\n\n    When the internal database persists across restarts and has entries in\n    catalog_databases for databases that no longer exist, accessing the\n    index page should not cause a 500 error (KeyError).\n    \"\"\"\n    from datasette.app import Datasette\n\n    internal_db_path = str(tmp_path / \"internal.db\")\n    data_db_path = str(tmp_path / \"data.db\")\n\n    # Create a data database file\n    import sqlite3\n\n    conn = sqlite3.connect(data_db_path)\n    conn.execute(\"CREATE TABLE test_table (id INTEGER PRIMARY KEY)\")\n    conn.close()\n\n    # First Datasette instance: with the data database and persistent internal db\n    ds1 = Datasette(files=[data_db_path], internal=internal_db_path)\n    await ds1.invoke_startup()\n\n    # Access the index page to populate the internal catalog\n    response = await ds1.client.get(\"/\")\n    assert \"data\" in ds1.databases\n    assert response.status_code == 200\n\n    # Second Datasette instance: reusing internal.db but WITHOUT the data database\n    # This simulates restarting Datasette after removing a database\n    ds2 = Datasette(internal=internal_db_path)\n    await ds2.invoke_startup()\n\n    # The database is not in ds2.databases\n    assert \"data\" not in ds2.databases\n\n    # Accessing the index page should NOT cause a 500 error\n    # This is the bug: it currently raises KeyError when trying to\n    # access ds.databases[\"data\"] for the stale catalog entry\n    response = await ds2.client.get(\"/\")\n    assert response.status_code == 200, (\n        f\"Index page should return 200, not {response.status_code}. \"\n        \"This fails due to stale catalog entries causing KeyError.\"\n    )\n"
  },
  {
    "path": "tests/test_internals_database.py",
    "content": "\"\"\"\nTests for the datasette.database.Database class\n\"\"\"\n\nfrom datasette.app import Datasette\nfrom datasette.database import Database, Results, MultipleValues\nfrom datasette.utils.sqlite import sqlite3, sqlite_version\nfrom datasette.utils import Column\nimport pytest\nimport time\nimport uuid\n\n\n@pytest.fixture\ndef db(app_client):\n    return app_client.ds.get_database(\"fixtures\")\n\n\n@pytest.mark.asyncio\nasync def test_execute(db):\n    results = await db.execute(\"select * from facetable\")\n    assert isinstance(results, Results)\n    assert 15 == len(results)\n\n\n@pytest.mark.asyncio\nasync def test_results_first(db):\n    assert None is (await db.execute(\"select * from facetable where pk > 100\")).first()\n    results = await db.execute(\"select * from facetable\")\n    row = results.first()\n    assert isinstance(row, sqlite3.Row)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"expected\", (True, False))\nasync def test_results_bool(db, expected):\n    where = \"\" if expected else \"where pk = 0\"\n    results = await db.execute(\"select * from facetable {}\".format(where))\n    assert bool(results) is expected\n\n\n@pytest.mark.asyncio\nasync def test_results_dicts(db):\n    results = await db.execute(\"select pk, name from roadside_attractions\")\n    assert results.dicts() == [\n        {\"pk\": 1, \"name\": \"The Mystery Spot\"},\n        {\"pk\": 2, \"name\": \"Winchester Mystery House\"},\n        {\"pk\": 3, \"name\": \"Burlingame Museum of PEZ Memorabilia\"},\n        {\"pk\": 4, \"name\": \"Bigfoot Discovery Museum\"},\n    ]\n\n\n@pytest.mark.parametrize(\n    \"query,expected\",\n    [\n        (\"select 1\", 1),\n        (\"select 1, 2\", None),\n        (\"select 1 as num union select 2 as num\", None),\n    ],\n)\n@pytest.mark.asyncio\nasync def test_results_single_value(db, query, expected):\n    results = await db.execute(query)\n    if expected:\n        assert expected == results.single_value()\n    else:\n        with pytest.raises(MultipleValues):\n            results.single_value()\n\n\n@pytest.mark.asyncio\nasync def test_execute_fn(db):\n    def get_1_plus_1(conn):\n        return conn.execute(\"select 1 + 1\").fetchall()[0][0]\n\n    assert 2 == await db.execute_fn(get_1_plus_1)\n\n\n@pytest.mark.asyncio\nasync def test_execute_fn_transaction_false():\n    datasette = Datasette(memory=True)\n    db = datasette.add_memory_database(\"test_execute_fn_transaction_false\")\n\n    def run(conn):\n        try:\n            with conn:\n                conn.execute(\"create table foo (id integer primary key)\")\n                conn.execute(\"insert into foo (id) values (44)\")\n                # Table should exist\n                assert (\n                    conn.execute(\n                        'select count(*) from sqlite_master where name = \"foo\"'\n                    ).fetchone()[0]\n                    == 1\n                )\n                assert conn.execute(\"select id from foo\").fetchall()[0][0] == 44\n                raise ValueError(\"Cancel commit\")\n        except ValueError:\n            pass\n        # Row should NOT exist\n        assert conn.execute(\"select count(*) from foo\").fetchone()[0] == 0\n\n    await db.execute_write_fn(run, transaction=False)\n\n\n@pytest.mark.parametrize(\n    \"tables,exists\",\n    (\n        ([\"facetable\", \"searchable\", \"tags\", \"searchable_tags\"], True),\n        ([\"foo\", \"bar\", \"baz\"], False),\n    ),\n)\n@pytest.mark.asyncio\nasync def test_table_exists(db, tables, exists):\n    for table in tables:\n        actual = await db.table_exists(table)\n        assert exists == actual\n\n\n@pytest.mark.parametrize(\n    \"view,expected\",\n    (\n        (\"not_a_view\", False),\n        (\"paginated_view\", True),\n    ),\n)\n@pytest.mark.asyncio\nasync def test_view_exists(db, view, expected):\n    actual = await db.view_exists(view)\n    assert actual == expected\n\n\n@pytest.mark.parametrize(\n    \"table,expected\",\n    (\n        (\n            \"facetable\",\n            [\n                \"pk\",\n                \"created\",\n                \"planet_int\",\n                \"on_earth\",\n                \"state\",\n                \"_city_id\",\n                \"_neighborhood\",\n                \"tags\",\n                \"complex_array\",\n                \"distinct_some_null\",\n                \"n\",\n            ],\n        ),\n        (\n            \"sortable\",\n            [\n                \"pk1\",\n                \"pk2\",\n                \"content\",\n                \"sortable\",\n                \"sortable_with_nulls\",\n                \"sortable_with_nulls_2\",\n                \"text\",\n            ],\n        ),\n    ),\n)\n@pytest.mark.asyncio\nasync def test_table_columns(db, table, expected):\n    columns = await db.table_columns(table)\n    assert columns == expected\n\n\n@pytest.mark.parametrize(\n    \"table,expected\",\n    (\n        (\n            \"facetable\",\n            [\n                Column(\n                    cid=0,\n                    name=\"pk\",\n                    type=\"integer\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=1,\n                    hidden=0,\n                ),\n                Column(\n                    cid=1,\n                    name=\"created\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=2,\n                    name=\"planet_int\",\n                    type=\"integer\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=3,\n                    name=\"on_earth\",\n                    type=\"integer\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=4,\n                    name=\"state\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=5,\n                    name=\"_city_id\",\n                    type=\"integer\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=6,\n                    name=\"_neighborhood\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=7,\n                    name=\"tags\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=8,\n                    name=\"complex_array\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=9,\n                    name=\"distinct_some_null\",\n                    type=\"\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=10,\n                    name=\"n\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n            ],\n        ),\n        (\n            \"sortable\",\n            [\n                Column(\n                    cid=0,\n                    name=\"pk1\",\n                    type=\"varchar(30)\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=1,\n                    hidden=0,\n                ),\n                Column(\n                    cid=1,\n                    name=\"pk2\",\n                    type=\"varchar(30)\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=2,\n                    hidden=0,\n                ),\n                Column(\n                    cid=2,\n                    name=\"content\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=3,\n                    name=\"sortable\",\n                    type=\"integer\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=4,\n                    name=\"sortable_with_nulls\",\n                    type=\"real\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=5,\n                    name=\"sortable_with_nulls_2\",\n                    type=\"real\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n                Column(\n                    cid=6,\n                    name=\"text\",\n                    type=\"text\",\n                    notnull=0,\n                    default_value=None,\n                    is_pk=0,\n                    hidden=0,\n                ),\n            ],\n        ),\n    ),\n)\n@pytest.mark.asyncio\nasync def test_table_column_details(db, table, expected):\n    columns = await db.table_column_details(table)\n    # Convert \"type\" to lowercase before comparison\n    # https://github.com/simonw/datasette/issues/1647\n    compare_columns = [\n        Column(\n            c.cid, c.name, c.type.lower(), c.notnull, c.default_value, c.is_pk, c.hidden\n        )\n        for c in columns\n    ]\n    assert compare_columns == expected\n\n\n@pytest.mark.asyncio\nasync def test_get_all_foreign_keys(db):\n    all_foreign_keys = await db.get_all_foreign_keys()\n    assert all_foreign_keys[\"roadside_attraction_characteristics\"] == {\n        \"incoming\": [],\n        \"outgoing\": [\n            {\n                \"other_table\": \"attraction_characteristic\",\n                \"column\": \"characteristic_id\",\n                \"other_column\": \"pk\",\n            },\n            {\n                \"other_table\": \"roadside_attractions\",\n                \"column\": \"attraction_id\",\n                \"other_column\": \"pk\",\n            },\n        ],\n    }\n    assert all_foreign_keys[\"attraction_characteristic\"] == {\n        \"incoming\": [\n            {\n                \"other_table\": \"roadside_attraction_characteristics\",\n                \"column\": \"pk\",\n                \"other_column\": \"characteristic_id\",\n            }\n        ],\n        \"outgoing\": [],\n    }\n    assert all_foreign_keys[\"compound_primary_key\"] == {\n        # No incoming because these are compound foreign keys, which we currently ignore\n        \"incoming\": [],\n        \"outgoing\": [],\n    }\n    assert all_foreign_keys[\"foreign_key_references\"] == {\n        \"incoming\": [],\n        \"outgoing\": [\n            {\n                \"other_table\": \"primary_key_multiple_columns\",\n                \"column\": \"foreign_key_with_no_label\",\n                \"other_column\": \"id\",\n            },\n            {\n                \"other_table\": \"simple_primary_key\",\n                \"column\": \"foreign_key_with_blank_label\",\n                \"other_column\": \"id\",\n            },\n            {\n                \"other_table\": \"simple_primary_key\",\n                \"column\": \"foreign_key_with_label\",\n                \"other_column\": \"id\",\n            },\n        ],\n    }\n\n\n@pytest.mark.asyncio\nasync def test_table_names(db):\n    table_names = await db.table_names()\n    # Tables are sorted alphabetically by name\n    assert table_names == [\n        \"123_starts_with_digits\",\n        \"Table With Space In Name\",\n        \"attraction_characteristic\",\n        \"binary_data\",\n        \"complex_foreign_keys\",\n        \"compound_primary_key\",\n        \"compound_three_primary_keys\",\n        \"custom_foreign_key_label\",\n        \"facet_cities\",\n        \"facetable\",\n        \"foreign_key_references\",\n        \"infinity\",\n        \"no_primary_key\",\n        \"primary_key_multiple_columns\",\n        \"primary_key_multiple_columns_explicit_label\",\n        \"roadside_attraction_characteristics\",\n        \"roadside_attractions\",\n        \"searchable\",\n        \"searchable_fts\",\n        \"searchable_fts_config\",\n        \"searchable_fts_data\",\n        \"searchable_fts_docsize\",\n        \"searchable_fts_idx\",\n        \"searchable_tags\",\n        \"select\",\n        \"simple_primary_key\",\n        \"sortable\",\n        \"table/with/slashes.csv\",\n        \"tags\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_view_names(db):\n    view_names = await db.view_names()\n    assert view_names == [\n        \"paginated_view\",\n        \"simple_view\",\n        \"searchable_view\",\n        \"searchable_view_configured_by_metadata\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_block_true(db):\n    await db.execute_write(\n        \"update roadside_attractions set name = ? where pk = ?\", [\"Mystery!\", 1]\n    )\n    rows = await db.execute(\"select name from roadside_attractions where pk = 1\")\n    assert \"Mystery!\" == rows.rows[0][0]\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_block_false(db):\n    await db.execute_write(\n        \"update roadside_attractions set name = ? where pk = ?\",\n        [\"Mystery!\", 1],\n    )\n    time.sleep(0.1)\n    rows = await db.execute(\"select name from roadside_attractions where pk = 1\")\n    assert \"Mystery!\" == rows.rows[0][0]\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_script(db):\n    await db.execute_write_script(\n        \"create table foo (id integer primary key); create table bar (id integer primary key);\"\n    )\n    table_names = await db.table_names()\n    assert {\"foo\", \"bar\"}.issubset(table_names)\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_many(db):\n    await db.execute_write_script(\"create table foomany (id integer primary key)\")\n    await db.execute_write_many(\n        \"insert into foomany (id) values (?)\", [(1,), (10,), (100,)]\n    )\n    result = await db.execute(\"select * from foomany\")\n    assert [r[0] for r in result.rows] == [1, 10, 100]\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_has_correctly_prepared_connection(db):\n    # The sleep() function is only available if ds._prepare_connection() was called\n    await db.execute_write(\"select sleep(0.01)\")\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_fn_block_false(db):\n    def write_fn(conn):\n        conn.execute(\"delete from roadside_attractions where pk = 1;\")\n        row = conn.execute(\"select count(*) from roadside_attractions\").fetchone()\n        return row[0]\n\n    task_id = await db.execute_write_fn(write_fn, block=False)\n    assert isinstance(task_id, uuid.UUID)\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_fn_block_true(db):\n    def write_fn(conn):\n        conn.execute(\"delete from roadside_attractions where pk = 1;\")\n        row = conn.execute(\"select count(*) from roadside_attractions\").fetchone()\n        return row[0]\n\n    new_count = await db.execute_write_fn(write_fn)\n    assert 3 == new_count\n\n\n@pytest.mark.asyncio\nasync def test_execute_write_fn_exception(db):\n    def write_fn(conn):\n        assert False\n\n    with pytest.raises(AssertionError):\n        await db.execute_write_fn(write_fn)\n\n\n@pytest.mark.asyncio\n@pytest.mark.timeout(1)\nasync def test_execute_write_fn_connection_exception(tmpdir, app_client):\n    path = str(tmpdir / \"immutable.db\")\n    sqlite3.connect(path).execute(\"vacuum\")\n    db = Database(app_client.ds, path=path, is_mutable=False)\n    app_client.ds.add_database(db, name=\"immutable-db\")\n\n    def write_fn(conn):\n        assert False\n\n    with pytest.raises(AssertionError):\n        await db.execute_write_fn(write_fn)\n\n    app_client.ds.remove_database(\"immutable-db\")\n\n\ndef table_exists(conn, name):\n    return bool(\n        conn.execute(\n            \"\"\"\n        with all_tables as (\n            select name from sqlite_master where type = 'table'\n                     union all\n            select name from temp.sqlite_master where type = 'table'\n        )\n        select 1 from all_tables where name = ?\n        \"\"\",\n            (name,),\n        ).fetchall(),\n    )\n\n\ndef table_exists_checker(name):\n    def inner(conn):\n        return table_exists(conn, name)\n\n    return inner\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"disable_threads\", (False, True))\nasync def test_execute_isolated(db, disable_threads):\n    if disable_threads:\n        ds = Datasette(memory=True, settings={\"num_sql_threads\": 0})\n        db = ds.add_database(Database(ds, memory_name=\"test_num_sql_threads_zero\"))\n\n    # Create temporary table in write\n    await db.execute_write(\n        \"create temporary table created_by_write (id integer primary key)\"\n    )\n    # Should stay visible to write connection\n    assert await db.execute_write_fn(table_exists_checker(\"created_by_write\"))\n\n    def create_shared_table(conn):\n        conn.execute(\"create table shared (id integer primary key)\")\n        # And a temporary table that should not continue to exist\n        conn.execute(\n            \"create temporary table created_by_isolated (id integer primary key)\"\n        )\n        assert table_exists(conn, \"created_by_isolated\")\n        # Also confirm that created_by_write does not exist\n        return table_exists(conn, \"created_by_write\")\n\n    # shared should not exist\n    assert not await db.execute_fn(table_exists_checker(\"shared\"))\n\n    # Create it using isolated\n    created_by_write_exists = await db.execute_isolated_fn(create_shared_table)\n    assert not created_by_write_exists\n\n    # shared SHOULD exist now\n    assert await db.execute_fn(table_exists_checker(\"shared\"))\n\n    # created_by_isolated should not exist, even in write connection\n    assert not await db.execute_write_fn(table_exists_checker(\"created_by_isolated\"))\n\n    # ... and a second call to isolated should not see that connection either\n    assert not await db.execute_isolated_fn(table_exists_checker(\"created_by_isolated\"))\n\n\n@pytest.mark.asyncio\nasync def test_mtime_ns(db):\n    assert isinstance(db.mtime_ns, int)\n\n\ndef test_mtime_ns_is_none_for_memory(app_client):\n    memory_db = Database(app_client.ds, is_memory=True)\n    assert memory_db.is_memory is True\n    assert None is memory_db.mtime_ns\n\n\ndef test_is_mutable(app_client):\n    assert Database(app_client.ds, is_memory=True).is_mutable is True\n    assert Database(app_client.ds, is_memory=True, is_mutable=True).is_mutable is True\n    assert Database(app_client.ds, is_memory=True, is_mutable=False).is_mutable is False\n\n\n@pytest.mark.asyncio\nasync def test_attached_databases(app_client_two_attached_databases_crossdb_enabled):\n    database = app_client_two_attached_databases_crossdb_enabled.ds.get_database(\n        \"_memory\"\n    )\n    attached = await database.attached_databases()\n    assert {a.name for a in attached} == {\"extra database\", \"fixtures\"}\n\n\n@pytest.mark.asyncio\nasync def test_database_memory_name(app_client):\n    ds = app_client.ds\n    foo1 = ds.add_database(Database(ds, memory_name=\"foo\"))\n    foo2 = ds.add_memory_database(\"foo\")\n    bar1 = ds.add_database(Database(ds, memory_name=\"bar\"))\n    bar2 = ds.add_memory_database(\"bar\")\n    for db in (foo1, foo2, bar1, bar2):\n        table_names = await db.table_names()\n        assert table_names == []\n    # Now create a table in foo\n    await foo1.execute_write(\"create table foo (t text)\")\n    assert await foo1.table_names() == [\"foo\"]\n    assert await foo2.table_names() == [\"foo\"]\n    assert await bar1.table_names() == []\n    assert await bar2.table_names() == []\n\n\n@pytest.mark.asyncio\nasync def test_in_memory_databases_forbid_writes(app_client):\n    ds = app_client.ds\n    db = ds.add_database(Database(ds, memory_name=\"test\"))\n    with pytest.raises(sqlite3.OperationalError):\n        await db.execute(\"create table foo (t text)\")\n    assert await db.table_names() == []\n    # Using db.execute_write() should work:\n    await db.execute_write(\"create table foo (t text)\")\n    assert await db.table_names() == [\"foo\"]\n\n\ndef pragma_table_list_supported():\n    return sqlite_version()[1] >= 37\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(\n    not pragma_table_list_supported(), reason=\"Requires PRAGMA table_list support\"\n)\nasync def test_hidden_tables(app_client):\n    ds = app_client.ds\n    db = ds.add_database(Database(ds, is_memory=True, is_mutable=True))\n    assert await db.hidden_table_names() == []\n    await db.execute(\"create virtual table f using fts5(a)\")\n    assert await db.hidden_table_names() == [\n        \"f_config\",\n        \"f_content\",\n        \"f_data\",\n        \"f_docsize\",\n        \"f_idx\",\n    ]\n\n    await db.execute(\"create virtual table r using rtree(id, amin, amax)\")\n    assert await db.hidden_table_names() == [\n        \"f_config\",\n        \"f_content\",\n        \"f_data\",\n        \"f_docsize\",\n        \"f_idx\",\n        \"r_node\",\n        \"r_parent\",\n        \"r_rowid\",\n    ]\n\n    await db.execute(\"create table _hideme(_)\")\n    assert await db.hidden_table_names() == [\n        \"_hideme\",\n        \"f_config\",\n        \"f_content\",\n        \"f_data\",\n        \"f_docsize\",\n        \"f_idx\",\n        \"r_node\",\n        \"r_parent\",\n        \"r_rowid\",\n    ]\n\n    # A fts virtual table with a content table should be hidden too\n    await db.execute(\"create virtual table f2_fts using fts5(a, content='f')\")\n    assert await db.hidden_table_names() == [\n        \"_hideme\",\n        \"f2_fts_config\",\n        \"f2_fts_data\",\n        \"f2_fts_docsize\",\n        \"f2_fts_idx\",\n        \"f_config\",\n        \"f_content\",\n        \"f_data\",\n        \"f_docsize\",\n        \"f_idx\",\n        \"r_node\",\n        \"r_parent\",\n        \"r_rowid\",\n        \"f2_fts\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_replace_database(tmpdir):\n    path1 = str(tmpdir / \"data1.db\")\n    (tmpdir / \"two\").mkdir()\n    path2 = str(tmpdir / \"two\" / \"data1.db\")\n    sqlite3.connect(path1).executescript(\"\"\"\n        create table t (id integer primary key);\n        insert into t (id) values (1);\n        insert into t (id) values (2);\n    \"\"\")\n    sqlite3.connect(path2).executescript(\"\"\"\n        create table t (id integer primary key);\n        insert into t (id) values (1);\n    \"\"\")\n    datasette = Datasette([path1])\n    db = datasette.get_database(\"data1\")\n    count = (await db.execute(\"select count(*) from t\")).first()[0]\n    assert count == 2\n    # Now replace that database\n    datasette.get_database(\"data1\").close()\n    datasette.remove_database(\"data1\")\n    datasette.add_database(Database(datasette, path2), \"data1\")\n    db2 = datasette.get_database(\"data1\")\n    count = (await db2.execute(\"select count(*) from t\")).first()[0]\n    assert count == 1\n"
  },
  {
    "path": "tests/test_internals_datasette.py",
    "content": "\"\"\"\nTests for the datasette.app.Datasette class\n\"\"\"\n\nimport dataclasses\nfrom datasette import Context\nfrom datasette.app import Datasette, Database, ResourcesSQL\nfrom datasette.resources import DatabaseResource\nfrom itsdangerous import BadSignature\nimport pytest\n\n\n@pytest.fixture\ndef datasette(ds_client):\n    return ds_client.ds\n\n\ndef test_get_database(datasette):\n    db = datasette.get_database(\"fixtures\")\n    assert \"fixtures\" == db.name\n    with pytest.raises(KeyError):\n        datasette.get_database(\"missing\")\n\n\ndef test_get_database_no_argument(datasette):\n    # Returns the first available database:\n    db = datasette.get_database()\n    assert \"fixtures\" == db.name\n\n\n@pytest.mark.parametrize(\"value\", [\"hello\", 123, {\"key\": \"value\"}])\n@pytest.mark.parametrize(\"namespace\", [None, \"two\"])\ndef test_sign_unsign(datasette, value, namespace):\n    extra_args = [namespace] if namespace else []\n    signed = datasette.sign(value, *extra_args)\n    assert value != signed\n    assert value == datasette.unsign(signed, *extra_args)\n    with pytest.raises(BadSignature):\n        datasette.unsign(signed[:-1] + (\"!\" if signed[-1] != \"!\" else \":\"))\n\n\n@pytest.mark.parametrize(\n    \"setting,expected\",\n    (\n        (\"base_url\", \"/\"),\n        (\"max_csv_mb\", 100),\n        (\"allow_csv_stream\", True),\n    ),\n)\ndef test_datasette_setting(datasette, setting, expected):\n    assert datasette.setting(setting) == expected\n\n\n@pytest.mark.asyncio\nasync def test_datasette_constructor():\n    ds = Datasette()\n    databases = (await ds.client.get(\"/-/databases.json\")).json()\n    assert databases == [\n        {\n            \"name\": \"_memory\",\n            \"route\": \"_memory\",\n            \"path\": None,\n            \"size\": 0,\n            \"is_mutable\": False,\n            \"is_memory\": True,\n            \"hash\": None,\n        }\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_num_sql_threads_zero():\n    ds = Datasette([], memory=True, settings={\"num_sql_threads\": 0})\n    db = ds.add_database(Database(ds, memory_name=\"test_num_sql_threads_zero\"))\n    await db.execute_write(\"create table t(id integer primary key)\")\n    await db.execute_write(\"insert into t (id) values (1)\")\n    response = await ds.client.get(\"/-/threads.json\")\n    assert response.json() == {\"num_threads\": 0, \"threads\": []}\n    response2 = await ds.client.get(\"/test_num_sql_threads_zero/t.json?_shape=array\")\n    assert response2.json() == [{\"id\": 1}]\n\n\nROOT = {\"id\": \"root\"}\nALLOW_ROOT = {\"allow\": {\"id\": \"root\"}}\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"actor,config,action,resource,should_allow,expected_private\",\n    (\n        (None, ALLOW_ROOT, \"view-instance\", None, False, False),\n        (ROOT, ALLOW_ROOT, \"view-instance\", None, True, True),\n        (\n            None,\n            {\"databases\": {\"_memory\": ALLOW_ROOT}},\n            \"view-database\",\n            DatabaseResource(database=\"_memory\"),\n            False,\n            False,\n        ),\n        (\n            ROOT,\n            {\"databases\": {\"_memory\": ALLOW_ROOT}},\n            \"view-database\",\n            DatabaseResource(database=\"_memory\"),\n            True,\n            True,\n        ),\n        # Check private is false for non-protected instance check\n        (\n            ROOT,\n            {\"allow\": True},\n            \"view-instance\",\n            None,\n            True,\n            False,\n        ),\n    ),\n)\nasync def test_datasette_check_visibility(\n    actor, config, action, resource, should_allow, expected_private\n):\n    ds = Datasette([], memory=True, config=config)\n    await ds.invoke_startup()\n    visible, private = await ds.check_visibility(\n        actor, action=action, resource=resource\n    )\n    assert visible == should_allow\n    assert private == expected_private\n\n\n@pytest.mark.asyncio\nasync def test_datasette_render_template_no_request():\n    # https://github.com/simonw/datasette/issues/1849\n    ds = Datasette(memory=True)\n    await ds.invoke_startup()\n    rendered = await ds.render_template(\"error.html\")\n    assert \"Error \" in rendered\n\n\n@pytest.mark.asyncio\nasync def test_datasette_render_template_with_dataclass():\n    @dataclasses.dataclass\n    class ExampleContext(Context):\n        title: str\n        status: int\n        error: str\n\n    context = ExampleContext(title=\"Hello\", status=200, error=\"Error message\")\n    ds = Datasette(memory=True)\n    await ds.invoke_startup()\n    rendered = await ds.render_template(\"error.html\", context)\n    assert \"<h1>Hello</h1>\" in rendered\n    assert \"Error message\" in rendered\n\n\ndef test_datasette_error_if_string_not_list(tmpdir):\n    # https://github.com/simonw/datasette/issues/1985\n    db_path = str(tmpdir / \"data.db\")\n    with pytest.raises(ValueError):\n        Datasette(db_path)\n\n\n@pytest.mark.asyncio\nasync def test_get_action(ds_client):\n    ds = ds_client.ds\n    for name_or_abbr in (\n        \"vi\",\n        \"view-instance\",\n        \"vt\",\n        \"view-table\",\n        \"sct\",\n        \"set-column-type\",\n    ):\n        action = ds.get_action(name_or_abbr)\n        if \"-\" in name_or_abbr:\n            assert action.name == name_or_abbr\n        else:\n            assert action.abbr == name_or_abbr\n    # And test None return for missing action\n    assert ds.get_action(\"missing-permission\") is None\n\n\n@pytest.mark.asyncio\nasync def test_apply_metadata_json():\n    ds = Datasette(\n        metadata={\n            \"databases\": {\n                \"legislators\": {\n                    \"tables\": {\"offices\": {\"summary\": \"office address or sumtin\"}},\n                    \"queries\": {\n                        \"millennial_representatives\": {\n                            \"summary\": \"Social media accounts for current legislators\"\n                        }\n                    },\n                }\n            },\n            \"weird_instance_value\": {\"nested\": [1, 2, 3]},\n        },\n    )\n    await ds.invoke_startup()\n    assert (await ds.client.get(\"/\")).status_code == 200\n    value = (await ds.get_instance_metadata()).get(\"weird_instance_value\")\n    assert value == '{\"nested\": [1, 2, 3]}'\n\n\n@pytest.mark.asyncio\nasync def test_allowed_resources_sql(datasette):\n    result = await datasette.allowed_resources_sql(\n        action=\"view-table\",\n        actor=None,\n    )\n    assert isinstance(result, ResourcesSQL)\n    assert \"all_rules AS\" in result.sql\n    assert result.params[\"action\"] == \"view-table\"\n"
  },
  {
    "path": "tests/test_internals_datasette_client.py",
    "content": "import httpx\nimport pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\n\n\n@pytest_asyncio.fixture\nasync def datasette(ds_client):\n    await ds_client.ds.invoke_startup()\n    return ds_client.ds\n\n\n@pytest_asyncio.fixture\nasync def datasette_with_permissions():\n    \"\"\"A datasette instance with permission restrictions for testing\"\"\"\n    ds = Datasette(config={\"databases\": {\"test_db\": {\"allow\": {\"id\": \"admin\"}}}})\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_datasette_with_permissions\", name=\"test_db\")\n    await db.execute_write(\n        \"create table if not exists test_table (id integer primary key, name text)\"\n    )\n    await db.execute_write(\n        \"insert or ignore into test_table (id, name) values (1, 'Alice')\"\n    )\n    # Trigger catalog refresh\n    await ds.client.get(\"/\")\n    return ds\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"method,path,expected_status\",\n    [\n        (\"get\", \"/\", 200),\n        (\"options\", \"/\", 200),\n        (\"head\", \"/\", 200),\n        (\"put\", \"/\", 405),\n        (\"patch\", \"/\", 405),\n        (\"delete\", \"/\", 405),\n    ],\n)\nasync def test_client_methods(datasette, method, path, expected_status):\n    client_method = getattr(datasette.client, method)\n    response = await client_method(path)\n    assert isinstance(response, httpx.Response)\n    assert response.status_code == expected_status\n    # Try that again using datasette.client.request\n    response2 = await datasette.client.request(method, path)\n    assert response2.status_code == expected_status\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"prefix\", [None, \"/prefix/\"])\nasync def test_client_post(datasette, prefix):\n    original_base_url = datasette._settings[\"base_url\"]\n    try:\n        if prefix is not None:\n            datasette._settings[\"base_url\"] = prefix\n        response = await datasette.client.post(\n            \"/-/messages\",\n            data={\n                \"message\": \"A message\",\n            },\n        )\n        assert isinstance(response, httpx.Response)\n        assert response.status_code == 302\n        assert \"ds_messages\" in response.cookies\n    finally:\n        datasette._settings[\"base_url\"] = original_base_url\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"prefix,expected_path\", [(None, \"/asgi-scope\"), (\"/prefix/\", \"/prefix/asgi-scope\")]\n)\nasync def test_client_path(datasette, prefix, expected_path):\n    original_base_url = datasette._settings[\"base_url\"]\n    try:\n        if prefix is not None:\n            datasette._settings[\"base_url\"] = prefix\n        response = await datasette.client.get(\"/asgi-scope\")\n        path = response.json()[\"path\"]\n        assert path == expected_path\n    finally:\n        datasette._settings[\"base_url\"] = original_base_url\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_allows_forbidden_access(\n    datasette_with_permissions,\n):\n    \"\"\"Test that skip_permission_checks=True bypasses permission checks\"\"\"\n    ds = datasette_with_permissions\n\n    # Without skip_permission_checks, anonymous user should get 403 for protected database\n    response = await ds.client.get(\"/test_db.json\")\n    assert response.status_code == 403\n\n    # With skip_permission_checks=True, should get 200\n    response = await ds.client.get(\"/test_db.json\", skip_permission_checks=True)\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"database\"] == \"test_db\"\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_on_table(datasette_with_permissions):\n    \"\"\"Test skip_permission_checks works for table access\"\"\"\n    ds = datasette_with_permissions\n\n    # Without skip_permission_checks, should get 403\n    response = await ds.client.get(\"/test_db/test_table.json\")\n    assert response.status_code == 403\n\n    # With skip_permission_checks=True, should get table data\n    response = await ds.client.get(\n        \"/test_db/test_table.json\", skip_permission_checks=True\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"rows\"] == [{\"id\": 1, \"name\": \"Alice\"}]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"method\", [\"get\", \"post\", \"put\", \"patch\", \"delete\", \"options\", \"head\"]\n)\nasync def test_skip_permission_checks_all_methods(datasette_with_permissions, method):\n    \"\"\"Test that skip_permission_checks works with all HTTP methods\"\"\"\n    ds = datasette_with_permissions\n\n    # All methods should work with skip_permission_checks=True\n    client_method = getattr(ds.client, method)\n    response = await client_method(\"/test_db.json\", skip_permission_checks=True)\n    # We don't check status code since some methods might not be allowed,\n    # but we verify the request doesn't fail due to permissions\n    assert isinstance(response, httpx.Response)\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_request_method(datasette_with_permissions):\n    \"\"\"Test that skip_permission_checks works with client.request()\"\"\"\n    ds = datasette_with_permissions\n\n    # Without skip_permission_checks\n    response = await ds.client.request(\"GET\", \"/test_db.json\")\n    assert response.status_code == 403\n\n    # With skip_permission_checks=True\n    response = await ds.client.request(\n        \"GET\", \"/test_db.json\", skip_permission_checks=True\n    )\n    assert response.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_isolated_to_request(datasette_with_permissions):\n    \"\"\"Test that skip_permission_checks doesn't affect other concurrent requests\"\"\"\n    ds = datasette_with_permissions\n\n    # First request with skip_permission_checks=True should succeed\n    response1 = await ds.client.get(\"/test_db.json\", skip_permission_checks=True)\n    assert response1.status_code == 200\n\n    # Subsequent request without it should still get 403\n    response2 = await ds.client.get(\"/test_db.json\")\n    assert response2.status_code == 403\n\n    # And another with skip should succeed again\n    response3 = await ds.client.get(\"/test_db.json\", skip_permission_checks=True)\n    assert response3.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_with_admin_actor(datasette_with_permissions):\n    \"\"\"Test that skip_permission_checks works even when actor is provided\"\"\"\n    ds = datasette_with_permissions\n\n    # Admin actor should normally have access\n    admin_cookies = {\"ds_actor\": ds.client.actor_cookie({\"id\": \"admin\"})}\n    response = await ds.client.get(\"/test_db.json\", cookies=admin_cookies)\n    assert response.status_code == 200\n\n    # Non-admin actor should get 403\n    user_cookies = {\"ds_actor\": ds.client.actor_cookie({\"id\": \"user\"})}\n    response = await ds.client.get(\"/test_db.json\", cookies=user_cookies)\n    assert response.status_code == 403\n\n    # Non-admin actor with skip_permission_checks=True should get 200\n    response = await ds.client.get(\n        \"/test_db.json\", cookies=user_cookies, skip_permission_checks=True\n    )\n    assert response.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_skip_permission_checks_shows_denied_tables():\n    \"\"\"Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json\"\"\"\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"fixtures\": {\"allow\": False}  # Deny all access to this database\n            }\n        }\n    )\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"fixtures\")\n    await db.execute_write(\n        \"CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)\"\n    )\n    await db.execute_write(\"INSERT INTO test_table (id, name) VALUES (1, 'Alice')\")\n    await ds._refresh_schemas()\n\n    # Without skip_permission_checks, tables from denied database should not appear in /-/tables.json\n    response = await ds.client.get(\"/-/tables.json\")\n    assert response.status_code == 200\n    data = response.json()\n    table_names = [match[\"name\"] for match in data[\"matches\"]]\n    # Should not see any fixtures tables since access is denied\n    fixtures_tables = [name for name in table_names if name.startswith(\"fixtures:\")]\n    assert len(fixtures_tables) == 0\n\n    # With skip_permission_checks=True, tables from denied database SHOULD appear\n    response = await ds.client.get(\"/-/tables.json\", skip_permission_checks=True)\n    assert response.status_code == 200\n    data = response.json()\n    table_names = [match[\"name\"] for match in data[\"matches\"]]\n    # Should see fixtures tables when permission checks are skipped\n    assert \"fixtures: test_table\" in table_names\n\n\n@pytest.mark.asyncio\nasync def test_in_client_returns_false_outside_request(datasette):\n    \"\"\"Test that datasette.in_client() returns False outside of a client request\"\"\"\n    assert datasette.in_client() is False\n\n\n@pytest.mark.asyncio\nasync def test_in_client_returns_true_inside_request():\n    \"\"\"Test that datasette.in_client() returns True inside a client request\"\"\"\n    from datasette import hookimpl, Response\n\n    class TestPlugin:\n        __name__ = \"test_in_client_plugin\"\n\n        @hookimpl\n        def register_routes(self):\n            async def test_view(datasette):\n                # Assert in_client() returns True within the view\n                assert datasette.in_client() is True\n                return Response.json({\"in_client\": datasette.in_client()})\n\n            return [\n                (r\"^/-/test-in-client$\", test_view),\n            ]\n\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.pm.register(TestPlugin(), name=\"test_in_client_plugin\")\n    try:\n\n        # Outside of a client request, should be False\n        assert ds.in_client() is False\n\n        # Make a request via datasette.client\n        response = await ds.client.get(\"/-/test-in-client\")\n        assert response.status_code == 200\n        assert response.json()[\"in_client\"] is True\n\n        # After the request, should be False again\n        assert ds.in_client() is False\n    finally:\n        ds.pm.unregister(name=\"test_in_client_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_in_client_with_skip_permission_checks():\n    \"\"\"Test that in_client() works regardless of skip_permission_checks value\"\"\"\n    from datasette import hookimpl\n    from datasette.utils.asgi import Response\n\n    in_client_values = []\n\n    class TestPlugin:\n        __name__ = \"test_in_client_skip_plugin\"\n\n        @hookimpl\n        def register_routes(self):\n            async def test_view(datasette):\n                in_client_values.append(datasette.in_client())\n                return Response.json({\"in_client\": datasette.in_client()})\n\n            return [\n                (r\"^/-/test-in-client$\", test_view),\n            ]\n\n    ds = Datasette(config={\"databases\": {\"test_db\": {\"allow\": {\"id\": \"admin\"}}}})\n    await ds.invoke_startup()\n    ds.pm.register(TestPlugin(), name=\"test_in_client_skip_plugin\")\n    try:\n\n        # Request without skip_permission_checks\n        await ds.client.get(\"/-/test-in-client\")\n        # Request with skip_permission_checks=True\n        await ds.client.get(\"/-/test-in-client\", skip_permission_checks=True)\n\n        # Both should have detected in_client as True\n        assert (\n            len(in_client_values) == 2\n        ), f\"Expected 2 values, got {len(in_client_values)}\"\n        assert all(in_client_values), f\"Expected all True, got {in_client_values}\"\n    finally:\n        ds.pm.unregister(name=\"test_in_client_skip_plugin\")\n"
  },
  {
    "path": "tests/test_internals_request.py",
    "content": "from datasette.utils.asgi import Request\nimport json\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_request_post_vars():\n    scope = {\n        \"http_version\": \"1.1\",\n        \"method\": \"POST\",\n        \"path\": \"/\",\n        \"raw_path\": b\"/\",\n        \"query_string\": b\"\",\n        \"scheme\": \"http\",\n        \"type\": \"http\",\n        \"headers\": [[b\"content-type\", b\"application/x-www-form-urlencoded\"]],\n    }\n\n    async def receive():\n        return {\n            \"type\": \"http.request\",\n            \"body\": b\"foo=bar&baz=1&empty=\",\n            \"more_body\": False,\n        }\n\n    request = Request(scope, receive)\n    assert {\"foo\": \"bar\", \"baz\": \"1\", \"empty\": \"\"} == await request.post_vars()\n\n\n@pytest.mark.asyncio\nasync def test_request_post_body():\n    scope = {\n        \"http_version\": \"1.1\",\n        \"method\": \"POST\",\n        \"path\": \"/\",\n        \"raw_path\": b\"/\",\n        \"query_string\": b\"\",\n        \"scheme\": \"http\",\n        \"type\": \"http\",\n        \"headers\": [[b\"content-type\", b\"application/json\"]],\n    }\n\n    data = {\"hello\": \"world\"}\n\n    async def receive():\n        return {\n            \"type\": \"http.request\",\n            \"body\": json.dumps(data, indent=4).encode(\"utf-8\"),\n            \"more_body\": False,\n        }\n\n    request = Request(scope, receive)\n    body = await request.post_body()\n    assert isinstance(body, bytes)\n    assert data == json.loads(body)\n\n\ndef test_request_args():\n    request = Request.fake(\"/foo?multi=1&multi=2&single=3\")\n    assert \"1\" == request.args.get(\"multi\")\n    assert \"3\" == request.args.get(\"single\")\n    assert \"1\" == request.args[\"multi\"]\n    assert \"3\" == request.args[\"single\"]\n    assert [\"1\", \"2\"] == request.args.getlist(\"multi\")\n    assert [] == request.args.getlist(\"missing\")\n    assert \"multi\" in request.args\n    assert \"single\" in request.args\n    assert \"missing\" not in request.args\n    expected = [\"multi\", \"single\"]\n    assert expected == list(request.args.keys())\n    for i, key in enumerate(request.args):\n        assert expected[i] == key\n    assert 2 == len(request.args)\n    with pytest.raises(KeyError):\n        request.args[\"missing\"]\n\n\ndef test_request_fake_url_vars():\n    request = Request.fake(\"/\")\n    assert request.url_vars == {}\n    request = Request.fake(\"/\", url_vars={\"database\": \"fixtures\"})\n    assert request.url_vars == {\"database\": \"fixtures\"}\n\n\ndef test_request_repr():\n    request = Request.fake(\"/foo?multi=1&multi=2&single=3\")\n    assert (\n        repr(request)\n        == '<asgi.Request method=\"GET\" url=\"http://localhost/foo?multi=1&multi=2&single=3\">'\n    )\n\n\ndef test_request_url_vars():\n    scope = {\n        \"http_version\": \"1.1\",\n        \"method\": \"POST\",\n        \"path\": \"/\",\n        \"raw_path\": b\"/\",\n        \"query_string\": b\"\",\n        \"scheme\": \"http\",\n        \"type\": \"http\",\n        \"headers\": [[b\"content-type\", b\"application/x-www-form-urlencoded\"]],\n    }\n    assert {} == Request(scope, None).url_vars\n    assert {\"name\": \"cleo\"} == Request(\n        dict(scope, url_route={\"kwargs\": {\"name\": \"cleo\"}}), None\n    ).url_vars\n\n\n@pytest.mark.parametrize(\n    \"path,query_string,expected_full_path\",\n    [(\"/\", \"\", \"/\"), (\"/\", \"foo=bar\", \"/?foo=bar\"), (\"/foo\", \"bar\", \"/foo?bar\")],\n)\ndef test_request_properties(path, query_string, expected_full_path):\n    path_with_query_string = path\n    if query_string:\n        path_with_query_string += \"?\" + query_string\n    scope = {\n        \"http_version\": \"1.1\",\n        \"method\": \"POST\",\n        \"path\": path,\n        \"raw_path\": path_with_query_string.encode(\"latin-1\"),\n        \"query_string\": query_string.encode(\"latin-1\"),\n        \"scheme\": \"http\",\n        \"type\": \"http\",\n    }\n    request = Request(scope, None)\n    assert request.path == path\n    assert request.query_string == query_string\n    assert request.full_path == expected_full_path\n\n\ndef test_request_blank_values():\n    request = Request.fake(\"/?a=b&foo=bar&foo=bar2&baz=\")\n    assert request.args._data == {\"a\": [\"b\"], \"foo\": [\"bar\", \"bar2\"], \"baz\": [\"\"]}\n\n\ndef test_json_in_query_string_name():\n    query_string = (\n        '?_through.[\"roadside_attraction_characteristics\"%2C\"characteristic_id\"]=1'\n    )\n    request = Request.fake(\"/\" + query_string)\n    assert (\n        request.args[\n            '_through.[\"roadside_attraction_characteristics\",\"characteristic_id\"]'\n        ]\n        == \"1\"\n    )\n"
  },
  {
    "path": "tests/test_internals_response.py",
    "content": "from datasette.utils.asgi import Response\nimport pytest\n\n\ndef test_response_html():\n    response = Response.html(\"Hello from HTML\")\n    assert 200 == response.status\n    assert \"Hello from HTML\" == response.body\n    assert \"text/html; charset=utf-8\" == response.content_type\n\n\ndef test_response_text():\n    response = Response.text(\"Hello from text\")\n    assert 200 == response.status\n    assert \"Hello from text\" == response.body\n    assert \"text/plain; charset=utf-8\" == response.content_type\n\n\ndef test_response_json():\n    response = Response.json({\"this_is\": \"json\"})\n    assert 200 == response.status\n    assert '{\"this_is\": \"json\"}' == response.body\n    assert \"application/json; charset=utf-8\" == response.content_type\n\n\ndef test_response_redirect():\n    response = Response.redirect(\"/foo\")\n    assert 302 == response.status\n    assert \"/foo\" == response.headers[\"Location\"]\n\n\n@pytest.mark.asyncio\nasync def test_response_set_cookie():\n    events = []\n\n    async def send(event):\n        events.append(event)\n\n    response = Response.redirect(\"/foo\")\n    response.set_cookie(\"foo\", \"bar\", max_age=10, httponly=True)\n    await response.asgi_send(send)\n\n    assert [\n        {\n            \"type\": \"http.response.start\",\n            \"status\": 302,\n            \"headers\": [\n                [b\"Location\", b\"/foo\"],\n                [b\"content-type\", b\"text/plain\"],\n                [b\"set-cookie\", b\"foo=bar; HttpOnly; Max-Age=10; Path=/; SameSite=lax\"],\n            ],\n        },\n        {\"type\": \"http.response.body\", \"body\": b\"\"},\n    ] == events\n"
  },
  {
    "path": "tests/test_internals_urls.py",
    "content": "from datasette.app import Datasette\nfrom datasette.utils import PrefixedUrlString\nimport pytest\n\n\n@pytest.fixture(scope=\"module\")\ndef ds():\n    return Datasette([], memory=True)\n\n\n@pytest.mark.parametrize(\n    \"base_url,path,expected\",\n    [\n        (\"/\", \"/\", \"/\"),\n        (\"/\", \"/foo\", \"/foo\"),\n        (\"/prefix/\", \"/\", \"/prefix/\"),\n        (\"/prefix/\", \"/foo\", \"/prefix/foo\"),\n        (\"/prefix/\", \"foo\", \"/prefix/foo\"),\n    ],\n)\ndef test_path(ds, base_url, path, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.path(path)\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\ndef test_path_applied_twice_does_not_double_prefix(ds):\n    ds._settings[\"base_url\"] = \"/prefix/\"\n    path = ds.urls.path(\"/\")\n    assert path == \"/prefix/\"\n    path = ds.urls.path(path)\n    assert path == \"/prefix/\"\n\n\n@pytest.mark.parametrize(\n    \"base_url,expected\",\n    [\n        (\"/\", \"/\"),\n        (\"/prefix/\", \"/prefix/\"),\n    ],\n)\ndef test_instance(ds, base_url, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.instance()\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,file,expected\",\n    [\n        (\"/\", \"foo.js\", \"/-/static/foo.js\"),\n        (\"/prefix/\", \"foo.js\", \"/prefix/-/static/foo.js\"),\n    ],\n)\ndef test_static(ds, base_url, file, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.static(file)\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,plugin,file,expected\",\n    [\n        (\n            \"/\",\n            \"datasette_cluster_map\",\n            \"datasette-cluster-map.js\",\n            \"/-/static-plugins/datasette_cluster_map/datasette-cluster-map.js\",\n        ),\n        (\n            \"/prefix/\",\n            \"datasette_cluster_map\",\n            \"datasette-cluster-map.js\",\n            \"/prefix/-/static-plugins/datasette_cluster_map/datasette-cluster-map.js\",\n        ),\n    ],\n)\ndef test_static_plugins(ds, base_url, plugin, file, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.static_plugins(plugin, file)\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,expected\",\n    [\n        (\"/\", \"/-/logout\"),\n        (\"/prefix/\", \"/prefix/-/logout\"),\n    ],\n)\ndef test_logout(ds, base_url, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.logout()\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,format,expected\",\n    [\n        (\"/\", None, \"/_memory\"),\n        (\"/prefix/\", None, \"/prefix/_memory\"),\n        (\"/\", \"json\", \"/_memory.json\"),\n    ],\n)\ndef test_database(ds, base_url, format, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.database(\"_memory\", format=format)\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,name,format,expected\",\n    [\n        (\"/\", \"name\", None, \"/_memory/name\"),\n        (\"/prefix/\", \"name\", None, \"/prefix/_memory/name\"),\n        (\"/\", \"name\", \"json\", \"/_memory/name.json\"),\n        (\"/\", \"name.json\", \"json\", \"/_memory/name~2Ejson.json\"),\n    ],\n)\ndef test_table_and_query(ds, base_url, name, format, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual1 = ds.urls.table(\"_memory\", name, format=format)\n    assert actual1 == expected\n    assert isinstance(actual1, PrefixedUrlString)\n    actual2 = ds.urls.query(\"_memory\", name, format=format)\n    assert actual2 == expected\n    assert isinstance(actual2, PrefixedUrlString)\n\n\n@pytest.mark.parametrize(\n    \"base_url,format,expected\",\n    [\n        (\"/\", None, \"/_memory/facetable/1\"),\n        (\"/prefix/\", None, \"/prefix/_memory/facetable/1\"),\n        (\"/\", \"json\", \"/_memory/facetable/1.json\"),\n    ],\n)\ndef test_row(ds, base_url, format, expected):\n    ds._settings[\"base_url\"] = base_url\n    actual = ds.urls.row(\"_memory\", \"facetable\", \"1\", format=format)\n    assert actual == expected\n    assert isinstance(actual, PrefixedUrlString)\n"
  },
  {
    "path": "tests/test_label_column_for_table.py",
    "content": "import pytest\nfrom datasette.database import Database\nfrom datasette.app import Datasette\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"create_sql,table_name,config,expected_label_column\",\n    [\n        # Explicit label_column\n        (\n            \"create table t1 (id integer primary key, name text, title text);\",\n            \"t1\",\n            {\"t1\": {\"label_column\": \"title\"}},\n            \"title\",\n        ),\n        # Single unique text column\n        (\n            \"create table t2 (id integer primary key, name2 text unique, title text);\",\n            \"t2\",\n            {},\n            \"name2\",\n        ),\n        (\n            \"create table t3 (id integer primary key, title2 text unique, name text);\",\n            \"t3\",\n            {},\n            \"title2\",\n        ),\n        # Two unique text columns means it cannot decide on one\n        (\n            \"create table t3x (id integer primary key, name2 text unique, title2 text unique);\",\n            \"t3x\",\n            {},\n            None,\n        ),\n        # Name or title column\n        (\n            \"create table t4 (id integer primary key, name text);\",\n            \"t4\",\n            {},\n            \"name\",\n        ),\n        (\n            \"create table t5 (id integer primary key, title text);\",\n            \"t5\",\n            {},\n            \"title\",\n        ),\n        # But not if there are multiple non-unique text that are not called title\n        (\n            \"create table t5x (id integer primary key, other1 text, other2 text);\",\n            \"t5x\",\n            {},\n            None,\n        ),\n        (\n            \"create table t6 (id integer primary key, Name text);\",\n            \"t6\",\n            {},\n            \"Name\",\n        ),\n        (\n            \"create table t7 (id integer primary key, Title text);\",\n            \"t7\",\n            {},\n            \"Title\",\n        ),\n        # Two columns, one of which is id\n        (\n            \"create table t8 (id integer primary key, content text);\",\n            \"t8\",\n            {},\n            \"content\",\n        ),\n        (\n            \"create table t9 (pk integer primary key, content text);\",\n            \"t9\",\n            {},\n            \"content\",\n        ),\n    ],\n)\nasync def test_label_column_for_table(\n    create_sql, table_name, config, expected_label_column\n):\n    \"\"\"Test cases for label_column_for_table method\"\"\"\n    ds = Datasette()\n    db = ds.add_database(Database(ds, memory_name=\"test_label_column_for_table\"))\n    await db.execute_write_script(create_sql)\n    if config:\n        ds.config = {\"databases\": {\"test_label_column_for_table\": {\"tables\": config}}}\n    actual_label_column = await db.label_column_for_table(table_name)\n    if expected_label_column is None:\n        assert actual_label_column is None\n    else:\n        assert actual_label_column == expected_label_column\n"
  },
  {
    "path": "tests/test_load_extensions.py",
    "content": "from datasette.app import Datasette\nimport pytest\nfrom pathlib import Path\n\n# not necessarily a full path - the full compiled path looks like \"ext.dylib\"\n# or another suffix, but sqlite will, under the hood, decide which file\n# extension to use based on the operating system (apple=dylib, windows=dll etc)\n# this resolves to \"./ext\", which is enough for SQLite to calculate the rest\nCOMPILED_EXTENSION_PATH = str(Path(__file__).parent / \"ext\")\n\n\n# See if ext.c has been compiled, based off the different possible suffixes.\ndef has_compiled_ext():\n    for ext in [\"dylib\", \"so\", \"dll\"]:\n        path = Path(__file__).parent / f\"ext.{ext}\"\n        if path.is_file():\n            return True\n    return False\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not has_compiled_ext(), reason=\"Requires compiled ext.c\")\nasync def test_load_extension_default_entrypoint():\n    # The default entrypoint only loads a() and NOT b() or c(), so those\n    # should fail.\n    ds = Datasette(sqlite_extensions=[COMPILED_EXTENSION_PATH])\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+a()\")\n    assert response.status_code == 200\n    assert response.json()[\"rows\"][0][0] == \"a\"\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+b()\")\n    assert response.status_code == 400\n    assert response.json()[\"error\"] == \"no such function: b\"\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+c()\")\n    assert response.status_code == 400\n    assert response.json()[\"error\"] == \"no such function: c\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not has_compiled_ext(), reason=\"Requires compiled ext.c\")\nasync def test_load_extension_multiple_entrypoints():\n    # Load in the default entrypoint and the other 2 custom entrypoints, now\n    # all a(), b(), and c() should run successfully.\n    ds = Datasette(\n        sqlite_extensions=[\n            COMPILED_EXTENSION_PATH,\n            (COMPILED_EXTENSION_PATH, \"sqlite3_ext_b_init\"),\n            (COMPILED_EXTENSION_PATH, \"sqlite3_ext_c_init\"),\n        ]\n    )\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+a()\")\n    assert response.status_code == 200\n    assert response.json()[\"rows\"][0][0] == \"a\"\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+b()\")\n    assert response.status_code == 200\n    assert response.json()[\"rows\"][0][0] == \"b\"\n\n    response = await ds.client.get(\"/_memory/-/query.json?_shape=arrays&sql=select+c()\")\n    assert response.status_code == 200\n    assert response.json()[\"rows\"][0][0] == \"c\"\n"
  },
  {
    "path": "tests/test_messages.py",
    "content": "from .utils import cookie_was_deleted\nimport pytest\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"qs,expected\",\n    [\n        (\"add_msg=added-message\", [[\"added-message\", 1]]),\n        (\"add_msg=added-warning&type=WARNING\", [[\"added-warning\", 2]]),\n        (\"add_msg=added-error&type=ERROR\", [[\"added-error\", 3]]),\n    ],\n)\nasync def test_add_message_sets_cookie(ds_client, qs, expected):\n    response = await ds_client.get(f\"/fixtures/-/query.message?sql=select+1&{qs}\")\n    signed = response.cookies[\"ds_messages\"]\n    decoded = ds_client.ds.unsign(signed, \"messages\")\n    assert expected == decoded\n\n\n@pytest.mark.asyncio\nasync def test_messages_are_displayed_and_cleared(ds_client):\n    # First set the message cookie\n    set_msg_response = await ds_client.get(\n        \"/fixtures/-/query.message?sql=select+1&add_msg=xmessagex\"\n    )\n    # Now access a page that displays messages\n    response = await ds_client.get(\"/\", cookies=set_msg_response.cookies)\n    # Messages should be in that HTML\n    assert \"xmessagex\" in response.text\n    # Cookie should have been set that clears messages\n    assert cookie_was_deleted(response, \"ds_messages\")\n"
  },
  {
    "path": "tests/test_multipart.py",
    "content": "\"\"\"\nTests for request.form() multipart form data parsing.\n\nUses TDD approach - these tests are written first, then implementation follows.\n\"\"\"\n\nimport base64\nimport json\nimport pytest\nfrom collections import namedtuple\n\nfrom multipart_form_data_conformance import get_tests_dir\n\nfrom datasette.utils.asgi import Request, BadRequest\n\n\ndef make_receive(body: bytes):\n    \"\"\"Create an async receive callable that yields body in chunks.\"\"\"\n    consumed = False\n\n    async def receive():\n        nonlocal consumed\n        if consumed:\n            return {\"type\": \"http.request\", \"body\": b\"\", \"more_body\": False}\n        consumed = True\n        return {\"type\": \"http.request\", \"body\": body, \"more_body\": False}\n\n    return receive\n\n\ndef make_chunked_receive(body: bytes, chunk_size: int = 64):\n    \"\"\"Create an async receive callable that yields body in small chunks.\"\"\"\n    offset = 0\n\n    async def receive():\n        nonlocal offset\n        chunk = body[offset : offset + chunk_size]\n        offset += chunk_size\n        more_body = offset < len(body)\n        return {\"type\": \"http.request\", \"body\": chunk, \"more_body\": more_body}\n\n    return receive\n\n\ndef make_receive_with_noise(body: bytes):\n    \"\"\"\n    Create an async receive callable that includes an unexpected ASGI message.\n\n    The parser should ignore the unknown message type and continue.\n    \"\"\"\n    messages = [\n        {\"type\": \"http.response.start\", \"status\": 200, \"headers\": []},\n        {\"type\": \"http.request\", \"body\": body, \"more_body\": False},\n    ]\n    index = 0\n\n    async def receive():\n        nonlocal index\n        if index >= len(messages):\n            return {\"type\": \"http.request\", \"body\": b\"\", \"more_body\": False}\n        message = messages[index]\n        index += 1\n        return message\n\n    return receive\n\n\ndef make_disconnect_receive(body: bytes, chunk_size: int = 64):\n    \"\"\"\n    Create an async receive callable that disconnects mid-request.\n\n    The parser should raise on the disconnect.\n    \"\"\"\n    offset = 0\n    disconnected = False\n\n    async def receive():\n        nonlocal offset, disconnected\n        if disconnected:\n            return {\"type\": \"http.disconnect\"}\n        chunk = body[offset : offset + chunk_size]\n        offset += chunk_size\n        more_body = offset < len(body)\n        if more_body:\n            disconnected = True\n        return {\"type\": \"http.request\", \"body\": chunk, \"more_body\": more_body}\n\n    return receive\n\n\nclass TestFormUrlEncoded:\n    \"\"\"Test request.form() with application/x-www-form-urlencoded data.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_basic_form_fields(self):\n        \"\"\"Basic URL-encoded form should be parseable via request.form().\"\"\"\n        body = b\"username=john&password=secret\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert form[\"username\"] == \"john\"\n        assert form[\"password\"] == \"secret\"\n\n    @pytest.mark.asyncio\n    async def test_form_with_multiple_values(self):\n        \"\"\"Multiple values for same key should be accessible via getlist().\"\"\"\n        body = b\"tag=python&tag=web&tag=api\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert form[\"tag\"] == \"python\"  # First value\n        assert form.getlist(\"tag\") == [\"python\", \"web\", \"api\"]\n\n    @pytest.mark.asyncio\n    async def test_empty_form(self):\n        \"\"\"Empty form should return empty FormData.\"\"\"\n        body = b\"\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert len(form) == 0\n\n    @pytest.mark.asyncio\n    async def test_form_with_special_characters(self):\n        \"\"\"URL-encoded special characters should be decoded properly.\"\"\"\n        body = b\"message=hello%20world&emoji=%F0%9F%91%8B\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert form[\"message\"] == \"hello world\"\n        assert form[\"emoji\"] == \"👋\"\n\n\nclass TestMultipartBasic:\n    \"\"\"Test request.form() with multipart/form-data (fields only, no files).\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_single_text_field(self):\n        \"\"\"Single text field in multipart should be parseable.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"username\"\\r\\n'\n            b\"\\r\\n\"\n            b\"john_doe\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert form[\"username\"] == \"john_doe\"\n\n    @pytest.mark.asyncio\n    async def test_multiple_text_fields(self):\n        \"\"\"Multiple text fields in multipart should all be accessible.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"first_name\"\\r\\n'\n            b\"\\r\\n\"\n            b\"John\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"last_name\"\\r\\n'\n            b\"\\r\\n\"\n            b\"Doe\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n\n        assert form[\"first_name\"] == \"John\"\n        assert form[\"last_name\"] == \"Doe\"\n\n    @pytest.mark.asyncio\n    async def test_file_discarded_when_files_false(self):\n        \"\"\"File content should be discarded when files=False (default).\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"title\"\\r\\n'\n            b\"\\r\\n\"\n            b\"My Document\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"doc.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"File content here\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"description\"\\r\\n'\n            b\"\\r\\n\"\n            b\"A sample document\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()  # files=False is default\n\n        # Text fields should be present\n        assert form[\"title\"] == \"My Document\"\n        assert form[\"description\"] == \"A sample document\"\n        # File should NOT be present\n        assert \"file\" not in form\n\n    @pytest.mark.asyncio\n    async def test_chunked_body_parsing(self):\n        \"\"\"Multipart should work when body arrives in small chunks.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"username\"\\r\\n'\n            b\"\\r\\n\"\n            b\"john_doe\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        # Use small chunks to test streaming parser\n        request = Request(scope, make_chunked_receive(body, chunk_size=16))\n\n        form = await request.form()\n\n        assert form[\"username\"] == \"john_doe\"\n\n\nclass TestMultipartWithFiles:\n    \"\"\"Test request.form(files=True) for file uploads.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_single_file_upload(self):\n        \"\"\"Single file upload should create UploadedFile object.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"document\"; filename=\"test.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Hello, World!\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        uploaded_file = form[\"document\"]\n        assert uploaded_file.filename == \"test.txt\"\n        assert uploaded_file.content_type == \"text/plain\"\n        assert await uploaded_file.read() == b\"Hello, World!\"\n        assert uploaded_file.size == 13\n\n    @pytest.mark.asyncio\n    async def test_mixed_fields_and_files(self):\n        \"\"\"Mixed form fields and files should all be accessible.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"title\"\\r\\n'\n            b\"\\r\\n\"\n            b\"My Document\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"doc.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Document content\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"description\"\\r\\n'\n            b\"\\r\\n\"\n            b\"A sample\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        # Text fields\n        assert form[\"title\"] == \"My Document\"\n        assert form[\"description\"] == \"A sample\"\n        # File\n        uploaded_file = form[\"file\"]\n        assert uploaded_file.filename == \"doc.txt\"\n        assert await uploaded_file.read() == b\"Document content\"\n\n    @pytest.mark.asyncio\n    async def test_multiple_files_same_name(self):\n        \"\"\"Multiple files with same name should be accessible via getlist().\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"files\"; filename=\"a.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"File A\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"files\"; filename=\"b.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"File B\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        files = form.getlist(\"files\")\n        assert len(files) == 2\n        assert files[0].filename == \"a.txt\"\n        assert files[1].filename == \"b.txt\"\n\n    @pytest.mark.asyncio\n    async def test_large_file_spills_to_disk(self):\n        \"\"\"Files larger than threshold should spill to temp file.\"\"\"\n        boundary = \"----TestBoundary123\"\n        # Create a body larger than the in-memory threshold (1MB)\n        large_content = b\"x\" * (2 * 1024 * 1024)  # 2MB\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"bigfile\"; filename=\"large.bin\"\\r\\n'\n            b\"Content-Type: application/octet-stream\\r\\n\"\n            b\"\\r\\n\" + large_content + b\"\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        uploaded_file = form[\"bigfile\"]\n        assert uploaded_file.size == len(large_content)\n        # Content should still be readable\n        content = await uploaded_file.read()\n        assert content == large_content\n\n    @pytest.mark.asyncio\n    async def test_uploaded_file_seek_and_read(self):\n        \"\"\"UploadedFile should support seek and multiple reads.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Hello, World!\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n        uploaded_file = form[\"file\"]\n\n        # First read\n        content1 = await uploaded_file.read()\n        assert content1 == b\"Hello, World!\"\n\n        # Seek back to start\n        await uploaded_file.seek(0)\n\n        # Second read\n        content2 = await uploaded_file.read()\n        assert content2 == b\"Hello, World!\"\n\n\nclass TestMultipartCleanup:\n    \"\"\"Test deterministic cleanup of uploaded files.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_formdata_close_closes_uploaded_files(self):\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Hello\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n        form = await request.form(files=True)\n        uploaded_file = form[\"file\"]\n\n        form.close()\n\n        with pytest.raises(ValueError):\n            await uploaded_file.read()\n\n    @pytest.mark.asyncio\n    async def test_formdata_async_context_manager_closes_files(self):\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Hello\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n        form = await request.form(files=True)\n        uploaded_file = form[\"file\"]\n\n        async with form:\n            pass\n\n        with pytest.raises(ValueError):\n            await uploaded_file.read()\n\n\nclass TestMultipartEdgeCases:\n    \"\"\"Test edge cases in multipart parsing.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_empty_file_upload(self):\n        \"\"\"Empty file (filename but no content) should be handled.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"empty.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        uploaded_file = form[\"file\"]\n        assert uploaded_file.filename == \"empty.txt\"\n        assert uploaded_file.size == 0\n        assert await uploaded_file.read() == b\"\"\n\n    @pytest.mark.asyncio\n    async def test_filename_with_path(self):\n        \"\"\"Filename containing path should extract just the filename.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"C:\\\\Users\\\\test\\\\doc.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"content\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form(files=True)\n\n        # Should extract just the filename, not the full path\n        uploaded_file = form[\"file\"]\n        assert uploaded_file.filename == \"doc.txt\"\n\n    @pytest.mark.asyncio\n    async def test_missing_content_type_header(self):\n        \"\"\"Missing content-type in request should raise BadRequest.\"\"\"\n        body = b\"some body\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest):\n            await request.form()\n\n    @pytest.mark.asyncio\n    async def test_invalid_content_type(self):\n        \"\"\"Non-form content-type should raise BadRequest.\"\"\"\n        body = b'{\"key\": \"value\"}'\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/json\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest):\n            await request.form()\n\n    @pytest.mark.asyncio\n    async def test_missing_boundary(self):\n        \"\"\"Multipart without boundary should raise BadRequest.\"\"\"\n        body = b\"some body\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"multipart/form-data\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest):\n            await request.form()\n\n\nclass TestSecurityLimits:\n    \"\"\"Test security limits on form parsing.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_max_fields_limit(self):\n        \"\"\"Should reject requests with too many fields.\"\"\"\n        boundary = \"----TestBoundary123\"\n        # Create body with many fields\n        parts = []\n        for i in range(1001):  # Default max is 1000\n            parts.append(\n                f\"------TestBoundary123\\r\\n\"\n                f'Content-Disposition: form-data; name=\"field{i}\"\\r\\n'\n                f\"\\r\\n\"\n                f\"value{i}\\r\\n\"\n            )\n        parts.append(\"------TestBoundary123--\\r\\n\")\n        body = \"\".join(parts).encode()\n\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"(?i)too many\"):\n            await request.form(max_fields=1000)\n\n    @pytest.mark.asyncio\n    async def test_max_file_size_limit(self):\n        \"\"\"Should reject files exceeding size limit.\"\"\"\n        boundary = \"----TestBoundary123\"\n        large_content = b\"x\" * (11 * 1024 * 1024)  # 11MB\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"big.bin\"\\r\\n'\n            b\"Content-Type: application/octet-stream\\r\\n\"\n            b\"\\r\\n\" + large_content + b\"\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"(?i)file.*too large|too large\"):\n            await request.form(files=True, max_file_size=10 * 1024 * 1024)\n\n    @pytest.mark.asyncio\n    async def test_max_request_size_limit(self):\n        \"\"\"Should reject requests exceeding total size limit.\"\"\"\n        boundary = \"----TestBoundary123\"\n        large_content = b\"x\" * (6 * 1024 * 1024)  # 6MB\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"big.bin\"\\r\\n'\n            b\"Content-Type: application/octet-stream\\r\\n\"\n            b\"\\r\\n\" + large_content + b\"\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"(?i)too large|request.*too large\"):\n            await request.form(files=True, max_request_size=5 * 1024 * 1024)\n\n\nclass TestMultipartStrictnessAndLimits:\n    \"\"\"Tests that enforce stricter ASGI and multipart behaviors.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_multipart_truncated_body_is_error(self):\n        \"\"\"Truncated multipart without closing boundary should raise.\"\"\"\n        boundary = \"----TestBoundary123\"\n        # Missing the final closing boundary line\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"field\"\\r\\n'\n            b\"\\r\\n\"\n            b\"value\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"Truncated multipart body\"):\n            await request.form()\n\n    @pytest.mark.asyncio\n    async def test_disconnect_mid_body_is_error(self):\n        \"\"\"Client disconnect during body streaming should raise.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"field\"\\r\\n'\n            b\"\\r\\n\"\n            b\"value\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_disconnect_receive(body, chunk_size=16))\n\n        with pytest.raises(BadRequest, match=\"disconnected\"):\n            await request.form()\n\n    @pytest.mark.asyncio\n    async def test_unknown_asgi_message_type_is_ignored(self):\n        \"\"\"Unexpected ASGI message types should be ignored.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"field\"\\r\\n'\n            b\"\\r\\n\"\n            b\"value\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive_with_noise(body))\n\n        form = await request.form()\n        assert form[\"field\"] == \"value\"\n\n    @pytest.mark.asyncio\n    async def test_max_files_enforced_even_when_files_false(self):\n        \"\"\"File count limits should apply even when file handling is disabled.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"f1\"; filename=\"a.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"a\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"f2\"; filename=\"b.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"b\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"Too many files\"):\n            await request.form(files=False, max_files=1)\n\n    @pytest.mark.asyncio\n    async def test_max_parts_limit(self):\n        \"\"\"Total part count should be bounded.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"a\"\\r\\n'\n            b\"\\r\\n\"\n            b\"1\\r\\n\"\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"b\"\\r\\n'\n            b\"\\r\\n\"\n            b\"2\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"Too many parts\"):\n            await request.form(max_parts=1)\n\n    @pytest.mark.asyncio\n    async def test_max_file_size_enforced_even_when_files_false(self):\n        \"\"\"File size limits should apply even when file handling is disabled.\"\"\"\n        boundary = \"----TestBoundary123\"\n        big_content = b\"x\" * 2048\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"big.bin\"\\r\\n'\n            b\"Content-Type: application/octet-stream\\r\\n\"\n            b\"\\r\\n\" + big_content + b\"\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"File too large\"):\n            await request.form(files=False, max_file_size=1024)\n\n    @pytest.mark.asyncio\n    async def test_part_header_limits(self):\n        \"\"\"Overly large part headers should be rejected.\"\"\"\n        boundary = \"----TestBoundary123\"\n        huge_header_value = \"x\" * 5000\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            + f'Content-Disposition: form-data; name=\"field\"; foo=\"{huge_header_value}\"\\r\\n'.encode()\n            + b\"\\r\\n\"\n            + b\"value\\r\\n\"\n            + b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"headers too large\"):\n            await request.form(max_part_header_bytes=1024)\n\n    @pytest.mark.asyncio\n    async def test_insufficient_disk_space_rejects_upload(self, monkeypatch):\n        \"\"\"Uploads should be rejected when free disk is below the floor.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\n'\n            b\"Content-Type: text/plain\\r\\n\"\n            b\"\\r\\n\"\n            b\"Hello\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n\n        DiskUsage = namedtuple(\"DiskUsage\", (\"total\", \"used\", \"free\"))\n        monkeypatch.setattr(\n            \"datasette.utils.multipart.shutil.disk_usage\",\n            lambda path: DiskUsage(total=100, used=95, free=5),\n        )\n\n        request = Request(scope, make_receive(body))\n        with pytest.raises(BadRequest, match=\"Insufficient disk space\"):\n            await request.form(files=True, min_free_disk_bytes=50)\n\n    @pytest.mark.asyncio\n    async def test_low_disk_space_does_not_block_field_only_forms(self, monkeypatch):\n        \"\"\"Low disk space should not reject multipart forms with no file parts.\"\"\"\n        boundary = \"----TestBoundary123\"\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"field\"\\r\\n'\n            b\"\\r\\n\"\n            b\"value\\r\\n\"\n            b\"------TestBoundary123--\\r\\n\"\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n\n        DiskUsage = namedtuple(\"DiskUsage\", (\"total\", \"used\", \"free\"))\n        monkeypatch.setattr(\n            \"datasette.utils.multipart.shutil.disk_usage\",\n            lambda path: DiskUsage(total=100, used=99, free=1),\n        )\n\n        request = Request(scope, make_receive(body))\n        form = await request.form(files=True, min_free_disk_bytes=50)\n        assert form[\"field\"] == \"value\"\n\n    @pytest.mark.asyncio\n    async def test_headers_without_newline_hit_header_byte_limit(self):\n        \"\"\"Headers that never terminate should still hit the header byte limit.\"\"\"\n        boundary = \"----TestBoundary123\"\n        huge = b\"x\" * 5000\n        # No CRLF is included after the header line\n        body = (\n            b\"------TestBoundary123\\r\\n\"\n            b'Content-Disposition: form-data; name=\"field\"; foo=\"' + huge + b'\"'\n        )\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", f\"multipart/form-data; boundary={boundary}\".encode()),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        with pytest.raises(BadRequest, match=\"headers too large\"):\n            await request.form(max_part_header_bytes=1024)\n\n\nclass TestFormDataLenSemantics:\n    \"\"\"Test that FormData.__len__ reflects number of items, not unique keys.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_len_counts_items(self):\n        body = b\"tag=python&tag=web&tag=api\"\n        scope = {\n            \"type\": \"http\",\n            \"method\": \"POST\",\n            \"headers\": [\n                (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n            ],\n        }\n        request = Request(scope, make_receive(body))\n\n        form = await request.form()\n        assert len(form) == 3\n\n\n# Conformance test suite using multipart-form-data-conformance\n\n# Tests where our parser intentionally differs from strict spec for security/practicality\n# Our parser sanitizes filenames (strips paths) while the conformance suite expects raw\nFILENAME_SANITIZATION_TESTS = {\n    \"026-filename-with-backslash\",  # We preserve backslashes but they test expects raw\n    \"029-filename-path-traversal\",  # We strip path components for security\n}\n\n# Tests for optional/lenient features we don't implement\nOPTIONAL_TESTS = {\n    \"085-header-folding\",  # Obsolete header folding feature\n}\n\n# Tests for malformed input where we're lenient instead of erroring\nLENIENT_PARSING_TESTS = {\n    \"203-missing-content-disposition\",\n    \"204-invalid-content-disposition\",\n}\n\n\ndef load_conformance_test_cases():\n    \"\"\"Load all test cases from multipart-form-data-conformance.\"\"\"\n    tests_dir = get_tests_dir()\n    test_cases = []\n\n    for category_dir in sorted(tests_dir.iterdir()):\n        if not category_dir.is_dir():\n            continue\n        for test_dir in sorted(category_dir.iterdir()):\n            if not test_dir.is_dir():\n                continue\n            test_json = test_dir / \"test.json\"\n            headers_json = test_dir / \"headers.json\"\n            input_raw = test_dir / \"input.raw\"\n\n            if not all(f.exists() for f in [test_json, headers_json, input_raw]):\n                continue\n\n            with open(test_json) as f:\n                test_spec = json.load(f)\n            with open(headers_json) as f:\n                headers = json.load(f)\n            with open(input_raw, \"rb\") as f:\n                body = f.read()\n\n            test_id = test_spec[\"id\"]\n\n            # Add marks for tests we handle differently\n            marks = []\n            if test_id in FILENAME_SANITIZATION_TESTS:\n                marks.append(\n                    pytest.mark.xfail(reason=\"Parser sanitizes filenames for security\")\n                )\n            elif test_id in OPTIONAL_TESTS:\n                marks.append(\n                    pytest.mark.xfail(reason=\"Optional feature not implemented\")\n                )\n            elif test_id in LENIENT_PARSING_TESTS:\n                marks.append(\n                    pytest.mark.xfail(reason=\"Parser is lenient with malformed input\")\n                )\n\n            test_cases.append(\n                pytest.param(\n                    test_spec,\n                    headers,\n                    body,\n                    id=test_id,\n                    marks=marks,\n                )\n            )\n\n    return test_cases\n\n\nCONFORMANCE_TEST_CASES = load_conformance_test_cases()\n\n\n@pytest.mark.parametrize(\"test_spec,headers,body\", CONFORMANCE_TEST_CASES)\n@pytest.mark.asyncio\nasync def test_conformance(test_spec, headers, body):\n    \"\"\"\n    Run conformance test cases from multipart-form-data-conformance.\n\n    Each test case specifies:\n    - headers: HTTP headers including Content-Type with boundary\n    - body: Raw multipart body bytes\n    - expected: Expected parse result (valid/invalid, parts list)\n    \"\"\"\n    scope = {\n        \"type\": \"http\",\n        \"method\": \"POST\",\n        \"headers\": [(k.encode(), v.encode()) for k, v in headers.items()],\n    }\n    request = Request(scope, make_receive(body))\n\n    expected = test_spec[\"expected\"]\n\n    if not expected[\"valid\"]:\n        # Should raise an error for invalid input\n        with pytest.raises((BadRequest, ValueError)):\n            await request.form(files=True)\n        return\n\n    # Parse form data\n    form = await request.form(files=True)\n\n    # Verify each expected part\n    for i, expected_part in enumerate(expected[\"parts\"]):\n        name = expected_part[\"name\"]\n\n        # Get value(s) for this name\n        values = form.getlist(name)\n\n        # Find the value at the correct index for this name\n        # (handles multiple values with same name)\n        same_name_count = sum(1 for p in expected[\"parts\"][:i] if p[\"name\"] == name)\n\n        if same_name_count >= len(values):\n            pytest.fail(\n                f\"Expected part {name} at index {same_name_count} but only {len(values)} found\"\n            )\n\n        value = values[same_name_count]\n\n        # Determine expected content\n        if \"body_base64\" in expected_part:\n            expected_content = base64.b64decode(expected_part[\"body_base64\"])\n        elif \"body_text\" in expected_part:\n            expected_content = expected_part[\"body_text\"].encode(\"utf-8\")\n        else:\n            expected_content = None\n\n        # Check for file vs field\n        # A part is a file if it has a filename OR filename_star\n        is_file = (\n            expected_part.get(\"filename\") is not None\n            or expected_part.get(\"filename_star\") is not None\n        )\n\n        if is_file:\n            # It's a file\n            assert hasattr(value, \"filename\"), f\"Expected file for {name}\"\n\n            # Check filename - use filename_star if present, else filename\n            expected_filename = expected_part.get(\"filename_star\") or expected_part.get(\n                \"filename\"\n            )\n            if expected_filename:\n                assert (\n                    value.filename == expected_filename\n                ), f\"Filename mismatch: expected {expected_filename!r}, got {value.filename!r}\"\n\n            if expected_part.get(\"content_type\"):\n                assert value.content_type == expected_part[\"content_type\"]\n\n            content = await value.read()\n            assert (\n                len(content) == expected_part[\"body_size\"]\n            ), f\"Size mismatch: expected {expected_part['body_size']}, got {len(content)}\"\n            if expected_content is not None:\n                assert content == expected_content\n        else:\n            # It's a text field\n            if hasattr(value, \"filename\"):\n                pytest.fail(f\"Expected text field for {name}, got file\")\n\n            if expected_content is not None:\n                # For text fields, value is a string\n                try:\n                    expected_text = expected_content.decode(\"utf-8\")\n                except UnicodeDecodeError:\n                    expected_text = expected_content.decode(\"latin-1\")\n                assert (\n                    value == expected_text\n                ), f\"Value mismatch: expected {expected_text!r}, got {value!r}\"\n"
  },
  {
    "path": "tests/test_package.py",
    "content": "from click.testing import CliRunner\nfrom datasette import cli\nfrom unittest import mock\nimport os\nimport pathlib\nimport pytest\n\n\nclass CaptureDockerfile:\n    def __call__(self, _):\n        self.captured = (pathlib.Path() / \"Dockerfile\").read_text()\n\n\nEXPECTED_DOCKERFILE = \"\"\"\nFROM python:3.11.0-slim-bullseye\nCOPY . /app\nWORKDIR /app\n\nENV DATASETTE_SECRET 'sekrit'\nRUN pip install -U datasette\nRUN datasette inspect test.db --inspect-file inspect-data.json\nENV PORT {port}\nEXPOSE {port}\nCMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --port $PORT\n\"\"\".strip()\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.cli.call\")\ndef test_package(mock_call, mock_which, tmp_path_factory):\n    mock_which.return_value = True\n    runner = CliRunner()\n    capture = CaptureDockerfile()\n    mock_call.side_effect = capture\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(cli.cli, [\"package\", \"test.db\", \"--secret\", \"sekrit\"])\n    assert 0 == result.exit_code\n    mock_call.assert_has_calls([mock.call([\"docker\", \"build\", \".\"])])\n    assert EXPECTED_DOCKERFILE.format(port=8001) == capture.captured\n\n\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.cli.call\")\ndef test_package_with_port(mock_call, mock_which, tmp_path_factory):\n    mock_which.return_value = True\n    capture = CaptureDockerfile()\n    mock_call.side_effect = capture\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli, [\"package\", \"test.db\", \"-p\", \"8080\", \"--secret\", \"sekrit\"]\n    )\n    assert 0 == result.exit_code\n    assert EXPECTED_DOCKERFILE.format(port=8080) == capture.captured\n"
  },
  {
    "path": "tests/test_permission_endpoints.py",
    "content": "\"\"\"\nTests for permission endpoints:\n- /-/allowed.json\n- /-/rules.json\n\"\"\"\n\nimport pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\n\n\n@pytest_asyncio.fixture\nasync def ds_with_permissions():\n    \"\"\"Create a Datasette instance with test data and permissions.\"\"\"\n    ds = Datasette()\n    ds.root_enabled = True\n    await ds.invoke_startup()\n\n    # Add some test databases and tables\n    db = ds.add_memory_database(\"analytics\")\n    await db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)\"\n    )\n    await db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_type TEXT, user_id INTEGER)\"\n    )\n\n    db2 = ds.add_memory_database(\"production\")\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, total REAL)\"\n    )\n    await db2.execute_write(\n        \"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY, name TEXT)\"\n    )\n\n    await ds.refresh_schemas()\n\n    return ds\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_status,expected_keys\",\n    [\n        # Instance level permission\n        (\n            \"/-/allowed.json?action=view-instance\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Database level permission\n        (\n            \"/-/allowed.json?action=view-database\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Table level permission\n        (\n            \"/-/allowed.json?action=view-table\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        (\n            \"/-/allowed.json?action=execute-sql\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Missing action parameter\n        (\"/-/allowed.json\", 400, {\"error\"}),\n        # Invalid action\n        (\"/-/allowed.json?action=nonexistent\", 404, {\"error\"}),\n        # Any valid action works, even if no permission rules exist for it\n        (\n            \"/-/allowed.json?action=insert-row\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n    ],\n)\nasync def test_allowed_json_basic(\n    ds_with_permissions, path, expected_status, expected_keys\n):\n    response = await ds_with_permissions.client.get(path)\n    assert response.status_code == expected_status\n    data = response.json()\n    assert expected_keys.issubset(data.keys())\n\n\n@pytest.mark.asyncio\nasync def test_allowed_json_response_structure(ds_with_permissions):\n    \"\"\"Test that /-/allowed.json returns the expected structure.\"\"\"\n    response = await ds_with_permissions.client.get(\n        \"/-/allowed.json?action=view-instance\"\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check required fields\n    assert \"action\" in data\n    assert \"actor_id\" in data\n    assert \"page\" in data\n    assert \"page_size\" in data\n    assert \"total\" in data\n    assert \"items\" in data\n\n    # Check items structure\n    assert isinstance(data[\"items\"], list)\n    if data[\"items\"]:\n        item = data[\"items\"][0]\n        assert \"parent\" in item\n        assert \"child\" in item\n        assert \"resource\" in item\n\n\n@pytest.mark.asyncio\nasync def test_allowed_json_with_actor(ds_with_permissions):\n    \"\"\"Test /-/allowed.json includes actor information.\"\"\"\n    response = await ds_with_permissions.client.get(\n        \"/-/allowed.json?action=view-table\",\n        cookies={\n            \"ds_actor\": ds_with_permissions.client.actor_cookie({\"id\": \"test_user\"})\n        },\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"actor_id\"] == \"test_user\"\n\n\n@pytest.mark.asyncio\nasync def test_allowed_json_pagination():\n    \"\"\"Test that /-/allowed.json pagination works.\"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Create many tables to test pagination\n    db = ds.add_memory_database(\"test\")\n    for i in range(30):\n        await db.execute_write(f\"CREATE TABLE table{i:02d} (id INTEGER PRIMARY KEY)\")\n    await ds.refresh_schemas()\n\n    # Test page 1\n    response = await ds.client.get(\n        \"/-/allowed.json?action=view-table&page_size=10&page=1\"\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"page\"] == 1\n    assert data[\"page_size\"] == 10\n    assert len(data[\"items\"]) == 10\n\n    # Test page 2\n    response = await ds.client.get(\n        \"/-/allowed.json?action=view-table&page_size=10&page=2\"\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"page\"] == 2\n    assert len(data[\"items\"]) == 10\n\n    # Verify items are different between pages\n    response1 = await ds.client.get(\n        \"/-/allowed.json?action=view-table&page_size=10&page=1\"\n    )\n    response2 = await ds.client.get(\n        \"/-/allowed.json?action=view-table&page_size=10&page=2\"\n    )\n    items1 = {(item[\"parent\"], item[\"child\"]) for item in response1.json()[\"items\"]}\n    items2 = {(item[\"parent\"], item[\"child\"]) for item in response2.json()[\"items\"]}\n    assert items1 != items2\n\n\n@pytest.mark.asyncio\nasync def test_allowed_json_total_count(tmp_path_factory):\n    \"\"\"Test that /-/allowed.json returns correct total count.\"\"\"\n    from datasette.database import Database\n\n    # Use temporary file databases to avoid leakage from other tests\n    tmp_dir = tmp_path_factory.mktemp(\"test_allowed_json_total_count\")\n\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    # Create test databases with tables\n    analytics_db = ds.add_database(\n        Database(ds, path=str(tmp_dir / \"analytics.db\")), name=\"analytics\"\n    )\n    await analytics_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)\"\n    )\n    await analytics_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_type TEXT, user_id INTEGER)\"\n    )\n\n    production_db = ds.add_database(\n        Database(ds, path=str(tmp_dir / \"production.db\")), name=\"production\"\n    )\n    await production_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, total REAL)\"\n    )\n    await production_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY, name TEXT)\"\n    )\n\n    await ds.refresh_schemas()\n\n    response = await ds.client.get(\"/-/allowed.json?action=view-table\")\n    assert response.status_code == 200\n    data = response.json()\n\n    # We created 4 tables total (2 in analytics, 2 in production)\n    import json\n\n    assert (\n        data[\"total\"] == 4\n    ), f\"Expected total=4, got: {json.dumps(data, separators=(',', ':'))}\"\n\n\n# /-/rules.json tests\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_status,expected_keys\",\n    [\n        # Instance level rules\n        (\n            \"/-/rules.json?action=view-instance\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Database level rules\n        (\n            \"/-/rules.json?action=view-database\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Table level rules\n        (\n            \"/-/rules.json?action=view-table\",\n            200,\n            {\"action\", \"items\", \"total\", \"page\"},\n        ),\n        # Missing action parameter\n        (\"/-/rules.json\", 400, {\"error\"}),\n        # Invalid action\n        (\"/-/rules.json?action=nonexistent\", 404, {\"error\"}),\n    ],\n)\nasync def test_rules_json_basic(\n    ds_with_permissions, path, expected_status, expected_keys\n):\n    # Use root actor for rules endpoint (requires permissions-debug)\n    response = await ds_with_permissions.client.get(\n        path,\n        cookies={\"ds_actor\": ds_with_permissions.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == expected_status\n    data = response.json()\n    assert expected_keys.issubset(data.keys())\n\n\n@pytest.mark.asyncio\nasync def test_rules_json_response_structure(ds_with_permissions):\n    \"\"\"Test that /-/rules.json returns the expected structure.\"\"\"\n    response = await ds_with_permissions.client.get(\n        \"/-/rules.json?action=view-instance\",\n        cookies={\"ds_actor\": ds_with_permissions.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check required fields\n    assert \"action\" in data\n    assert \"actor_id\" in data\n    assert \"page\" in data\n    assert \"page_size\" in data\n    assert \"total\" in data\n    assert \"items\" in data\n\n    # Check items structure\n    assert isinstance(data[\"items\"], list)\n    if data[\"items\"]:\n        item = data[\"items\"][0]\n        assert \"parent\" in item\n        assert \"child\" in item\n        assert \"resource\" in item\n        assert \"allow\" in item\n        assert \"reason\" in item\n\n\n@pytest.mark.asyncio\nasync def test_rules_json_includes_all_rules(ds_with_permissions):\n    \"\"\"Test that /-/rules.json includes both allowed and denied resources.\"\"\"\n    # Root user should see rules for everything\n    response = await ds_with_permissions.client.get(\n        \"/-/rules.json?action=view-table\",\n        cookies={\"ds_actor\": ds_with_permissions.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Should have items (root has global allow)\n    assert len(data[\"items\"]) > 0\n\n    # Each item should have allow field (0 or 1)\n    for item in data[\"items\"]:\n        assert \"allow\" in item\n        assert item[\"allow\"] in [0, 1]\n\n\n@pytest.mark.asyncio\nasync def test_rules_json_pagination():\n    \"\"\"Test that /-/rules.json pagination works.\"\"\"\n    ds = Datasette()\n    ds.root_enabled = True\n    await ds.invoke_startup()\n\n    # Create some tables\n    db = ds.add_memory_database(\"test\")\n    for i in range(5):\n        await db.execute_write(\n            f\"CREATE TABLE IF NOT EXISTS table{i:02d} (id INTEGER PRIMARY KEY)\"\n        )\n    await ds.refresh_schemas()\n\n    # Test basic pagination structure - just verify it returns paginated results\n    response = await ds.client.get(\n        \"/-/rules.json?action=view-table&page_size=2&page=1\",\n        cookies={\"ds_actor\": ds.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"page\"] == 1\n    assert data[\"page_size\"] == 2\n    # Verify items is a list (may have fewer items than page_size if there aren't many rules)\n    assert isinstance(data[\"items\"], list)\n    assert \"total\" in data\n\n\n@pytest.mark.asyncio\nasync def test_rules_json_with_actor(ds_with_permissions):\n    \"\"\"Test /-/rules.json includes actor information.\"\"\"\n    # Use root actor (rules endpoint requires permissions-debug)\n    response = await ds_with_permissions.client.get(\n        \"/-/rules.json?action=view-table\",\n        cookies={\"ds_actor\": ds_with_permissions.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"actor_id\"] == \"root\"\n\n\n@pytest.mark.asyncio\nasync def test_root_user_respects_settings_deny():\n    \"\"\"\n    Test for issue #2509: Settings-based deny rules should override root user privileges.\n\n    When a database has `allow: false` in settings, the root user should NOT see\n    that database in /-/allowed.json?action=view-database.\n    \"\"\"\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"content\": {\n                    \"allow\": False,  # Deny everyone, including root\n                }\n            }\n        }\n    )\n    ds.root_enabled = True\n    await ds.invoke_startup()\n    ds.add_memory_database(\"content\")\n\n    # Root user should NOT see the denied database\n    response = await ds.client.get(\n        \"/-/allowed.json?action=view-database\",\n        cookies={\"ds_actor\": ds.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check that content database is NOT in the allowed list\n    allowed_databases = [item[\"parent\"] for item in data[\"items\"]]\n    assert \"content\" not in allowed_databases, (\n        f\"Root user should not see 'content' database when settings deny it, \"\n        f\"but found it in: {allowed_databases}\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_root_user_respects_settings_deny_tables():\n    \"\"\"\n    Test for issue #2509: Settings-based deny rules should override root for tables too.\n\n    When a database has `allow: false` in settings, the root user should NOT see\n    tables from that database in /-/allowed.json?action=view-table.\n    \"\"\"\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"content\": {\n                    \"allow\": False,  # Deny everyone, including root\n                }\n            }\n        }\n    )\n    ds.root_enabled = True\n    await ds.invoke_startup()\n\n    # Add a database with a table\n    db = ds.add_memory_database(\"content\")\n    await db.execute_write(\"CREATE TABLE repos (id INTEGER PRIMARY KEY, name TEXT)\")\n    await ds.refresh_schemas()\n\n    # Root user should NOT see tables from the content database\n    response = await ds.client.get(\n        \"/-/allowed.json?action=view-table\",\n        cookies={\"ds_actor\": ds.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check that content.repos table is NOT in the allowed list\n    content_tables = [\n        item[\"child\"] for item in data[\"items\"] if item[\"parent\"] == \"content\"\n    ]\n    assert \"repos\" not in content_tables, (\n        f\"Root user should not see tables from 'content' database when settings deny it, \"\n        f\"but found: {content_tables}\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_execute_sql_requires_view_database():\n    \"\"\"\n    Test for issue #2527: execute-sql permission should require view-database permission.\n\n    A user who has execute-sql permission but not view-database permission should not\n    be able to execute SQL on that database.\n    \"\"\"\n    from datasette.permissions import PermissionSQL\n    from datasette import hookimpl\n\n    class TestPermissionPlugin:\n        __name__ = \"TestPermissionPlugin\"\n\n        @hookimpl\n        def permission_resources_sql(self, datasette, actor, action):\n            if actor is None or actor.get(\"id\") != \"test_user\":\n                return []\n\n            if action == \"execute-sql\":\n                # Grant execute-sql on the \"secret\" database\n                return PermissionSQL(\n                    sql=\"SELECT 'secret' AS parent, NULL AS child, 1 AS allow, 'can execute sql' AS reason\",\n                )\n            elif action == \"view-database\":\n                # Deny view-database on the \"secret\" database\n                return PermissionSQL(\n                    sql=\"SELECT 'secret' AS parent, NULL AS child, 0 AS allow, 'cannot view db' AS reason\",\n                )\n\n            return []\n\n    plugin = TestPermissionPlugin()\n\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.pm.register(plugin, name=\"test_plugin\")\n\n    try:\n        ds.add_memory_database(\"secret\")\n        await ds.refresh_schemas()\n\n        # User should NOT have execute-sql permission because view-database is denied\n        response = await ds.client.get(\n            \"/-/allowed.json?action=execute-sql\",\n            cookies={\"ds_actor\": ds.client.actor_cookie({\"id\": \"test_user\"})},\n        )\n        assert response.status_code == 200\n        data = response.json()\n\n        # The \"secret\" database should NOT be in the allowed list for execute-sql\n        allowed_databases = [item[\"parent\"] for item in data[\"items\"]]\n        assert \"secret\" not in allowed_databases, (\n            f\"User should not have execute-sql permission without view-database, \"\n            f\"but found 'secret' in: {allowed_databases}\"\n        )\n\n        # Also verify that attempting to execute SQL on the database is denied\n        # (may be 403 or 302 redirect to login/error page depending on middleware)\n        response = await ds.client.get(\n            \"/secret?sql=SELECT+1\",\n            cookies={\"ds_actor\": ds.client.actor_cookie({\"id\": \"test_user\"})},\n        )\n        assert response.status_code in (302, 403), (\n            f\"Expected 302 or 403 when trying to execute SQL without view-database permission, \"\n            f\"but got {response.status_code}\"\n        )\n    finally:\n        ds.pm.unregister(plugin)\n"
  },
  {
    "path": "tests/test_permissions.py",
    "content": "import collections\nfrom datasette.app import Datasette\nfrom datasette.cli import cli\nfrom datasette.default_permissions import restrictions_allow_action\nfrom .fixtures import assert_permissions_checked, make_app_client\nfrom click.testing import CliRunner\nfrom bs4 import BeautifulSoup as Soup\nimport copy\nimport json\nfrom pprint import pprint\nimport pytest_asyncio\nimport pytest\nimport re\nimport time\nimport urllib\n\n\n@pytest.fixture(scope=\"module\")\ndef padlock_client():\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"fixtures\": {\n                    \"queries\": {\"two\": {\"sql\": \"select 1 + 1\"}},\n                }\n            }\n        }\n    ) as client:\n        yield client\n\n\n@pytest_asyncio.fixture\nasync def perms_ds():\n    ds = Datasette()\n    await ds.invoke_startup()\n    one = ds.add_memory_database(\"perms_ds_one\")\n    two = ds.add_memory_database(\"perms_ds_two\")\n    await one.execute_write(\"create table if not exists t1 (id integer primary key)\")\n    await one.execute_write(\"insert or ignore into t1 (id) values (1)\")\n    await one.execute_write(\"create view if not exists v1 as select * from t1\")\n    await one.execute_write(\"create table if not exists t2 (id integer primary key)\")\n    await two.execute_write(\"create table if not exists t1 (id integer primary key)\")\n    # Trigger catalog refresh so allowed_resources() can be called\n    await ds.client.get(\"/\")\n    return ds\n\n\n@pytest.mark.parametrize(\n    \"allow,expected_anon,expected_auth\",\n    [\n        (None, 200, 200),\n        ({}, 403, 403),\n        ({\"id\": \"root\"}, 403, 200),\n    ],\n)\n@pytest.mark.parametrize(\n    \"path\",\n    (\n        \"/\",\n        \"/fixtures\",\n        \"/-/api\",\n        \"/fixtures/compound_three_primary_keys\",\n        \"/fixtures/compound_three_primary_keys/a,a,a\",\n        pytest.param(\n            \"/fixtures/two\",\n            marks=pytest.mark.xfail(\n                reason=\"view-query not yet migrated to new permission system\"\n            ),\n        ),  # Query\n    ),\n)\ndef test_view_padlock(allow, expected_anon, expected_auth, path, padlock_client):\n    padlock_client.ds.config[\"allow\"] = allow\n    fragment = \"🔒</h1>\"\n    anon_response = padlock_client.get(path)\n    assert expected_anon == anon_response.status\n    if allow and anon_response.status == 200:\n        # Should be no padlock\n        assert fragment not in anon_response.text\n    auth_response = padlock_client.get(\n        path,\n        cookies={\"ds_actor\": padlock_client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert expected_auth == auth_response.status\n    # Check for the padlock\n    if allow and expected_anon == 403 and expected_auth == 200:\n        assert fragment in auth_response.text\n    del padlock_client.ds.config[\"allow\"]\n\n\n@pytest.mark.parametrize(\n    \"allow,expected_anon,expected_auth\",\n    [\n        (None, 200, 200),\n        ({}, 403, 403),\n        ({\"id\": \"root\"}, 403, 200),\n    ],\n)\n@pytest.mark.parametrize(\"use_metadata\", (True, False))\ndef test_view_database(allow, expected_anon, expected_auth, use_metadata):\n    key = \"metadata\" if use_metadata else \"config\"\n    kwargs = {key: {\"databases\": {\"fixtures\": {\"allow\": allow}}}}\n    with make_app_client(**kwargs) as client:\n        for path in (\n            \"/fixtures\",\n            \"/fixtures/compound_three_primary_keys\",\n            \"/fixtures/compound_three_primary_keys/a,a,a\",\n        ):\n            anon_response = client.get(path)\n            assert expected_anon == anon_response.status, path\n            if allow and path == \"/fixtures\" and anon_response.status == 200:\n                # Should be no padlock\n                assert \">fixtures 🔒</h1>\" not in anon_response.text\n            auth_response = client.get(\n                path,\n                cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})},\n            )\n            assert expected_auth == auth_response.status\n            if (\n                allow\n                and path == \"/fixtures\"\n                and expected_anon == 403\n                and expected_auth == 200\n            ):\n                assert \">fixtures 🔒</h1>\" in auth_response.text\n\n\ndef test_database_list_respects_view_database():\n    with make_app_client(\n        config={\"databases\": {\"fixtures\": {\"allow\": {\"id\": \"root\"}}}},\n        extra_databases={\"data.db\": \"create table names (name text)\"},\n    ) as client:\n        anon_response = client.get(\"/\")\n        assert '<a href=\"/data\">data</a></h2>' in anon_response.text\n        assert '<a href=\"/fixtures\">fixtures</a>' not in anon_response.text\n        auth_response = client.get(\n            \"/\",\n            cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})},\n        )\n        assert '<a href=\"/data\">data</a></h2>' in auth_response.text\n        assert '<a href=\"/fixtures\">fixtures</a> 🔒</h2>' in auth_response.text\n\n\ndef test_database_list_respects_view_table():\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"tables\": {\n                        \"names\": {\"allow\": {\"id\": \"root\"}},\n                        \"v\": {\"allow\": {\"id\": \"root\"}},\n                    }\n                }\n            }\n        },\n        extra_databases={\n            \"data.db\": \"create table names (name text); create view v as select * from names\"\n        },\n    ) as client:\n        html_fragments = [\n            \">names</a> 🔒\",\n            \">v</a> 🔒\",\n        ]\n        anon_response_text = client.get(\"/\").text\n        assert \"0 rows in 0 tables\" in anon_response_text\n        for html_fragment in html_fragments:\n            assert html_fragment not in anon_response_text\n        auth_response_text = client.get(\n            \"/\",\n            cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})},\n        ).text\n        for html_fragment in html_fragments:\n            assert html_fragment in auth_response_text\n\n\n@pytest.mark.parametrize(\n    \"allow,expected_anon,expected_auth\",\n    [\n        (None, 200, 200),\n        ({}, 403, 403),\n        ({\"id\": \"root\"}, 403, 200),\n    ],\n)\n@pytest.mark.parametrize(\"use_metadata\", (True, False))\ndef test_view_table(allow, expected_anon, expected_auth, use_metadata):\n    key = \"metadata\" if use_metadata else \"config\"\n    kwargs = {\n        key: {\n            \"databases\": {\n                \"fixtures\": {\n                    \"tables\": {\"compound_three_primary_keys\": {\"allow\": allow}}\n                }\n            }\n        }\n    }\n    with make_app_client(**kwargs) as client:\n        anon_response = client.get(\"/fixtures/compound_three_primary_keys\")\n        assert expected_anon == anon_response.status\n        if allow and anon_response.status == 200:\n            # Should be no padlock\n            assert \">compound_three_primary_keys 🔒</h1>\" not in anon_response.text\n        auth_response = client.get(\n            \"/fixtures/compound_three_primary_keys\",\n            cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})},\n        )\n        assert expected_auth == auth_response.status\n        if allow and expected_anon == 403 and expected_auth == 200:\n            assert \">compound_three_primary_keys 🔒</h1>\" in auth_response.text\n\n\ndef test_table_list_respects_view_table():\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"fixtures\": {\n                    \"tables\": {\n                        \"compound_three_primary_keys\": {\"allow\": {\"id\": \"root\"}},\n                        # And a SQL view too:\n                        \"paginated_view\": {\"allow\": {\"id\": \"root\"}},\n                    }\n                }\n            }\n        }\n    ) as client:\n        html_fragments = [\n            \">compound_three_primary_keys</a> 🔒\",\n            \">paginated_view</a> 🔒\",\n        ]\n        anon_response = client.get(\"/fixtures\")\n        for html_fragment in html_fragments:\n            assert html_fragment not in anon_response.text\n        auth_response = client.get(\n            \"/fixtures\", cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})}\n        )\n        for html_fragment in html_fragments:\n            assert html_fragment in auth_response.text\n\n\n@pytest.mark.parametrize(\n    \"allow,expected_anon,expected_auth\",\n    [\n        (None, 200, 200),\n        ({}, 403, 403),\n        ({\"id\": \"root\"}, 403, 200),\n    ],\n)\ndef test_view_query(allow, expected_anon, expected_auth):\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"fixtures\": {\"queries\": {\"q\": {\"sql\": \"select 1 + 1\", \"allow\": allow}}}\n            }\n        }\n    ) as client:\n        anon_response = client.get(\"/fixtures/q\")\n        assert expected_anon == anon_response.status\n        if allow and anon_response.status == 200:\n            # Should be no padlock\n            assert \"🔒</h1>\" not in anon_response.text\n        auth_response = client.get(\n            \"/fixtures/q\", cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})}\n        )\n        assert expected_auth == auth_response.status\n        if allow and expected_anon == 403 and expected_auth == 200:\n            assert \">fixtures: q 🔒</h1>\" in auth_response.text\n\n\n@pytest.mark.parametrize(\n    \"config\",\n    [\n        {\"allow_sql\": {\"id\": \"root\"}},\n        {\"databases\": {\"fixtures\": {\"allow_sql\": {\"id\": \"root\"}}}},\n    ],\n)\ndef test_execute_sql(config):\n    schema_re = re.compile(\"const schema = ({.*?});\", re.DOTALL)\n    with make_app_client(config=config) as client:\n        form_fragment = '<form class=\"sql core\" action=\"/fixtures/-/query\"'\n\n        # Anonymous users - should not display the form:\n        anon_html = client.get(\"/fixtures\").text\n        assert form_fragment not in anon_html\n        # And const schema should be an empty object:\n        assert \"const schema = {};\" in anon_html\n        # This should 403:\n        assert client.get(\"/fixtures/-/query?sql=select+1\").status == 403\n        # ?_where= not allowed on tables:\n        assert client.get(\"/fixtures/facet_cities?_where=id=3\").status == 403\n\n        # But for logged in user all of these should work:\n        cookies = {\"ds_actor\": client.actor_cookie({\"id\": \"root\"})}\n        response_text = client.get(\"/fixtures\", cookies=cookies).text\n        # Extract the schema= portion of the JavaScript\n        schema_json = schema_re.search(response_text).group(1)\n        schema = json.loads(schema_json)\n        assert set(schema[\"attraction_characteristic\"]) == {\"name\", \"pk\"}\n        assert schema[\"paginated_view\"] == []\n        assert form_fragment in response_text\n        query_response = client.get(\"/fixtures/-/query?sql=select+1\", cookies=cookies)\n        assert query_response.status == 200\n        schema2 = json.loads(schema_re.search(query_response.text).group(1))\n        assert set(schema2[\"attraction_characteristic\"]) == {\"name\", \"pk\"}\n        assert (\n            client.get(\"/fixtures/facet_cities?_where=id=3\", cookies=cookies).status\n            == 200\n        )\n\n\ndef test_query_list_respects_view_query():\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"fixtures\": {\n                    \"queries\": {\"q\": {\"sql\": \"select 1 + 1\", \"allow\": {\"id\": \"root\"}}}\n                }\n            }\n        }\n    ) as client:\n        html_fragment = '<li><a href=\"/fixtures/q\" title=\"select 1 + 1\">q</a> 🔒</li>'\n        anon_response = client.get(\"/fixtures\")\n        assert html_fragment not in anon_response.text\n        assert '\"/fixtures/q\"' not in anon_response.text\n        auth_response = client.get(\n            \"/fixtures\", cookies={\"ds_actor\": client.actor_cookie({\"id\": \"root\"})}\n        )\n        assert html_fragment in auth_response.text\n\n\n@pytest.mark.parametrize(\n    \"path,permissions\",\n    [\n        (\"/\", [\"view-instance\"]),\n        (\"/fixtures\", [\"view-instance\", (\"view-database\", \"fixtures\")]),\n        (\n            \"/fixtures/facetable/1\",\n            [\"view-instance\", (\"view-table\", (\"fixtures\", \"facetable\"))],\n        ),\n        (\n            \"/fixtures/simple_primary_key\",\n            [\n                \"view-instance\",\n                (\"view-database\", \"fixtures\"),\n                (\"view-table\", (\"fixtures\", \"simple_primary_key\")),\n            ],\n        ),\n        (\n            \"/fixtures/-/query?sql=select+1\",\n            [\n                \"view-instance\",\n                (\"view-database\", \"fixtures\"),\n                (\"execute-sql\", \"fixtures\"),\n            ],\n        ),\n        (\n            \"/fixtures.db\",\n            [\n                \"view-instance\",\n                (\"view-database\", \"fixtures\"),\n                (\"view-database-download\", \"fixtures\"),\n            ],\n        ),\n        pytest.param(\n            \"/fixtures/neighborhood_search\",\n            [\n                \"view-instance\",\n                (\"view-database\", \"fixtures\"),\n                (\"view-query\", (\"fixtures\", \"neighborhood_search\")),\n            ],\n        ),\n    ],\n)\ndef test_permissions_checked(app_client, path, permissions):\n    # Needs file-backed app_client for /fixtures.db\n    app_client.ds._permission_checks.clear()\n    response = app_client.get(path)\n    assert response.status_code in (200, 403)\n    assert_permissions_checked(app_client.ds, permissions)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"filter_\", (\"all\", \"exclude-yours\", \"only-yours\"))\nasync def test_permissions_debug(ds_client, filter_):\n    ds_client.ds._permission_checks.clear()\n    assert (await ds_client.get(\"/-/permissions\")).status_code == 403\n    # With the cookie it should work (need to set root_enabled for root user)\n    ds_client.ds.root_enabled = True\n    cookie = ds_client.actor_cookie({\"id\": \"root\"})\n    response = await ds_client.get(\n        f\"/-/permissions?filter={filter_}\", cookies={\"ds_actor\": cookie}\n    )\n    assert response.status_code == 200\n    # Should have a select box listing permissions\n    for fragment in (\n        '<select name=\"permission\" id=\"permission\">',\n        '<option value=\"view-instance\">view-instance</option>',\n        '<option value=\"insert-row\">insert-row</option>',\n    ):\n        assert fragment in response.text\n    # Should show one failure and one success\n    soup = Soup(response.text, \"html.parser\")\n    table = soup.find(\"table\", {\"id\": \"permission-checks-table\"})\n    rows = table.find(\"tbody\").find_all(\"tr\")\n    checks = []\n    for row in rows:\n        cells = row.find_all(\"td\")\n        result_cell = cells[5]\n        if result_cell.select_one(\".check-result-true\"):\n            result = True\n        elif result_cell.select_one(\".check-result-false\"):\n            result = False\n        else:\n            result = None\n        actor_code = cells[4].find(\"code\")\n        actor = json.loads(actor_code.text) if actor_code else None\n        checks.append(\n            {\n                \"action\": cells[1].text.strip(),\n                \"result\": result,\n                \"actor\": actor,\n            }\n        )\n    expected_checks = [\n        {\n            \"action\": \"permissions-debug\",\n            \"result\": True,\n            \"actor\": {\"id\": \"root\"},\n        },\n        {\n            \"action\": \"view-instance\",\n            \"result\": True,\n            \"actor\": {\"id\": \"root\"},\n        },\n        {\"action\": \"debug-menu\", \"result\": False, \"actor\": None},\n        {\n            \"action\": \"view-instance\",\n            \"result\": True,\n            \"actor\": None,\n        },\n        {\n            \"action\": \"permissions-debug\",\n            \"result\": False,\n            \"actor\": None,\n        },\n        {\n            \"action\": \"view-instance\",\n            \"result\": True,\n            \"actor\": None,\n        },\n    ]\n    if filter_ == \"only-yours\":\n        expected_checks = [\n            check for check in expected_checks if check[\"actor\"] is not None\n        ]\n    elif filter_ == \"exclude-yours\":\n        expected_checks = [check for check in expected_checks if check[\"actor\"] is None]\n    assert checks == expected_checks\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"actor,allow,expected_fragment\",\n    [\n        ('{\"id\":\"root\"}', \"{}\", \"Result: deny\"),\n        ('{\"id\":\"root\"}', '{\"id\": \"*\"}', \"Result: allow\"),\n        ('{\"', '{\"id\": \"*\"}', \"Actor JSON error\"),\n        ('{\"id\":\"root\"}', '\"*\"}', \"Allow JSON error\"),\n    ],\n)\nasync def test_allow_debug(ds_client, actor, allow, expected_fragment):\n    response = await ds_client.get(\n        \"/-/allow-debug?\" + urllib.parse.urlencode({\"actor\": actor, \"allow\": allow})\n    )\n    assert response.status_code == 200\n    assert expected_fragment in response.text\n\n\n@pytest.mark.parametrize(\n    \"allow,expected\",\n    [\n        ({\"id\": \"root\"}, 403),\n        ({\"id\": \"root\", \"unauthenticated\": True}, 200),\n    ],\n)\ndef test_allow_unauthenticated(allow, expected):\n    with make_app_client(config={\"allow\": allow}) as client:\n        assert expected == client.get(\"/\").status\n\n\n@pytest.fixture(scope=\"session\")\ndef view_instance_client():\n    with make_app_client(config={\"allow\": {}}) as client:\n        yield client\n\n\n@pytest.mark.parametrize(\n    \"path\",\n    [\n        \"/\",\n        \"/fixtures\",\n        \"/fixtures/facetable\",\n        \"/-/versions\",\n        \"/-/plugins\",\n        \"/-/settings\",\n        \"/-/threads\",\n        \"/-/databases\",\n        \"/-/permissions\",\n        \"/-/messages\",\n        \"/-/patterns\",\n    ],\n)\ndef test_view_instance(path, view_instance_client):\n    assert 403 == view_instance_client.get(path).status\n    if path not in (\"/-/permissions\", \"/-/messages\", \"/-/patterns\"):\n        assert 403 == view_instance_client.get(path + \".json\").status\n\n\n@pytest.fixture(scope=\"session\")\ndef cascade_app_client():\n    with make_app_client(is_immutable=True) as client:\n        yield client\n\n\n@pytest.mark.parametrize(\n    \"path,permissions,expected_status\",\n    [\n        (\"/\", [], 403),\n        (\"/\", [\"instance\"], 200),\n        # Can view table even if not allowed database or instance\n        (\"/fixtures/binary_data\", [], 403),\n        (\"/fixtures/binary_data\", [\"database\"], 403),\n        (\"/fixtures/binary_data\", [\"instance\"], 403),\n        (\"/fixtures/binary_data\", [\"table\"], 200),\n        (\"/fixtures/binary_data\", [\"table\", \"database\"], 200),\n        (\"/fixtures/binary_data\", [\"table\", \"database\", \"instance\"], 200),\n        # ... same for row\n        (\"/fixtures/binary_data/1\", [], 403),\n        (\"/fixtures/binary_data/1\", [\"database\"], 403),\n        (\"/fixtures/binary_data/1\", [\"instance\"], 403),\n        (\"/fixtures/binary_data/1\", [\"table\"], 200),\n        (\"/fixtures/binary_data/1\", [\"table\", \"database\"], 200),\n        (\"/fixtures/binary_data/1\", [\"table\", \"database\", \"instance\"], 200),\n        # Can view query even if not allowed database or instance\n        (\"/fixtures/magic_parameters\", [], 403),\n        (\"/fixtures/magic_parameters\", [\"database\"], 403),\n        (\"/fixtures/magic_parameters\", [\"instance\"], 403),\n        (\"/fixtures/magic_parameters\", [\"query\"], 200),\n        (\"/fixtures/magic_parameters\", [\"query\", \"database\"], 200),\n        (\"/fixtures/magic_parameters\", [\"query\", \"database\", \"instance\"], 200),\n        # Can view database even if not allowed instance\n        (\"/fixtures\", [], 403),\n        (\"/fixtures\", [\"instance\"], 403),\n        (\"/fixtures\", [\"database\"], 200),\n        # Downloading the fixtures.db file\n        (\"/fixtures.db\", [], 403),\n        (\"/fixtures.db\", [\"instance\"], 403),\n        (\"/fixtures.db\", [\"database\"], 200),\n        (\"/fixtures.db\", [\"download\"], 200),\n    ],\n)\ndef test_permissions_cascade(cascade_app_client, path, permissions, expected_status):\n    \"\"\"Test that e.g. having view-table but NOT view-database lets you view table page, etc\"\"\"\n    allow = {\"id\": \"*\"}\n    deny = {}\n    previous_config = cascade_app_client.ds.config\n    updated_config = copy.deepcopy(previous_config)\n    actor = {\"id\": \"test\"}\n    if \"download\" in permissions:\n        actor[\"can_download\"] = 1\n    try:\n        # Set up the different allow blocks\n        updated_config[\"allow\"] = allow if \"instance\" in permissions else deny\n        # Note: download permission also needs database access (via plugin granting both)\n        # so we don't set a deny rule when download is in permissions\n        updated_config[\"databases\"][\"fixtures\"][\"allow\"] = (\n            allow if (\"database\" in permissions or \"download\" in permissions) else deny\n        )\n        updated_config[\"databases\"][\"fixtures\"][\"tables\"][\"binary_data\"] = {\n            \"allow\": (allow if \"table\" in permissions else deny)\n        }\n        updated_config[\"databases\"][\"fixtures\"][\"queries\"][\"magic_parameters\"][\n            \"allow\"\n        ] = (allow if \"query\" in permissions else deny)\n        cascade_app_client.ds.config = updated_config\n        response = cascade_app_client.get(\n            path,\n            cookies={\"ds_actor\": cascade_app_client.actor_cookie(actor)},\n        )\n        assert (\n            response.status == expected_status\n        ), \"path: {}, permissions: {}, expected_status: {}, status: {}\".format(\n            path, permissions, expected_status, response.status\n        )\n    finally:\n        cascade_app_client.ds.config = previous_config\n\n\ndef test_padlocks_on_database_page(cascade_app_client):\n    config = {\n        \"databases\": {\n            \"fixtures\": {\n                \"allow\": {\"id\": \"test\"},\n                \"tables\": {\n                    \"123_starts_with_digits\": {\"allow\": True},\n                    \"simple_view\": {\"allow\": True},\n                },\n                \"queries\": {\"query_two\": {\"allow\": True, \"sql\": \"select 2\"}},\n            }\n        }\n    }\n    previous_config = cascade_app_client.ds.config\n    try:\n        cascade_app_client.ds.config = config\n        response = cascade_app_client.get(\n            \"/fixtures\",\n            cookies={\"ds_actor\": cascade_app_client.actor_cookie({\"id\": \"test\"})},\n        )\n        # Tables\n        assert \">123_starts_with_digits</a></h3>\" in response.text\n        assert \">Table With Space In Name</a> 🔒</h3>\" in response.text\n        # Queries\n        assert \">from_async_hook</a> 🔒</li>\" in response.text\n        assert \">query_two</a></li>\" in response.text\n        # Views\n        assert \">paginated_view</a> 🔒</li>\" in response.text\n        assert \">simple_view</a></li>\" in response.text\n    finally:\n        cascade_app_client.ds.config = previous_config\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"actor,permission,resource_1,resource_2,expected_result\",\n    (\n        # Without restrictions the defaults apply\n        ({\"id\": \"t\"}, \"view-instance\", None, None, True),\n        ({\"id\": \"t\"}, \"view-database\", \"one\", None, True),\n        ({\"id\": \"t\"}, \"view-table\", \"one\", \"t1\", True),\n        # If there is an _r block, everything gets denied unless explicitly allowed\n        ({\"id\": \"t\", \"_r\": {}}, \"view-instance\", None, None, False),\n        ({\"id\": \"t\", \"_r\": {}}, \"view-database\", \"one\", None, False),\n        ({\"id\": \"t\", \"_r\": {}}, \"view-table\", \"one\", \"t1\", False),\n        # Explicit allowing works at the \"a\" for all level:\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vi\"]}}, \"view-instance\", None, None, True),\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vd\"]}}, \"view-database\", \"one\", None, True),\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vt\"]}}, \"view-table\", \"one\", \"t1\", True),\n        # But not if it's the wrong permission\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vi\"]}}, \"view-database\", \"one\", None, False),\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vd\"]}}, \"view-table\", \"one\", \"t1\", False),\n        # Works at the \"d\" for database level:\n        ({\"id\": \"t\", \"_r\": {\"d\": {\"one\": [\"vd\"]}}}, \"view-database\", \"one\", None, True),\n        (\n            # view-database-download requires view-database too (also_requires)\n            {\"id\": \"t\", \"_r\": {\"d\": {\"one\": [\"vdd\", \"vd\"]}}},\n            \"view-database-download\",\n            \"one\",\n            None,\n            True,\n        ),\n        (\n            # execute-sql requires view-database too (also_requires)\n            {\"id\": \"t\", \"_r\": {\"d\": {\"one\": [\"es\", \"vd\"]}}},\n            \"execute-sql\",\n            \"one\",\n            None,\n            True,\n        ),\n        # Works at the \"r\" for table level:\n        (\n            {\"id\": \"t\", \"_r\": {\"r\": {\"one\": {\"t1\": [\"vt\"]}}}},\n            \"view-table\",\n            \"one\",\n            \"t1\",\n            True,\n        ),\n        (\n            {\"id\": \"t\", \"_r\": {\"r\": {\"one\": {\"t1\": [\"vt\"]}}}},\n            \"view-table\",\n            \"one\",\n            \"t2\",\n            False,\n        ),\n        # non-abbreviations should work too\n        (\n            {\"id\": \"t\", \"_r\": {\"a\": [\"view-instance\"]}},\n            \"view-instance\",\n            None,\n            None,\n            True,\n        ),\n        (\n            {\"id\": \"t\", \"_r\": {\"d\": {\"one\": [\"view-database\"]}}},\n            \"view-database\",\n            \"one\",\n            None,\n            True,\n        ),\n        (\n            {\"id\": \"t\", \"_r\": {\"r\": {\"one\": {\"t1\": [\"view-table\"]}}}},\n            \"view-table\",\n            \"one\",\n            \"t1\",\n            True,\n        ),\n        # view-database does NOT grant view-instance (no upward cascading)\n        ({\"id\": \"t\", \"_r\": {\"a\": [\"vd\"]}}, \"view-instance\", None, None, False),\n    ),\n)\nasync def test_actor_restricted_permissions(\n    perms_ds, actor, permission, resource_1, resource_2, expected_result\n):\n    perms_ds.pdb = True\n    perms_ds.root_enabled = True  # Allow root actor to access /-/permissions\n    cookies = {\"ds_actor\": perms_ds.sign({\"a\": {\"id\": \"root\"}}, \"actor\")}\n    csrftoken = (await perms_ds.client.get(\"/-/permissions\", cookies=cookies)).cookies[\n        \"ds_csrftoken\"\n    ]\n    cookies[\"ds_csrftoken\"] = csrftoken\n    response = await perms_ds.client.post(\n        \"/-/permissions\",\n        data={\n            \"actor\": json.dumps(actor),\n            \"permission\": permission,\n            \"resource_1\": resource_1,\n            \"resource_2\": resource_2,\n            \"csrftoken\": csrftoken,\n        },\n        cookies=cookies,\n    )\n    # Response mirrors /-/check JSON structure\n    if resource_1 is None:\n        expected_path = \"/\"\n    elif resource_2 is None:\n        expected_path = f\"/{resource_1}\"\n    else:\n        expected_path = f\"/{resource_1}/{resource_2}\"\n\n    expected_resource = {\n        \"parent\": resource_1,\n        \"child\": resource_2,\n        \"path\": expected_path,\n    }\n    expected = {\n        \"action\": permission,\n        \"allowed\": expected_result,\n        \"resource\": expected_resource,\n    }\n    if actor.get(\"id\"):\n        expected[\"actor_id\"] = actor[\"id\"]\n    assert response.json() == expected\n\n\nPermConfigTestCase = collections.namedtuple(\n    \"PermConfigTestCase\",\n    \"config,actor,action,resource,expected_result\",\n)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"config,actor,action,resource,expected_result\",\n    (\n        # Simple view-instance default=True example\n        PermConfigTestCase(\n            config={},\n            actor=None,\n            action=\"view-instance\",\n            resource=None,\n            expected_result=True,\n        ),\n        # debug-menu on root\n        PermConfigTestCase(\n            config={\"permissions\": {\"debug-menu\": {\"id\": \"user\"}}},\n            actor={\"id\": \"user\"},\n            action=\"debug-menu\",\n            resource=None,\n            expected_result=True,\n        ),\n        # debug-menu on root, wrong actor\n        PermConfigTestCase(\n            config={\"permissions\": {\"debug-menu\": {\"id\": \"user\"}}},\n            actor={\"id\": \"user2\"},\n            action=\"debug-menu\",\n            resource=None,\n            expected_result=False,\n        ),\n        # create-table on root\n        PermConfigTestCase(\n            config={\"permissions\": {\"create-table\": {\"id\": \"user\"}}},\n            actor={\"id\": \"user\"},\n            action=\"create-table\",\n            resource=None,\n            expected_result=True,\n        ),\n        # create-table on database - no resource specified\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\"permissions\": {\"create-table\": {\"id\": \"user\"}}}\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"create-table\",\n            resource=None,\n            expected_result=False,\n        ),\n        # create-table on database\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\"permissions\": {\"create-table\": {\"id\": \"user\"}}}\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"create-table\",\n            resource=\"perms_ds_one\",\n            expected_result=True,\n        ),\n        # insert-row on root, wrong actor\n        PermConfigTestCase(\n            config={\"permissions\": {\"insert-row\": {\"id\": \"user\"}}},\n            actor={\"id\": \"user2\"},\n            action=\"insert-row\",\n            resource=(\"perms_ds_one\", \"t1\"),\n            expected_result=False,\n        ),\n        # insert-row on root, right actor\n        PermConfigTestCase(\n            config={\"permissions\": {\"insert-row\": {\"id\": \"user\"}}},\n            actor={\"id\": \"user\"},\n            action=\"insert-row\",\n            resource=(\"perms_ds_one\", \"t1\"),\n            expected_result=True,\n        ),\n        # set-column-type on specific table\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"tables\": {\n                            \"t1\": {\"permissions\": {\"set-column-type\": {\"id\": \"user\"}}}\n                        }\n                    }\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"set-column-type\",\n            resource=(\"perms_ds_one\", \"t1\"),\n            expected_result=True,\n        ),\n        # insert-row on database\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\"permissions\": {\"insert-row\": {\"id\": \"user\"}}}\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"insert-row\",\n            resource=\"perms_ds_one\",\n            expected_result=True,\n        ),\n        # insert-row on table, wrong table\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"tables\": {\n                            \"t1\": {\"permissions\": {\"insert-row\": {\"id\": \"user\"}}}\n                        }\n                    }\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"insert-row\",\n            resource=(\"perms_ds_one\", \"t2\"),\n            expected_result=False,\n        ),\n        # insert-row on table, right table\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"tables\": {\n                            \"t1\": {\"permissions\": {\"insert-row\": {\"id\": \"user\"}}}\n                        }\n                    }\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"insert-row\",\n            resource=(\"perms_ds_one\", \"t1\"),\n            expected_result=True,\n        ),\n        # view-query on canned query, wrong actor\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"queries\": {\n                            \"q1\": {\n                                \"sql\": \"select 1 + 1\",\n                                \"permissions\": {\"view-query\": {\"id\": \"user\"}},\n                            }\n                        }\n                    }\n                }\n            },\n            actor={\"id\": \"user2\"},\n            action=\"view-query\",\n            resource=(\"perms_ds_one\", \"q1\"),\n            expected_result=False,\n        ),\n        # view-query on canned query, right actor\n        PermConfigTestCase(\n            config={\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"queries\": {\n                            \"q1\": {\n                                \"sql\": \"select 1 + 1\",\n                                \"permissions\": {\"view-query\": {\"id\": \"user\"}},\n                            }\n                        }\n                    }\n                }\n            },\n            actor={\"id\": \"user\"},\n            action=\"view-query\",\n            resource=(\"perms_ds_one\", \"q1\"),\n            expected_result=True,\n        ),\n    ),\n)\nasync def test_permissions_in_config(\n    perms_ds, config, actor, action, resource, expected_result\n):\n    previous_config = perms_ds.config\n    updated_config = copy.deepcopy(previous_config)\n    updated_config.update(config)\n    perms_ds.config = updated_config\n    try:\n        # Convert old-style resource to Resource object\n        from datasette.resources import DatabaseResource, TableResource\n\n        resource_obj = None\n        if resource:\n            if isinstance(resource, str):\n                resource_obj = DatabaseResource(database=resource)\n            elif isinstance(resource, tuple) and len(resource) == 2:\n                resource_obj = TableResource(database=resource[0], table=resource[1])\n\n        result = await perms_ds.allowed(\n            action=action, resource=resource_obj, actor=actor\n        )\n        if result != expected_result:\n            pprint(perms_ds._permission_checks)\n            assert result == expected_result\n    finally:\n        perms_ds.config = previous_config\n\n\n@pytest.mark.asyncio\nasync def test_allowed_resources_view_query_includes_actor_specific_canned_queries():\n    \"\"\"\n    Actor-specific canned queries should be listed by allowed_resources(\"view-query\").\n\n    This test is intentionally explicit about the previous bug:\n    - the canned query only exists for actor \"alice\"\n    - the permission rule only allows actor \"alice\" to view it\n    - allowed() succeeds for that specific query resource\n    - allowed_resources(\"view-query\", actor) must include the same query\n\n    Before the fix, QueryResource.resources_sql() called canned_queries(..., actor=None),\n    so the query was omitted from resource enumeration and allowed_resources() returned\n    an empty list even though allowed() returned True.\n    \"\"\"\n    from datasette import hookimpl\n    from datasette.permissions import PermissionSQL\n    from datasette.resources import QueryResource\n\n    class ActorSpecificQueryPlugin:\n        __name__ = \"ActorSpecificQueryPlugin\"\n\n        @hookimpl\n        def canned_queries(self, datasette, database, actor):\n            if database == \"testdb\" and actor and actor.get(\"id\") == \"alice\":\n                return {\"user_only\": {\"sql\": \"select 1 as n\"}}\n            return {}\n\n        @hookimpl\n        def permission_resources_sql(self, datasette, actor, action):\n            if action == \"view-query\" and actor and actor.get(\"id\") == \"alice\":\n                return PermissionSQL(sql=\"\"\"\n                        SELECT 'testdb' AS parent, 'user_only' AS child, 1 AS allow,\n                               'alice can view her actor-specific canned query' AS reason\n                    \"\"\")\n            return None\n\n    ds = Datasette(default_deny=True)\n    await ds.invoke_startup()\n    ds.add_memory_database(\"testdb\")\n    await ds._refresh_schemas()\n\n    plugin = ActorSpecificQueryPlugin()\n    ds.pm.register(plugin, name=\"actor_specific_query_plugin\")\n\n    try:\n        actor = {\"id\": \"alice\"}\n\n        assert await ds.allowed(\n            action=\"view-query\",\n            resource=QueryResource(\"testdb\", \"user_only\"),\n            actor=actor,\n        )\n\n        page = await ds.allowed_resources(\"view-query\", actor)\n        assert [(resource.parent, resource.child) for resource in page.resources] == [\n            (\"testdb\", \"user_only\")\n        ]\n    finally:\n        ds.pm.unregister(name=\"actor_specific_query_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_actor_endpoint_allows_any_token():\n    ds = Datasette()\n    token = ds.sign(\n        {\n            \"a\": \"root\",\n            \"token\": \"dstok\",\n            \"t\": int(time.time()),\n            \"_r\": {\"a\": [\"debug-menu\"]},\n        },\n        namespace=\"token\",\n    )\n    response = await ds.client.get(\n        \"/-/actor.json\", headers={\"Authorization\": f\"Bearer dstok_{token}\"}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"actor\"] == {\n        \"id\": \"root\",\n        \"token\": \"dstok\",\n        \"_r\": {\"a\": [\"debug-menu\"]},\n    }\n\n\n@pytest.mark.serial\n@pytest.mark.parametrize(\n    \"options,expected\",\n    (\n        ([], {\"id\": \"root\", \"token\": \"dstok\"}),\n        (\n            [\"--all\", \"debug-menu\"],\n            {\"_r\": {\"a\": [\"dm\"]}, \"id\": \"root\", \"token\": \"dstok\"},\n        ),\n        (\n            [\"-a\", \"debug-menu\", \"--all\", \"create-table\"],\n            {\"_r\": {\"a\": [\"dm\", \"ct\"]}, \"id\": \"root\", \"token\": \"dstok\"},\n        ),\n        (\n            [\"-r\", \"db1\", \"t1\", \"insert-row\"],\n            {\"_r\": {\"r\": {\"db1\": {\"t1\": [\"ir\"]}}}, \"id\": \"root\", \"token\": \"dstok\"},\n        ),\n        (\n            [\"-d\", \"db1\", \"create-table\"],\n            {\"_r\": {\"d\": {\"db1\": [\"ct\"]}}, \"id\": \"root\", \"token\": \"dstok\"},\n        ),\n        # And one with all of them multiple times using all the names\n        (\n            [\n                \"-a\",\n                \"debug-menu\",\n                \"--all\",\n                \"create-table\",\n                \"-r\",\n                \"db1\",\n                \"t1\",\n                \"insert-row\",\n                \"--resource\",\n                \"db1\",\n                \"t2\",\n                \"update-row\",\n                \"-d\",\n                \"db1\",\n                \"create-table\",\n                \"--database\",\n                \"db2\",\n                \"drop-table\",\n            ],\n            {\n                \"_r\": {\n                    \"a\": [\"dm\", \"ct\"],\n                    \"d\": {\"db1\": [\"ct\"], \"db2\": [\"dt\"]},\n                    \"r\": {\"db1\": {\"t1\": [\"ir\"], \"t2\": [\"ur\"]}},\n                },\n                \"id\": \"root\",\n                \"token\": \"dstok\",\n            },\n        ),\n    ),\n)\ndef test_cli_create_token(options, expected):\n    runner = CliRunner()\n    result1 = runner.invoke(\n        cli,\n        [\n            \"create-token\",\n            \"--secret\",\n            \"sekrit\",\n            \"root\",\n        ]\n        + options,\n    )\n    token = result1.output.strip()\n    result2 = runner.invoke(\n        cli,\n        [\n            \"serve\",\n            \"--secret\",\n            \"sekrit\",\n            \"--get\",\n            \"/-/actor.json\",\n            \"--token\",\n            token,\n        ],\n    )\n    assert 0 == result2.exit_code, result2.output\n    assert json.loads(result2.output) == {\"actor\": expected}\n\n\n_visible_tables_re = re.compile(r\">\\/((\\w+)\\/(\\w+))\\.json<\\/a> - Get rows for\")\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"is_logged_in,config,expected_visible_tables\",\n    (\n        # Unprotected instance logged out user sees everything:\n        (\n            False,\n            None,\n            [\"perms_ds_one/t1\", \"perms_ds_one/t2\", \"perms_ds_two/t1\"],\n        ),\n        # Fully protected instance logged out user sees nothing\n        (False, {\"allow\": {\"id\": \"user\"}}, None),\n        # User with visibility of just perms_ds_one sees both tables there\n        (\n            True,\n            {\n                \"databases\": {\n                    \"perms_ds_one\": {\"allow\": {\"id\": \"user\"}},\n                    \"perms_ds_two\": {\"allow\": False},\n                }\n            },\n            [\"perms_ds_one/t1\", \"perms_ds_one/t2\"],\n        ),\n        # User with visibility of only table perms_ds_one/t1 sees just that one\n        (\n            True,\n            {\n                \"databases\": {\n                    \"perms_ds_one\": {\n                        \"allow\": {\"id\": \"user\"},\n                        \"tables\": {\"t2\": {\"allow\": False}},\n                    },\n                    \"perms_ds_two\": {\"allow\": False},\n                }\n            },\n            [\"perms_ds_one/t1\"],\n        ),\n    ),\n)\nasync def test_api_explorer_visibility(\n    perms_ds, is_logged_in, config, expected_visible_tables\n):\n    try:\n        prev_config = perms_ds.config\n        perms_ds.config = config or {}\n        cookies = {}\n        if is_logged_in:\n            cookies = {\"ds_actor\": perms_ds.client.actor_cookie({\"id\": \"user\"})}\n        response = await perms_ds.client.get(\"/-/api\", cookies=cookies)\n        if expected_visible_tables:\n            assert response.status_code == 200\n            # Search HTML for stuff matching:\n            # '>/perms_ds_one/t2.json</a> - Get rows for'\n            visible_tables = [\n                match[0] for match in _visible_tables_re.findall(response.text)\n            ]\n            assert visible_tables == expected_visible_tables\n        else:\n            assert response.status_code == 403\n    finally:\n        perms_ds.config = prev_config\n\n\n@pytest.mark.asyncio\nasync def test_view_table_token_cannot_gain_access_without_base_permission(perms_ds):\n    # Only allow a different actor to view this table\n    previous_config = perms_ds.config\n    perms_ds.config = {\n        \"databases\": {\n            \"perms_ds_two\": {\n                # Only someone-else can see anything in this database\n                \"allow\": {\"id\": \"someone-else\"},\n            }\n        }\n    }\n    try:\n        actor = {\n            \"id\": \"restricted-token\",\n            \"token\": \"dstok\",\n            # Restricted token claims access to perms_ds_two/t1 only\n            \"_r\": {\"r\": {\"perms_ds_two\": {\"t1\": [\"vt\"]}}},\n        }\n        cookies = {\"ds_actor\": perms_ds.client.actor_cookie(actor)}\n        response = await perms_ds.client.get(\"/perms_ds_two/t1.json\", cookies=cookies)\n        assert response.status_code == 403\n    finally:\n        perms_ds.config = previous_config\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"restrictions,verb,path,body,expected_status\",\n    (\n        # No restrictions\n        (None, \"get\", \"/.json\", None, 200),\n        (None, \"get\", \"/perms_ds_one.json\", None, 200),\n        (None, \"get\", \"/perms_ds_one/t1.json\", None, 200),\n        (None, \"get\", \"/perms_ds_one/t1/1.json\", None, 200),\n        (None, \"get\", \"/perms_ds_one/v1.json\", None, 200),\n        # Restricted to just view-instance\n        ({\"a\": [\"vi\"]}, \"get\", \"/.json\", None, 200),\n        ({\"a\": [\"vi\"]}, \"get\", \"/perms_ds_one.json\", None, 403),\n        ({\"a\": [\"vi\"]}, \"get\", \"/perms_ds_one/t1.json\", None, 403),\n        ({\"a\": [\"vi\"]}, \"get\", \"/perms_ds_one/t1/1.json\", None, 403),\n        ({\"a\": [\"vi\"]}, \"get\", \"/perms_ds_one/v1.json\", None, 403),\n        # Restricted to just view-database\n        (\n            {\"a\": [\"vd\"]},\n            \"get\",\n            \"/.json\",\n            None,\n            403,\n        ),  # Cannot see instance (no upward cascading)\n        ({\"a\": [\"vd\"]}, \"get\", \"/perms_ds_one.json\", None, 200),\n        ({\"a\": [\"vd\"]}, \"get\", \"/perms_ds_one/t1.json\", None, 403),\n        ({\"a\": [\"vd\"]}, \"get\", \"/perms_ds_one/t1/1.json\", None, 403),\n        ({\"a\": [\"vd\"]}, \"get\", \"/perms_ds_one/v1.json\", None, 403),\n        # Restricted to just view-table for specific database\n        (\n            {\"d\": {\"perms_ds_one\": [\"vt\"]}},\n            \"get\",\n            \"/.json\",\n            None,\n            403,\n        ),  # Cannot see instance (no upward cascading)\n        (\n            {\"d\": {\"perms_ds_one\": [\"vt\"]}},\n            \"get\",\n            \"/perms_ds_one.json\",\n            None,\n            403,\n        ),  # Cannot see database page (no upward cascading)\n        (\n            {\"d\": {\"perms_ds_one\": [\"vt\"]}},\n            \"get\",\n            \"/perms_ds_two.json\",\n            None,\n            403,\n        ),  # But not this one\n        (\n            # Can see the table\n            {\"d\": {\"perms_ds_one\": [\"vt\"]}},\n            \"get\",\n            \"/perms_ds_one/t1.json\",\n            None,\n            200,\n        ),\n        (\n            # And the view\n            {\"d\": {\"perms_ds_one\": [\"vt\"]}},\n            \"get\",\n            \"/perms_ds_one/v1.json\",\n            None,\n            200,\n        ),\n        # view-table access to a specific table\n        (\n            {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}},\n            \"get\",\n            \"/.json\",\n            None,\n            403,\n        ),  # Cannot see instance (no upward cascading)\n        (\n            {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}},\n            \"get\",\n            \"/perms_ds_one.json\",\n            None,\n            403,\n        ),  # Cannot see database page (no upward cascading)\n        (\n            {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}},\n            \"get\",\n            \"/perms_ds_one/t1.json\",\n            None,\n            200,\n        ),\n        # But cannot see the other table\n        (\n            {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}},\n            \"get\",\n            \"/perms_ds_one/t2.json\",\n            None,\n            403,\n        ),\n        # Or the view\n        (\n            {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}},\n            \"get\",\n            \"/perms_ds_one/v1.json\",\n            None,\n            403,\n        ),\n    ),\n)\nasync def test_actor_restrictions(\n    perms_ds, restrictions, verb, path, body, expected_status\n):\n    actor = {\"id\": \"user\"}\n    if restrictions:\n        actor[\"_r\"] = restrictions\n    method = getattr(perms_ds.client, verb)\n    kwargs = {\"cookies\": {\"ds_actor\": perms_ds.client.actor_cookie(actor)}}\n    if body:\n        kwargs[\"json\"] = body\n    perms_ds._permission_checks.clear()\n    response = await method(path, **kwargs)\n    assert response.status_code == expected_status, json.dumps(\n        {\n            \"verb\": verb,\n            \"path\": path,\n            \"body\": body,\n            \"restrictions\": restrictions,\n            \"expected_status\": expected_status,\n            \"response_status\": response.status_code,\n            \"checks\": [\n                {\n                    \"action\": check.action,\n                    \"parent\": check.parent,\n                    \"child\": check.child,\n                    \"result\": check.result,\n                }\n                for check in perms_ds._permission_checks\n            ],\n        },\n        indent=2,\n    )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"restrictions,action,resource,expected\",\n    (\n        # Exact match: view-instance restriction allows view-instance action\n        ({\"a\": [\"view-instance\"]}, \"view-instance\", None, True),\n        # No implication: view-table does NOT imply view-instance\n        ({\"a\": [\"view-table\"]}, \"view-instance\", None, False),\n        ({\"a\": [\"view-database\"]}, \"view-instance\", None, False),\n        # update-row does not imply view-instance\n        ({\"a\": [\"update-row\"]}, \"view-instance\", None, False),\n        # view-table on a resource does NOT imply view-instance\n        ({\"r\": {\"db1\": {\"t1\": [\"view-table\"]}}}, \"view-instance\", None, False),\n        # execute-sql on a database does NOT imply view-instance or view-database\n        ({\"d\": {\"db1\": [\"es\"]}}, \"view-instance\", None, False),\n        ({\"d\": {\"db1\": [\"es\"]}}, \"view-database\", \"db1\", False),\n        ({\"d\": {\"db1\": [\"es\"]}}, \"view-database\", \"db2\", False),\n        # But execute-sql abbreviation DOES allow execute-sql action on that database\n        ({\"d\": {\"db1\": [\"es\"]}}, \"execute-sql\", \"db1\", True),\n        # update-row on a resource does not imply view-instance\n        ({\"r\": {\"db1\": {\"t1\": [\"update-row\"]}}}, \"view-instance\", None, False),\n        # view-database on a database does NOT imply view-instance\n        ({\"d\": {\"db1\": [\"view-database\"]}}, \"view-instance\", None, False),\n        # But it DOES allow view-database on that specific database\n        ({\"d\": {\"db1\": [\"view-database\"]}}, \"view-database\", \"db1\", True),\n        # Having view-table on \"a\" allows access to any specific table\n        ({\"a\": [\"view-table\"]}, \"view-table\", (\"dbname\", \"tablename\"), True),\n        # Having view-table on a database allows access to tables in that database\n        (\n            {\"d\": {\"dbname\": [\"view-table\"]}},\n            \"view-table\",\n            (\"dbname\", \"tablename\"),\n            True,\n        ),\n        # But not if it's allowed on a different database\n        (\n            {\"d\": {\"dbname\": [\"view-table\"]}},\n            \"view-table\",\n            (\"dbname2\", \"tablename\"),\n            False,\n        ),\n        # Table-level restriction allows access to that specific table\n        (\n            {\"r\": {\"dbname\": {\"tablename\": [\"view-table\"]}}},\n            \"view-table\",\n            (\"dbname\", \"tablename\"),\n            True,\n        ),\n        # But not to a different table in the same database\n        (\n            {\"r\": {\"dbname\": {\"tablename\": [\"view-table\"]}}},\n            \"view-table\",\n            (\"dbname\", \"other_table\"),\n            False,\n        ),\n    ),\n)\nasync def test_restrictions_allow_action(restrictions, action, resource, expected):\n    ds = Datasette()\n    await ds.invoke_startup()\n    actual = restrictions_allow_action(ds, restrictions, action, resource)\n    assert actual == expected\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_filters_allowed_resources(perms_ds):\n    \"\"\"Test that allowed_resources() respects actor restrictions - issue #2534\"\"\"\n\n    # Actor restricted to just perms_ds_one/t1\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}}}\n\n    # Should only return t1\n    page = await perms_ds.allowed_resources(\"view-table\", actor)\n    assert len(page.resources) == 1\n    assert page.resources[0].parent == \"perms_ds_one\"\n    assert page.resources[0].child == \"t1\"\n\n    # Database listing should be empty (no view-database permission)\n    db_page = await perms_ds.allowed_resources(\"view-database\", actor)\n    assert len(db_page.resources) == 0\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_do_not_expand_allowed_resources(perms_ds):\n    \"\"\"Restrictions cannot grant access not already allowed to the actor.\"\"\"\n\n    previous_config = perms_ds.config\n    perms_ds.config = {\n        \"databases\": {\n            \"perms_ds_one\": {\n                \"allow\": {\"id\": \"someone-else\"},\n            }\n        }\n    }\n    try:\n        actor = {\"id\": \"user\", \"_r\": {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}}}\n\n        # Base actor is not allowed to see t1, so restrictions should not change that\n        page = await perms_ds.allowed_resources(\"view-table\", actor)\n        assert len(page.resources) == 0\n\n        # And explicit permission checks should still deny\n        response = await perms_ds.client.get(\n            \"/perms_ds_one/t1.json\",\n            cookies={\"ds_actor\": perms_ds.client.actor_cookie(actor)},\n        )\n        assert response.status_code == 403\n    finally:\n        perms_ds.config = previous_config\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_database_level(perms_ds):\n    \"\"\"Test database-level restrictions allow all tables in database - issue #2534\"\"\"\n\n    actor = {\"id\": \"user\", \"_r\": {\"d\": {\"perms_ds_one\": [\"vt\"]}}}\n\n    page = await perms_ds.allowed_resources(\"view-table\", actor, parent=\"perms_ds_one\")\n\n    # Should return all tables in perms_ds_one\n    table_names = {r.child for r in page.resources}\n    assert \"t1\" in table_names\n    assert \"t2\" in table_names\n    assert \"v1\" in table_names  # views too\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_global_level(perms_ds):\n    \"\"\"Test global-level restrictions allow all resources - issue #2534\"\"\"\n\n    actor = {\"id\": \"user\", \"_r\": {\"a\": [\"vt\"]}}\n\n    page = await perms_ds.allowed_resources(\"view-table\", actor)\n\n    # Should return all tables in all databases\n    assert len(page.resources) > 0\n    dbs = {r.parent for r in page.resources}\n    assert \"perms_ds_one\" in dbs\n    assert \"perms_ds_two\" in dbs\n\n\n@pytest.mark.asyncio\nasync def test_restrictions_gate_before_config(perms_ds):\n    \"\"\"Test that restrictions act as gating filter before config permissions - issue #2534\"\"\"\n    from datasette.resources import TableResource\n\n    # Actor restricted to just t1 (not t2)\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}}}\n\n    # Config doesn't matter - restrictions gate what's checked\n    # t2 is not in restriction allowlist, so should be DENIED\n    result = await perms_ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"perms_ds_one\", \"t2\"),\n        actor=actor,\n    )\n    assert result is False\n\n    # t1 is in restrictions AND passes normal permission check - should be ALLOWED\n    result = await perms_ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"perms_ds_one\", \"t1\"),\n        actor=actor,\n    )\n    assert result is True\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_json_endpoints_show_filtered_listings(perms_ds):\n    \"\"\"Test that /.json and /db.json show correct filtered listings - issue #2534\"\"\"\n\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}}}\n    cookies = {\"ds_actor\": perms_ds.client.actor_cookie(actor)}\n\n    # /.json should be 403 (no view-instance permission)\n    response = await perms_ds.client.get(\"/.json\", cookies=cookies)\n    assert response.status_code == 403\n\n    # /perms_ds_one.json should be 403 (no view-database permission)\n    response = await perms_ds.client.get(\"/perms_ds_one.json\", cookies=cookies)\n    assert response.status_code == 403\n\n    # /perms_ds_one/t1.json should be 200\n    response = await perms_ds.client.get(\"/perms_ds_one/t1.json\", cookies=cookies)\n    assert response.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_view_instance_only(perms_ds):\n    \"\"\"Test actor restricted to view-instance only - issue #2534\"\"\"\n\n    actor = {\"id\": \"user\", \"_r\": {\"a\": [\"vi\"]}}\n    cookies = {\"ds_actor\": perms_ds.client.actor_cookie(actor)}\n\n    # /.json should be 200 (has view-instance permission)\n    response = await perms_ds.client.get(\"/.json\", cookies=cookies)\n    assert response.status_code == 200\n\n    # But no databases should be visible (no view-database permission)\n    # The instance is visible but databases list should be empty or minimal\n    # Actually, let's check via allowed_resources\n    page = await perms_ds.allowed_resources(\"view-database\", actor)\n    assert len(page.resources) == 0\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_empty_allowlist(perms_ds):\n    \"\"\"Test actor with empty restrictions allowlist denies everything - issue #2534\"\"\"\n\n    actor = {\"id\": \"user\", \"_r\": {}}\n\n    # No actions in allowlist, so everything should be denied\n    page1 = await perms_ds.allowed_resources(\"view-table\", actor)\n    assert len(page1.resources) == 0\n\n    page2 = await perms_ds.allowed_resources(\"view-database\", actor)\n    assert len(page2.resources) == 0\n\n    result = await perms_ds.allowed(action=\"view-instance\", actor=actor)\n    assert result is False\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_cannot_be_overridden_by_config():\n    \"\"\"Test that config permissions cannot override actor restrictions - issue #2534\"\"\"\n    from datasette.app import Datasette\n    from datasette.resources import TableResource\n\n    # Create datasette with config that allows user to access both t1 AND t2\n    config = {\n        \"databases\": {\n            \"test_db\": {\n                \"tables\": {\n                    \"t1\": {\"allow\": {\"id\": \"user\"}},\n                    \"t2\": {\"allow\": {\"id\": \"user\"}},\n                }\n            }\n        }\n    }\n\n    ds = Datasette(config=config)\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_db\")\n    await db.execute_write(\"create table t1 (id integer primary key)\")\n    await db.execute_write(\"create table t2 (id integer primary key)\")\n\n    # Actor restricted to ONLY t1 (not t2)\n    # Even though config allows t2, restrictions should deny it\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"test_db\": {\"t1\": [\"vt\"]}}}}\n\n    # t1 should be allowed (in restrictions AND config allows)\n    result = await ds.allowed(\n        action=\"view-table\", resource=TableResource(\"test_db\", \"t1\"), actor=actor\n    )\n    assert result is True, \"t1 should be allowed - in restriction allowlist\"\n\n    # t2 should be DENIED (not in restrictions, even though config allows)\n    result = await ds.allowed(\n        action=\"view-table\", resource=TableResource(\"test_db\", \"t2\"), actor=actor\n    )\n    assert (\n        result is False\n    ), \"t2 should be denied - NOT in restriction allowlist, config cannot override\"\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_with_database_level_config(perms_ds):\n    \"\"\"Test database-level restrictions with table-level config - issue #2534\"\"\"\n    from datasette.resources import TableResource\n\n    # Config allows specific tables only\n    perms_ds._config = {\n        \"databases\": {\n            \"perms_ds_one\": {\n                \"tables\": {\n                    \"t1\": {\"allow\": {\"id\": \"user\"}},\n                    \"t2\": {\"allow\": {\"id\": \"user\"}},\n                }\n            }\n        }\n    }\n\n    # Actor has database-level restriction (all tables in perms_ds_one)\n    # Should only access tables that pass BOTH restrictions AND config\n    actor = {\"id\": \"user\", \"_r\": {\"d\": {\"perms_ds_one\": [\"vt\"]}}}\n\n    # t1 - in restrictions (all tables) AND config allows\n    result = await perms_ds.allowed(\n        action=\"view-table\", resource=TableResource(\"perms_ds_one\", \"t1\"), actor=actor\n    )\n    assert result is True\n\n    # t2 - in restrictions (all tables) AND config allows\n    result = await perms_ds.allowed(\n        action=\"view-table\", resource=TableResource(\"perms_ds_one\", \"t2\"), actor=actor\n    )\n    assert result is True\n\n    # v1 (view) - in restrictions (all tables) AND config doesn't mention it\n    # Since actor has database-level restriction allowing all tables, v1 is allowed\n    # Config is additive, not restrictive - it doesn't create implicit denies\n    result = await perms_ds.allowed(\n        action=\"view-table\", resource=TableResource(\"perms_ds_one\", \"v1\"), actor=actor\n    )\n    assert result is True, \"v1 should be allowed - actor has db-level restriction\"\n\n    # Clean up\n    perms_ds._config = None\n\n\n@pytest.mark.asyncio\nasync def test_actor_restrictions_parent_deny_blocks_config_child_allow(perms_ds):\n    \"\"\"\n    Test that table-level restrictions add parent-level deny to block\n    other tables in the same database, even if config allows them\n    \"\"\"\n    from datasette.resources import TableResource\n\n    # Config allows both t1 and t2\n    perms_ds._config = {\n        \"databases\": {\n            \"perms_ds_one\": {\n                \"tables\": {\n                    \"t1\": {\"allow\": {\"id\": \"user\"}},\n                    \"t2\": {\"allow\": {\"id\": \"user\"}},\n                }\n            }\n        }\n    }\n\n    # Restriction allows ONLY t1 in perms_ds_one\n    # This should add:\n    # - parent-level DENY for perms_ds_one (to block other tables)\n    # - child-level ALLOW for t1\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"perms_ds_one\": {\"t1\": [\"vt\"]}}}}\n\n    # t1 should work (child-level allow beats parent-level deny)\n    result = await perms_ds.allowed(\n        action=\"view-table\", resource=TableResource(\"perms_ds_one\", \"t1\"), actor=actor\n    )\n    assert result is True\n\n    # t2 should be DENIED by parent-level deny from restrictions\n    # even though config has child-level allow\n    # Because restrictions should run first\n    result = await perms_ds.allowed(\n        action=\"view-table\", resource=TableResource(\"perms_ds_one\", \"t2\"), actor=actor\n    )\n    assert (\n        result is False\n    ), \"t2 should be denied - restriction parent deny should beat config child allow\"\n\n    # Clean up\n    perms_ds._config = None\n\n\n@pytest.mark.asyncio\nasync def test_permission_check_view_requires_debug_permission():\n    \"\"\"Test that /-/check requires permissions-debug permission\"\"\"\n    # Anonymous user should be denied\n    ds = Datasette()\n    response = await ds.client.get(\"/-/check.json?action=view-instance\")\n    assert response.status_code == 403\n    assert \"permissions-debug\" in response.text\n\n    # User without permissions-debug should be denied\n    response = await ds.client.get(\n        \"/-/check.json?action=view-instance\",\n        cookies={\"ds_actor\": ds.sign({\"id\": \"user\"}, \"actor\")},\n    )\n    assert response.status_code == 403\n\n    # Root user should have access (root has all permissions)\n    ds_with_root = Datasette()\n    ds_with_root.root_enabled = True\n    root_token = await ds_with_root.create_token(\"root\", handler=\"signed\")\n    response = await ds_with_root.client.get(\n        \"/-/check.json?action=view-instance\",\n        headers={\"Authorization\": f\"Bearer {root_token}\"},\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"action\"] == \"view-instance\"\n    assert data[\"allowed\"] is True\n\n\n@pytest.mark.asyncio\nasync def test_root_allow_block_with_table_restricted_actor():\n    \"\"\"\n    Test that root-level allow: blocks are processed for actors with\n    table-level restrictions.\n\n    This covers the case in config.py is_in_restriction_allowlist() where\n    parent=None, child=None and actor has table restrictions but not global.\n    \"\"\"\n    from datasette.resources import TableResource\n\n    # Config with root-level allow block that denies non-admin users\n    ds = Datasette(\n        config={\n            \"allow\": {\"id\": \"admin\"},  # Root-level allow block\n        }\n    )\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"mydb\")\n    await db.execute_write(\"create table t1 (id integer primary key)\")\n    await ds.client.get(\"/\")  # Trigger catalog refresh\n\n    # Actor with table-level restrictions only (not global)\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"mydb\": {\"t1\": [\"view-table\"]}}}}\n\n    # The root-level allow: {id: admin} should be processed and deny this user\n    # because they're not \"admin\", even though they have table restrictions\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"mydb\", \"t1\"),\n        actor=actor,\n    )\n    # Should be False because root allow: {id: admin} denies non-admin users\n    assert result is False\n\n    # But admin with same restrictions should be allowed\n    admin_actor = {\"id\": \"admin\", \"_r\": {\"r\": {\"mydb\": {\"t1\": [\"view-table\"]}}}}\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"mydb\", \"t1\"),\n        actor=admin_actor,\n    )\n    assert result is True\n"
  },
  {
    "path": "tests/test_plugins.py",
    "content": "from bs4 import BeautifulSoup as Soup\nfrom .fixtures import (\n    make_app_client,\n    TABLES,\n    TEMP_PLUGIN_SECRET_FILE,\n    PLUGINS_DIR,\n    TestClient as _TestClient,\n)  # noqa\nfrom click.testing import CliRunner\nfrom datasette.app import Datasette\nfrom datasette import cli, hookimpl\nfrom datasette.filters import FilterArguments\nfrom datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm\nfrom datasette.permissions import PermissionSQL, Action\nfrom datasette.resources import DatabaseResource\nfrom datasette.utils.sqlite import sqlite3\nfrom datasette.utils import StartupError, await_me_maybe\nfrom jinja2 import ChoiceLoader, FileSystemLoader\nimport base64\nimport datetime\nimport importlib\nimport json\nimport os\nimport pathlib\nimport re\nimport textwrap\nimport pytest\nimport urllib\n\nat_memory_re = re.compile(r\" at 0x\\w+\")\n\n\n@pytest.mark.parametrize(\n    \"plugin_hook\", [name for name in dir(pm.hook) if not name.startswith(\"_\")]\n)\ndef test_plugin_hooks_have_tests(plugin_hook):\n    \"\"\"Every plugin hook should be referenced in this test module\"\"\"\n    tests_in_this_module = [t for t in globals().keys() if t.startswith(\"test_hook_\")]\n    ok = False\n    for test in tests_in_this_module:\n        if plugin_hook in test:\n            ok = True\n    assert ok, f\"Plugin hook is missing tests: {plugin_hook}\"\n\n\n@pytest.mark.asyncio\nasync def test_hook_plugins_dir_plugin_prepare_connection(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?_shape=arrayfirst&sql=select+convert_units(100%2C+'m'%2C+'ft')\"\n    )\n    assert response.json()[0] == pytest.approx(328.0839)\n\n\n@pytest.mark.asyncio\nasync def test_hook_plugin_prepare_connection_arguments(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=select+prepare_connection_args()&_shape=arrayfirst\"\n    )\n    assert [\n        \"database=fixtures, datasette.plugin_config(\\\"name-of-plugin\\\")={'depth': 'root'}\"\n    ] == response.json()\n\n    # Function should not be available on the internal database\n    db = ds_client.ds.get_internal_database()\n    with pytest.raises(sqlite3.OperationalError):\n        await db.execute(\"select prepare_connection_args()\")\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_decoded_object\",\n    [\n        (\n            \"/\",\n            {\n                \"template\": \"index.html\",\n                \"database\": None,\n                \"table\": None,\n                \"view_name\": \"index\",\n                \"request_path\": \"/\",\n                \"added\": 15,\n                \"columns\": None,\n            },\n        ),\n        (\n            \"/fixtures\",\n            {\n                \"template\": \"database.html\",\n                \"database\": \"fixtures\",\n                \"table\": None,\n                \"view_name\": \"database\",\n                \"request_path\": \"/fixtures\",\n                \"added\": 15,\n                \"columns\": None,\n            },\n        ),\n        (\n            \"/fixtures/sortable\",\n            {\n                \"template\": \"table.html\",\n                \"database\": \"fixtures\",\n                \"table\": \"sortable\",\n                \"view_name\": \"table\",\n                \"request_path\": \"/fixtures/sortable\",\n                \"added\": 15,\n                \"columns\": [\n                    \"pk1\",\n                    \"pk2\",\n                    \"content\",\n                    \"sortable\",\n                    \"sortable_with_nulls\",\n                    \"sortable_with_nulls_2\",\n                    \"text\",\n                ],\n            },\n        ),\n    ],\n)\nasync def test_hook_extra_css_urls(ds_client, path, expected_decoded_object):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    links = Soup(response.text, \"html.parser\").find_all(\"link\")\n    special_href = [\n        link\n        for link in links\n        if link.attrs[\"href\"].endswith(\"/extra-css-urls-demo.css\")\n    ][0][\"href\"]\n    # This link has a base64-encoded JSON blob in it\n    encoded = special_href.split(\"/\")[3]\n    actual_decoded_object = json.loads(base64.b64decode(encoded).decode(\"utf8\"))\n    assert expected_decoded_object == actual_decoded_object\n\n\n@pytest.mark.asyncio\nasync def test_hook_extra_js_urls(ds_client):\n    response = await ds_client.get(\"/\")\n    scripts = Soup(response.text, \"html.parser\").find_all(\"script\")\n    script_attrs = [s.attrs for s in scripts]\n    for attrs in [\n        {\n            \"integrity\": \"SRIHASH\",\n            \"crossorigin\": \"anonymous\",\n            \"src\": \"https://plugin-example.datasette.io/jquery.js\",\n        },\n        {\n            \"src\": \"https://plugin-example.datasette.io/plugin.module.js\",\n            \"type\": \"module\",\n        },\n    ]:\n        assert any(s == attrs for s in script_attrs), \"Expected: {}\".format(attrs)\n\n\n@pytest.mark.asyncio\nasync def test_plugins_with_duplicate_js_urls(ds_client):\n    # If two plugins both require jQuery, jQuery should be loaded only once\n    response = await ds_client.get(\"/fixtures\")\n    # This test is a little tricky, as if the user has any other plugins in\n    # their current virtual environment those may affect what comes back too.\n    # What matters is that https://plugin-example.datasette.io/jquery.js is only there once\n    # and it comes before plugin1.js and plugin2.js which could be in either\n    # order\n    scripts = Soup(response.text, \"html.parser\").find_all(\"script\")\n    srcs = [s[\"src\"] for s in scripts if s.get(\"src\")]\n    # No duplicates allowed:\n    assert len(srcs) == len(set(srcs))\n    # jquery.js loaded once:\n    assert 1 == srcs.count(\"https://plugin-example.datasette.io/jquery.js\")\n    # plugin1.js and plugin2.js are both there:\n    assert 1 == srcs.count(\"https://plugin-example.datasette.io/plugin1.js\")\n    assert 1 == srcs.count(\"https://plugin-example.datasette.io/plugin2.js\")\n    # jquery comes before them both\n    assert srcs.index(\"https://plugin-example.datasette.io/jquery.js\") < srcs.index(\n        \"https://plugin-example.datasette.io/plugin1.js\"\n    )\n    assert srcs.index(\"https://plugin-example.datasette.io/jquery.js\") < srcs.index(\n        \"https://plugin-example.datasette.io/plugin2.js\"\n    )\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_link_from_json(ds_client):\n    sql = \"\"\"\n        select '{\"href\": \"http://example.com/\", \"label\":\"Example\"}'\n    \"\"\".strip()\n    path = \"/fixtures/-/query?\" + urllib.parse.urlencode({\"sql\": sql})\n    response = await ds_client.get(path)\n    td = Soup(response.text, \"html.parser\").find(\"table\").find(\"tbody\").find(\"td\")\n    a = td.find(\"a\")\n    assert a is not None, str(a)\n    assert a.attrs[\"href\"] == \"http://example.com/\"\n    assert a.attrs[\"data-database\"] == \"fixtures\"\n    assert a.text == \"Example\"\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_demo(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/simple_primary_key?id=4&_render_cell_extra=1\"\n    )\n    soup = Soup(response.text, \"html.parser\")\n    td = soup.find(\"td\", {\"class\": \"col-content\"})\n    assert json.loads(td.string) == {\n        \"row\": {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        \"column\": \"content\",\n        \"table\": \"simple_primary_key\",\n        \"database\": \"fixtures\",\n        \"pks\": [\"id\"],\n        \"config\": {\"depth\": \"table\", \"special\": \"this-is-simple_primary_key\"},\n        \"render_cell_extra\": 1,\n    }\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_pks_single_pk(ds_client):\n    \"\"\"pks should be [\"id\"] for a table with a single primary key\"\"\"\n    response = await ds_client.get(\"/fixtures/simple_primary_key?id=4\")\n    soup = Soup(response.text, \"html.parser\")\n    td = soup.find(\"td\", {\"class\": \"col-content\"})\n    data = json.loads(td.string)\n    assert data[\"pks\"] == [\"id\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_pks_compound_pk(ds_client):\n    \"\"\"pks should list all primary key columns for a compound pk table\"\"\"\n    response = await ds_client.get(\"/fixtures/compound_primary_key?pk1=d&pk2=e\")\n    soup = Soup(response.text, \"html.parser\")\n    td = soup.find(\"td\", {\"class\": \"col-content\"})\n    data = json.loads(td.string)\n    assert data[\"pks\"] == [\"pk1\", \"pk2\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_pks_rowid_table(ds_client):\n    \"\"\"pks should be [\"rowid\"] for a table with no explicit primary key\"\"\"\n    response = await ds_client.get(\"/fixtures/no_primary_key?content=RENDER_CELL_DEMO\")\n    soup = Soup(response.text, \"html.parser\")\n    td = soup.find(\"td\", {\"class\": \"col-content\"})\n    data = json.loads(td.string)\n    assert data[\"pks\"] == [\"rowid\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_render_cell_pks_custom_sql(ds_client):\n    \"\"\"pks should be [] for custom SQL queries\"\"\"\n    response = await ds_client.get(\n        \"/fixtures/-/query?sql=select+'RENDER_CELL_DEMO'+as+content\"\n    )\n    soup = Soup(response.text, \"html.parser\")\n    td = soup.find(\"td\", {\"class\": \"col-content\"})\n    data = json.loads(td.string)\n    assert data[\"pks\"] == []\n    assert data[\"table\"] is None\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path\",\n    (\n        \"/fixtures/-/query?sql=select+'RENDER_CELL_ASYNC'\",\n        \"/fixtures/simple_primary_key\",\n    ),\n)\nasync def test_hook_render_cell_async(ds_client, path):\n    response = await ds_client.get(path)\n    assert b\"RENDER_CELL_ASYNC_RESULT\" in response.content\n\n\n@pytest.mark.asyncio\nasync def test_plugin_config(ds_client):\n    assert {\"depth\": \"table\"} == ds_client.ds.plugin_config(\n        \"name-of-plugin\", database=\"fixtures\", table=\"sortable\"\n    )\n    assert {\"depth\": \"database\"} == ds_client.ds.plugin_config(\n        \"name-of-plugin\", database=\"fixtures\", table=\"unknown_table\"\n    )\n    assert {\"depth\": \"database\"} == ds_client.ds.plugin_config(\n        \"name-of-plugin\", database=\"fixtures\"\n    )\n    assert {\"depth\": \"root\"} == ds_client.ds.plugin_config(\n        \"name-of-plugin\", database=\"unknown_database\"\n    )\n    assert {\"depth\": \"root\"} == ds_client.ds.plugin_config(\"name-of-plugin\")\n    assert None is ds_client.ds.plugin_config(\"unknown-plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_plugin_config_env(ds_client, monkeypatch):\n    monkeypatch.setenv(\"FOO_ENV\", \"FROM_ENVIRONMENT\")\n    assert ds_client.ds.plugin_config(\"env-plugin\") == {\"foo\": \"FROM_ENVIRONMENT\"}\n\n\n@pytest.mark.asyncio\nasync def test_plugin_config_env_from_config(monkeypatch):\n    monkeypatch.setenv(\"FOO_ENV\", \"FROM_ENVIRONMENT_2\")\n    datasette = Datasette(\n        config={\"plugins\": {\"env-plugin\": {\"setting\": {\"$env\": \"FOO_ENV\"}}}}\n    )\n    assert datasette.plugin_config(\"env-plugin\") == {\"setting\": \"FROM_ENVIRONMENT_2\"}\n\n\n@pytest.mark.asyncio\nasync def test_plugin_config_env_from_list(ds_client):\n    os.environ[\"FOO_ENV\"] = \"FROM_ENVIRONMENT\"\n    assert [{\"in_a_list\": \"FROM_ENVIRONMENT\"}] == ds_client.ds.plugin_config(\n        \"env-plugin-list\"\n    )\n    del os.environ[\"FOO_ENV\"]\n\n\n@pytest.mark.asyncio\nasync def test_plugin_config_file(ds_client):\n    with open(TEMP_PLUGIN_SECRET_FILE, \"w\") as fp:\n        fp.write(\"FROM_FILE\")\n    assert {\"foo\": \"FROM_FILE\"} == ds_client.ds.plugin_config(\"file-plugin\")\n    os.remove(TEMP_PLUGIN_SECRET_FILE)\n\n\n@pytest.mark.parametrize(\n    \"path,expected_extra_body_script\",\n    [\n        (\n            \"/\",\n            {\n                \"template\": \"index.html\",\n                \"database\": None,\n                \"table\": None,\n                \"config\": {\"depth\": \"root\"},\n                \"view_name\": \"index\",\n                \"request_path\": \"/\",\n                \"added\": 15,\n                \"columns\": None,\n            },\n        ),\n        (\n            \"/fixtures\",\n            {\n                \"template\": \"database.html\",\n                \"database\": \"fixtures\",\n                \"table\": None,\n                \"config\": {\"depth\": \"database\"},\n                \"view_name\": \"database\",\n                \"request_path\": \"/fixtures\",\n                \"added\": 15,\n                \"columns\": None,\n            },\n        ),\n        (\n            \"/fixtures/sortable\",\n            {\n                \"template\": \"table.html\",\n                \"database\": \"fixtures\",\n                \"table\": \"sortable\",\n                \"config\": {\"depth\": \"table\"},\n                \"view_name\": \"table\",\n                \"request_path\": \"/fixtures/sortable\",\n                \"added\": 15,\n                \"columns\": [\n                    \"pk1\",\n                    \"pk2\",\n                    \"content\",\n                    \"sortable\",\n                    \"sortable_with_nulls\",\n                    \"sortable_with_nulls_2\",\n                    \"text\",\n                ],\n            },\n        ),\n    ],\n)\ndef test_hook_extra_body_script(app_client, path, expected_extra_body_script):\n    r = re.compile(r\"<script type=\\\"module\\\">var extra_body_script = (.*?);</script>\")\n    response = app_client.get(path)\n    assert response.status_code == 200, response.text\n    match = r.search(response.text)\n    assert match is not None, \"No extra_body_script found in HTML\"\n    json_data = match.group(1)\n    actual_data = json.loads(json_data)\n    assert expected_extra_body_script == actual_data\n\n\n@pytest.mark.asyncio\nasync def test_hook_asgi_wrapper(ds_client):\n    response = await ds_client.get(\"/fixtures\")\n    assert \"fixtures\" == response.headers[\"x-databases\"]\n\n\ndef test_hook_extra_template_vars(restore_working_directory):\n    with make_app_client(\n        template_dir=str(pathlib.Path(__file__).parent / \"test_templates\")\n    ) as client:\n        response = client.get(\"/-/versions\")\n        assert response.status_code == 200\n        extra_template_vars = json.loads(\n            Soup(response.text, \"html.parser\").select(\"pre.extra_template_vars\")[0].text\n        )\n        assert {\n            \"template\": \"show_json.html\",\n            \"scope_path\": \"/-/versions\",\n            \"columns\": None,\n        } == extra_template_vars\n        extra_template_vars_from_awaitable = json.loads(\n            Soup(response.text, \"html.parser\")\n            .select(\"pre.extra_template_vars_from_awaitable\")[0]\n            .text\n        )\n        assert {\n            \"template\": \"show_json.html\",\n            \"awaitable\": True,\n            \"scope_path\": \"/-/versions\",\n        } == extra_template_vars_from_awaitable\n\n\ndef test_plugins_async_template_function(restore_working_directory):\n    with make_app_client(\n        template_dir=str(pathlib.Path(__file__).parent / \"test_templates\")\n    ) as client:\n        response = client.get(\"/-/versions\")\n        assert response.status_code == 200\n        extra_from_awaitable_function = (\n            Soup(response.text, \"html.parser\")\n            .select(\"pre.extra_from_awaitable_function\")[0]\n            .text\n        )\n        expected = (\n            sqlite3.connect(\":memory:\").execute(\"select sqlite_version()\").fetchone()[0]\n        )\n        assert expected == extra_from_awaitable_function\n\n\ndef test_default_plugins_have_no_templates_path_or_static_path():\n    # The default plugins that ship with Datasette should have their static_path and\n    # templates_path all set to None\n    plugins = get_plugins()\n    for plugin in plugins:\n        if plugin[\"name\"] in DEFAULT_PLUGINS:\n            assert None is plugin[\"static_path\"]\n            assert None is plugin[\"templates_path\"]\n\n\n@pytest.fixture(scope=\"session\")\ndef view_names_client(tmp_path_factory):\n    tmpdir = tmp_path_factory.mktemp(\"test-view-names\")\n    templates = tmpdir / \"templates\"\n    templates.mkdir()\n    plugins = tmpdir / \"plugins\"\n    plugins.mkdir()\n    for template in (\n        \"index.html\",\n        \"database.html\",\n        \"table.html\",\n        \"row.html\",\n        \"show_json.html\",\n        \"query.html\",\n    ):\n        (templates / template).write_text(\"view_name:{{ view_name }}\", \"utf-8\")\n    (plugins / \"extra_vars.py\").write_text(\n        textwrap.dedent(\"\"\"\n        from datasette import hookimpl\n        @hookimpl\n        def extra_template_vars(view_name):\n            return {\"view_name\": view_name}\n    \"\"\"),\n        \"utf-8\",\n    )\n    db_path = str(tmpdir / \"fixtures.db\")\n    conn = sqlite3.connect(db_path)\n    conn.executescript(TABLES)\n    return _TestClient(\n        Datasette([db_path], template_dir=str(templates), plugins_dir=str(plugins))\n    )\n\n\n@pytest.mark.parametrize(\n    \"path,view_name\",\n    (\n        (\"/\", \"index\"),\n        (\"/fixtures\", \"database\"),\n        (\"/fixtures/facetable\", \"table\"),\n        (\"/fixtures/facetable/1\", \"row\"),\n        (\"/-/versions\", \"json_data\"),\n        (\"/fixtures/-/query?sql=select+1\", \"database\"),\n    ),\n)\ndef test_view_names(view_names_client, path, view_name):\n    response = view_names_client.get(path)\n    assert response.status_code == 200\n    assert f\"view_name:{view_name}\" == response.text\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_no_parameters(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.testnone\")\n    assert response.status_code == 200\n    assert b\"Hello\" == response.content\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_all_parameters(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.testall\")\n    assert response.status_code == 200\n    # Lots of 'at 0x103a4a690' in here - replace those so we can do\n    # an easy comparison\n    body = at_memory_re.sub(\" at 0xXXX\", response.text)\n    assert json.loads(body) == {\n        \"datasette\": \"<datasette.app.Datasette object at 0xXXX>\",\n        \"columns\": [\n            \"pk\",\n            \"created\",\n            \"planet_int\",\n            \"on_earth\",\n            \"state\",\n            \"_city_id\",\n            \"_neighborhood\",\n            \"tags\",\n            \"complex_array\",\n            \"distinct_some_null\",\n            \"n\",\n        ],\n        \"rows\": [\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n            \"<sqlite3.Row object at 0xXXX>\",\n        ],\n        \"sql\": \"select pk, created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n from facetable order by pk limit 51\",\n        \"query_name\": None,\n        \"database\": \"fixtures\",\n        \"table\": \"facetable\",\n        \"request\": '<asgi.Request method=\"GET\" url=\"http://localhost/fixtures/facetable.testall\">',\n        \"view_name\": \"table\",\n        \"1+1\": 2,\n    }\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_custom_status_code(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/pragma_cache_size.testall?status_code=202\"\n    )\n    assert response.status_code == 202\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_custom_content_type(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/pragma_cache_size.testall?content_type=text/blah\"\n    )\n    assert \"text/blah\" == response.headers[\"content-type\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_custom_headers(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/pragma_cache_size.testall?header=x-wow:1&header=x-gosh:2\"\n    )\n    assert \"1\" == response.headers[\"x-wow\"]\n    assert \"2\" == response.headers[\"x-gosh\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_returning_response(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.testresponse\")\n    assert response.status_code == 200\n    assert response.json() == {\"this_is\": \"json\"}\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_returning_broken_value(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.testresponse?_broken=1\")\n    assert response.status_code == 500\n    assert \"this should break should be dict or Response\" in response.text\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_output_renderer_can_render(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable?_no_can_render=1\")\n    assert response.status_code == 200\n    links = (\n        Soup(response.text, \"html.parser\")\n        .find(\"p\", {\"class\": \"export-links\"})\n        .find_all(\"a\")\n    )\n    actual = [link[\"href\"] for link in links]\n    # Should not be present because we sent ?_no_can_render=1\n    assert \"/fixtures/facetable.testall?_labels=on\" not in actual\n    # Check that it was passed the values we expected\n    assert hasattr(ds_client.ds, \"_can_render_saw\")\n    assert {\n        \"datasette\": ds_client.ds,\n        \"columns\": [\n            \"pk\",\n            \"created\",\n            \"planet_int\",\n            \"on_earth\",\n            \"state\",\n            \"_city_id\",\n            \"_neighborhood\",\n            \"tags\",\n            \"complex_array\",\n            \"distinct_some_null\",\n            \"n\",\n        ],\n        \"sql\": \"select pk, created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n from facetable order by pk limit 51\",\n        \"query_name\": None,\n        \"database\": \"fixtures\",\n        \"table\": \"facetable\",\n        \"view_name\": \"table\",\n    }.items() <= ds_client.ds._can_render_saw.items()\n\n\n@pytest.mark.asyncio\nasync def test_hook_prepare_jinja2_environment(ds_client):\n    ds_client.ds._HELLO = \"HI\"\n    await ds_client.ds.invoke_startup()\n    environment = ds_client.ds.get_jinja_environment(None)\n    template = environment.from_string(\n        \"Hello there, {{ a|format_numeric }}, {{ a|to_hello }}, {{ b|select_times_three }}\",\n        {\"a\": 3412341, \"b\": 5},\n    )\n    rendered = await ds_client.ds.render_template(template)\n    assert \"Hello there, 3,412,341, HI, 15\" == rendered\n\n\ndef test_hook_publish_subcommand():\n    # This is hard to test properly, because publish subcommand plugins\n    # cannot be loaded using the --plugins-dir mechanism - they need\n    # to be installed using \"pip install\". So I'm cheating and taking\n    # advantage of the fact that cloudrun/heroku use the plugin hook\n    # to register themselves as default plugins.\n    assert [\"cloudrun\", \"heroku\"] == cli.publish.list_commands({})\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_facet_classes(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets\"\n    )\n    assert response.json()[\"suggested_facets\"] == [\n        {\n            \"name\": \"pk1\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet_dummy=pk1\",\n            \"type\": \"dummy\",\n        },\n        {\n            \"name\": \"pk2\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet_dummy=pk2\",\n            \"type\": \"dummy\",\n        },\n        {\n            \"name\": \"pk3\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet_dummy=pk3\",\n            \"type\": \"dummy\",\n        },\n        {\n            \"name\": \"content\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet_dummy=content\",\n            \"type\": \"dummy\",\n        },\n        {\n            \"name\": \"pk1\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet=pk1\",\n        },\n        {\n            \"name\": \"pk2\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet=pk2\",\n        },\n        {\n            \"name\": \"pk3\",\n            \"toggle_url\": \"http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_extra=suggested_facets&_facet=pk3\",\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_actor_from_request(ds_client):\n    await ds_client.get(\"/\")\n    # Should have no actor\n    assert ds_client.ds._last_request.scope[\"actor\"] is None\n    await ds_client.get(\"/?_bot=1\")\n    # Should have bot actor\n    assert ds_client.ds._last_request.scope[\"actor\"] == {\"id\": \"bot\"}\n\n\n@pytest.mark.asyncio\nasync def test_hook_actor_from_request_async(ds_client):\n    await ds_client.get(\"/\")\n    # Should have no actor\n    assert ds_client.ds._last_request.scope[\"actor\"] is None\n    await ds_client.get(\"/?_bot2=1\")\n    # Should have bot2 actor\n    assert ds_client.ds._last_request.scope[\"actor\"] == {\"id\": \"bot2\", \"1+1\": 2}\n\n\n@pytest.mark.asyncio\nasync def test_existing_scope_actor_respected(ds_client):\n    await ds_client.get(\"/?_actor_in_scope=1\")\n    assert ds_client.ds._last_request.scope[\"actor\"] == {\"id\": \"from-scope\"}\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"action,expected\",\n    [\n        (\"this_is_allowed\", True),\n        (\"this_is_denied\", False),\n        (\"this_is_allowed_async\", True),\n        (\"this_is_denied_async\", False),\n    ],\n)\nasync def test_hook_custom_allowed(action, expected):\n    # Test actions and permission logic are defined in tests/plugins/my_plugin.py\n    ds = Datasette(plugins_dir=PLUGINS_DIR)\n    await ds.invoke_startup()\n    actual = await ds.allowed(action=action, actor={\"id\": \"actor\"})\n    assert expected == actual\n\n\n@pytest.mark.asyncio\nasync def test_hook_permission_resources_sql():\n    ds = Datasette()\n    await ds.invoke_startup()\n\n    collected = []\n    for block in ds.pm.hook.permission_resources_sql(\n        datasette=ds,\n        actor={\"id\": \"alice\"},\n        action=\"view-table\",\n    ):\n        block = await await_me_maybe(block)\n        if block is None:\n            continue\n        if isinstance(block, (list, tuple)):\n            collected.extend(block)\n        else:\n            collected.append(block)\n\n    assert collected\n    assert all(isinstance(item, PermissionSQL) for item in collected)\n\n\n@pytest.mark.asyncio\nasync def test_actor_json(ds_client):\n    assert (await ds_client.get(\"/-/actor.json\")).json() == {\"actor\": None}\n    assert (await ds_client.get(\"/-/actor.json?_bot2=1\")).json() == {\n        \"actor\": {\"id\": \"bot2\", \"1+1\": 2}\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,body\",\n    [\n        (\"/one/\", \"2\"),\n        (\"/two/Ray?greeting=Hail\", \"Hail Ray\"),\n        (\"/not-async/\", \"This was not async\"),\n    ],\n)\nasync def test_hook_register_routes(ds_client, path, body):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    assert response.text == body\n\n\n@pytest.mark.parametrize(\"configured_path\", (\"path1\", \"path2\"))\ndef test_hook_register_routes_with_datasette(configured_path):\n    with make_app_client(\n        config={\n            \"plugins\": {\n                \"register-route-demo\": {\n                    \"path\": configured_path,\n                }\n            }\n        }\n    ) as client:\n        response = client.get(f\"/{configured_path}/\")\n        assert response.status_code == 200\n        assert configured_path.upper() == response.text\n        # Other one should 404\n        other_path = [p for p in (\"path1\", \"path2\") if configured_path != p][0]\n        assert client.get(f\"/{other_path}/\", follow_redirects=True).status_code == 404\n\n\ndef test_hook_register_routes_override():\n    \"Plugins can over-ride default paths such as /db/table\"\n    with make_app_client(\n        config={\n            \"plugins\": {\n                \"register-route-demo\": {\n                    \"path\": \"blah\",\n                }\n            }\n        }\n    ) as client:\n        response = client.get(\"/db/table\")\n        assert response.status_code == 200\n        assert (\n            response.text\n            == \"/db/table: [('db_name', 'db'), ('table_and_format', 'table')]\"\n        )\n\n\ndef test_hook_register_routes_post(app_client):\n    response = app_client.post(\"/post/\", {\"this is\": \"post data\"}, csrftoken_from=True)\n    assert response.status_code == 200\n    assert \"csrftoken\" in response.json\n    assert response.json[\"this is\"] == \"post data\"\n\n\ndef test_hook_register_routes_csrftoken(restore_working_directory, tmpdir_factory):\n    templates = tmpdir_factory.mktemp(\"templates\")\n    (templates / \"csrftoken_form.html\").write_text(\n        \"CSRFTOKEN: {{ csrftoken() }}\", \"utf-8\"\n    )\n    with make_app_client(template_dir=templates) as client:\n        response = client.get(\"/csrftoken-form/\")\n        expected_token = client.ds._last_request.scope[\"csrftoken\"]()\n        assert f\"CSRFTOKEN: {expected_token}\" == response.text\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_routes_asgi(ds_client):\n    response = await ds_client.get(\"/three/\")\n    assert {\"hello\": \"world\"} == response.json()\n    assert \"1\" == response.headers[\"x-three\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_routes_add_message(ds_client):\n    response = await ds_client.get(\"/add-message/\")\n    assert response.status_code == 200\n    assert response.text == \"Added message\"\n    decoded = ds_client.ds.unsign(response.cookies[\"ds_messages\"], \"messages\")\n    assert decoded == [[\"Hello from messages\", 1]]\n\n\ndef test_hook_register_routes_render_message(restore_working_directory, tmpdir_factory):\n    templates = tmpdir_factory.mktemp(\"templates\")\n    (templates / \"render_message.html\").write_text('{% extends \"base.html\" %}', \"utf-8\")\n    with make_app_client(template_dir=templates) as client:\n        response1 = client.get(\"/add-message/\")\n        response2 = client.get(\"/render-message/\", cookies=response1.cookies)\n        assert 200 == response2.status\n        assert \"Hello from messages\" in response2.text\n\n\n@pytest.mark.asyncio\nasync def test_hook_startup(ds_client):\n    await ds_client.ds.invoke_startup()\n    assert ds_client.ds._startup_hook_fired\n    assert 2 == ds_client.ds._startup_hook_calculation\n\n\n@pytest.mark.asyncio\nasync def test_hook_startup_metadata_available(ds_client):\n    # Metadata from metadata.yaml should be populated before startup() fires\n    assert \"title\" in ds_client.ds._startup_metadata_keys\n\n\n@pytest.mark.asyncio\nasync def test_hook_startup_catalog_populated(ds_client):\n    # Internal catalog tables should be populated before startup() fires\n    assert \"fixtures\" in ds_client.ds._startup_catalog_databases\n\n\n@pytest.mark.asyncio\nasync def test_hook_canned_queries(ds_client):\n    queries = (await ds_client.get(\"/fixtures.json\")).json()[\"queries\"]\n    queries_by_name = {q[\"name\"]: q for q in queries}\n    assert {\n        \"sql\": \"select 2\",\n        \"name\": \"from_async_hook\",\n        \"private\": False,\n    } == queries_by_name[\"from_async_hook\"]\n    assert {\n        \"sql\": \"select 1, 'null' as actor_id\",\n        \"name\": \"from_hook\",\n        \"private\": False,\n    } == queries_by_name[\"from_hook\"]\n\n\n@pytest.mark.asyncio\nasync def test_hook_canned_queries_non_async(ds_client):\n    response = await ds_client.get(\"/fixtures/from_hook.json?_shape=array\")\n    assert [{\"1\": 1, \"actor_id\": \"null\"}] == response.json()\n\n\n@pytest.mark.asyncio\nasync def test_hook_canned_queries_async(ds_client):\n    response = await ds_client.get(\"/fixtures/from_async_hook.json?_shape=array\")\n    assert [{\"2\": 2}] == response.json()\n\n\n@pytest.mark.asyncio\nasync def test_hook_canned_queries_actor(ds_client):\n    assert (\n        await ds_client.get(\"/fixtures/from_hook.json?_bot=1&_shape=array\")\n    ).json() == [{\"1\": 1, \"actor_id\": \"bot\"}]\n\n\ndef test_hook_register_magic_parameters(restore_working_directory):\n    with make_app_client(\n        extra_databases={\"data.db\": \"create table logs (line text)\"},\n        config={\n            \"databases\": {\n                \"data\": {\n                    \"queries\": {\n                        \"runme\": {\n                            \"sql\": \"insert into logs (line) values (:_request_http_version)\",\n                            \"write\": True,\n                        },\n                        \"get_uuid\": {\n                            \"sql\": \"select :_uuid_new\",\n                        },\n                        \"asyncrequest\": {\n                            \"sql\": \"select :_asyncrequest_key\",\n                        },\n                    }\n                }\n            }\n        },\n    ) as client:\n        response = client.post(\"/data/runme\", {}, csrftoken_from=True)\n        assert response.status_code == 302\n        actual = client.get(\"/data/logs.json?_sort_desc=rowid&_shape=array\").json\n        assert [{\"rowid\": 1, \"line\": \"1.1\"}] == actual\n        # Now try the GET request against get_uuid\n        response_get = client.get(\"/data/get_uuid.json?_shape=array\")\n        assert 200 == response_get.status\n        new_uuid = response_get.json[0][\":_uuid_new\"]\n        assert 4 == new_uuid.count(\"-\")\n        # And test the async one\n        response_async = client.get(\"/data/asyncrequest.json?_shape=array\")\n        assert 200 == response_async.status\n        assert response_async.json[0][\":_asyncrequest_key\"] == \"key\"\n\n\ndef test_hook_forbidden(restore_working_directory):\n    with make_app_client(\n        extra_databases={\"data2.db\": \"create table logs (line text)\"},\n        config={\"allow\": {}},\n    ) as client:\n        response = client.get(\"/\")\n        assert response.status_code == 403\n        response2 = client.get(\"/data2\")\n        assert 302 == response2.status\n        assert (\n            response2.headers[\"Location\"]\n            == \"/login?message=You do not have permission to view this database\"\n        )\n        assert (\n            client.ds._last_forbidden_message\n            == \"You do not have permission to view this database\"\n        )\n\n\n@pytest.mark.asyncio\nasync def test_hook_handle_exception(ds_client):\n    await ds_client.get(\"/trigger-error?x=123\")\n    assert hasattr(ds_client.ds, \"_exception_hook_fired\")\n    request, exception = ds_client.ds._exception_hook_fired\n    assert request.url == \"http://localhost/trigger-error?x=123\"\n    assert isinstance(exception, ZeroDivisionError)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"param\", (\"_custom_error\", \"_custom_error_async\"))\nasync def test_hook_handle_exception_custom_response(ds_client, param):\n    response = await ds_client.get(\"/trigger-error?{}=1\".format(param))\n    assert response.text == param\n\n\n@pytest.mark.asyncio\nasync def test_hook_menu_links(ds_client):\n    def get_menu_links(html):\n        soup = Soup(html, \"html.parser\")\n        return [\n            {\"label\": a.text, \"href\": a[\"href\"]} for a in soup.select(\".nav-menu a\")\n        ]\n\n    response = await ds_client.get(\"/\")\n    assert get_menu_links(response.text) == []\n\n    response_2 = await ds_client.get(\"/?_bot=1&_hello=BOB\")\n    assert get_menu_links(response_2.text) == [\n        {\"label\": \"Hello, BOB\", \"href\": \"/\"},\n        {\"label\": \"Hello 2\", \"href\": \"/\"},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_table_actions(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable\")\n    assert get_actions_links(response.text) == []\n    response_2 = await ds_client.get(\"/fixtures/facetable?_bot=1&_hello=BOB\")\n    assert \">Table actions<\" in response_2.text\n    assert sorted(\n        get_actions_links(response_2.text), key=lambda link: link[\"label\"]\n    ) == [\n        {\"label\": \"Database: fixtures\", \"href\": \"/\", \"description\": None},\n        {\"label\": \"From async BOB\", \"href\": \"/\", \"description\": None},\n        {\"label\": \"Table: facetable\", \"href\": \"/\", \"description\": None},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_view_actions(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_view\")\n    assert get_actions_links(response.text) == []\n    response_2 = await ds_client.get(\n        \"/fixtures/simple_view\",\n        cookies={\"ds_actor\": ds_client.actor_cookie({\"id\": \"bob\"})},\n    )\n    assert \">View actions<\" in response_2.text\n    assert sorted(\n        get_actions_links(response_2.text), key=lambda link: link[\"label\"]\n    ) == [\n        {\"label\": \"Database: fixtures\", \"href\": \"/\", \"description\": None},\n        {\"label\": \"View: simple_view\", \"href\": \"/\", \"description\": None},\n    ]\n\n\ndef get_actions_links(html):\n    soup = Soup(html, \"html.parser\")\n    details = soup.find(\"details\", {\"class\": \"actions-menu-links\"})\n    if details is None:\n        return []\n    links = []\n    for a_el in details.select(\"a\"):\n        description = None\n        if a_el.find(\"p\") is not None:\n            description = a_el.find(\"p\").text.strip()\n            a_el.find(\"p\").extract()\n        label = a_el.text.strip()\n        href = a_el[\"href\"]\n        links.append({\"label\": label, \"href\": href, \"description\": description})\n    return links\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_url\",\n    (\n        (\"/fixtures/-/query?sql=select+1\", \"/fixtures/-/query?sql=explain+select+1\"),\n        pytest.param(\n            \"/fixtures/pragma_cache_size\",\n            \"/fixtures/-/query?sql=explain+PRAGMA+cache_size%3B\",\n        ),\n        # Don't attempt to explain an explain\n        (\"/fixtures/-/query?sql=explain+select+1\", None),\n    ),\n)\nasync def test_hook_query_actions(ds_client, path, expected_url):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    links = get_actions_links(response.text)\n    if expected_url is None:\n        assert links == []\n    else:\n        assert links == [\n            {\n                \"label\": \"Explain this query\",\n                \"href\": expected_url,\n                \"description\": \"Runs a SQLite explain\",\n            }\n        ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_row_actions(ds_client):\n    response = await ds_client.get(\"/fixtures/facet_cities/1\")\n    assert get_actions_links(response.text) == []\n\n    response_2 = await ds_client.get(\n        \"/fixtures/facet_cities/1\",\n        cookies={\"ds_actor\": ds_client.actor_cookie({\"id\": \"sam\"})},\n    )\n    assert get_actions_links(response_2.text) == [\n        {\n            \"label\": \"Row details for sam\",\n            \"href\": \"/\",\n            \"description\": '{\"id\": 1, \"name\": \"San Francisco\"}',\n        }\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_database_actions(ds_client):\n    response = await ds_client.get(\"/fixtures\")\n    assert get_actions_links(response.text) == []\n\n    response_2 = await ds_client.get(\"/fixtures?_bot=1&_hello=BOB\")\n    assert get_actions_links(response_2.text) == [\n        {\"label\": \"Database: fixtures - BOB\", \"href\": \"/\", \"description\": None},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_hook_homepage_actions(ds_client):\n    response = await ds_client.get(\"/\")\n    # No button for anonymous users\n    assert \"<span>Homepage actions</span>\" not in response.text\n    # Signed in user gets an action\n    response2 = await ds_client.get(\n        \"/\", cookies={\"ds_actor\": ds_client.actor_cookie({\"id\": \"troy\"})}\n    )\n    assert \"<span>Homepage actions</span>\" in response2.text\n    assert get_actions_links(response2.text) == [\n        {\n            \"label\": \"Custom homepage for: troy\",\n            \"href\": \"/-/custom-homepage\",\n            \"description\": None,\n        },\n    ]\n\n\ndef test_hook_skip_csrf(app_client):\n    cookie = app_client.actor_cookie({\"id\": \"test\"})\n    csrf_response = app_client.post(\n        \"/post/\",\n        post_data={\"this is\": \"post data\"},\n        csrftoken_from=True,\n        cookies={\"ds_actor\": cookie},\n    )\n    assert csrf_response.status_code == 200\n    missing_csrf_response = app_client.post(\n        \"/post/\", post_data={\"this is\": \"post data\"}, cookies={\"ds_actor\": cookie}\n    )\n    assert missing_csrf_response.status_code == 403\n    # But \"/skip-csrf\" should allow\n    allow_csrf_response = app_client.post(\n        \"/skip-csrf\", post_data={\"this is\": \"post data\"}, cookies={\"ds_actor\": cookie}\n    )\n    assert allow_csrf_response.status_code == 405  # Method not allowed\n    # /skip-csrf-2 should not\n    second_missing_csrf_response = app_client.post(\n        \"/skip-csrf-2\", post_data={\"this is\": \"post data\"}, cookies={\"ds_actor\": cookie}\n    )\n    assert second_missing_csrf_response.status_code == 403\n\n\ndef _extract_commands(output):\n    lines = output.split(\"Commands:\\n\", 1)[1].split(\"\\n\")\n    return {line.split()[0].replace(\"*\", \"\") for line in lines if line.strip()}\n\n\ndef test_hook_register_commands():\n    # Without the plugin should have seven commands\n    runner = CliRunner()\n    result = runner.invoke(cli.cli, \"--help\")\n    commands = _extract_commands(result.output)\n    assert commands == {\n        \"serve\",\n        \"inspect\",\n        \"install\",\n        \"package\",\n        \"plugins\",\n        \"publish\",\n        \"uninstall\",\n        \"create-token\",\n    }\n\n    # Now install a plugin\n    class VerifyPlugin:\n        __name__ = \"VerifyPlugin\"\n\n        @hookimpl\n        def register_commands(self, cli):\n            @cli.command()\n            def verify():\n                pass\n\n            @cli.command()\n            def unverify():\n                pass\n\n    pm.register(VerifyPlugin(), name=\"verify\")\n    importlib.reload(cli)\n    result2 = runner.invoke(cli.cli, \"--help\")\n    commands2 = _extract_commands(result2.output)\n    assert commands2 == {\n        \"serve\",\n        \"inspect\",\n        \"install\",\n        \"package\",\n        \"plugins\",\n        \"publish\",\n        \"uninstall\",\n        \"verify\",\n        \"unverify\",\n        \"create-token\",\n    }\n    pm.unregister(name=\"verify\")\n    importlib.reload(cli)\n\n\n@pytest.mark.asyncio\nasync def test_hook_filters_from_request(ds_client):\n    class ReturnNothingPlugin:\n        __name__ = \"ReturnNothingPlugin\"\n\n        @hookimpl\n        def filters_from_request(self, request):\n            if request.args.get(\"_nothing\"):\n                return FilterArguments([\"1 = 0\"], human_descriptions=[\"NOTHING\"])\n\n    ds_client.ds.pm.register(ReturnNothingPlugin(), name=\"ReturnNothingPlugin\")\n    response = await ds_client.get(\"/fixtures/facetable?_nothing=1\")\n    assert \"0 rows\\n        where NOTHING\" in response.text\n    json_response = await ds_client.get(\"/fixtures/facetable.json?_nothing=1\")\n    assert json_response.json()[\"rows\"] == []\n    ds_client.ds.pm.unregister(name=\"ReturnNothingPlugin\")\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"extra_metadata\", (False, True))\nasync def test_hook_register_actions(extra_metadata):\n\n    ds = Datasette(\n        config=(\n            {\n                \"plugins\": {\n                    \"datasette-register-actions\": {\n                        \"actions\": [\n                            {\n                                \"name\": \"extra-from-metadata\",\n                                \"abbr\": \"efm\",\n                                \"description\": \"Extra from metadata\",\n                            }\n                        ]\n                    }\n                }\n            }\n            if extra_metadata\n            else None\n        ),\n        plugins_dir=PLUGINS_DIR,\n    )\n    await ds.invoke_startup()\n    assert ds.actions[\"action-from-plugin\"] == Action(\n        name=\"action-from-plugin\",\n        abbr=\"ap\",\n        description=\"New action added by a plugin\",\n        resource_class=DatabaseResource,\n    )\n    if extra_metadata:\n        assert ds.actions[\"extra-from-metadata\"] == Action(\n            name=\"extra-from-metadata\",\n            abbr=\"efm\",\n            description=\"Extra from metadata\",\n        )\n    else:\n        assert \"extra-from-metadata\" not in ds.actions\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"duplicate\", (\"name\", \"abbr\"))\nasync def test_hook_register_actions_no_duplicates(duplicate):\n    name1, name2 = \"name1\", \"name2\"\n    abbr1, abbr2 = \"abbr1\", \"abbr2\"\n    if duplicate == \"name\":\n        name2 = \"name1\"\n    if duplicate == \"abbr\":\n        abbr2 = \"abbr1\"\n    ds = Datasette(\n        config={\n            \"plugins\": {\n                \"datasette-register-actions\": {\n                    \"actions\": [\n                        {\n                            \"name\": name1,\n                            \"abbr\": abbr1,\n                            \"description\": None,\n                        },\n                        {\n                            \"name\": name2,\n                            \"abbr\": abbr2,\n                            \"description\": None,\n                        },\n                    ]\n                }\n            }\n        },\n        plugins_dir=PLUGINS_DIR,\n    )\n    # This should error:\n    with pytest.raises(StartupError) as ex:\n        await ds.invoke_startup()\n        assert \"Duplicate action {}\".format(duplicate) in str(ex.value)\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_actions_allows_identical_duplicates():\n    ds = Datasette(\n        config={\n            \"plugins\": {\n                \"datasette-register-actions\": {\n                    \"actions\": [\n                        {\n                            \"name\": \"name1\",\n                            \"abbr\": \"abbr1\",\n                            \"description\": None,\n                        },\n                        {\n                            \"name\": \"name1\",\n                            \"abbr\": \"abbr1\",\n                            \"description\": None,\n                        },\n                    ]\n                }\n            }\n        },\n        plugins_dir=PLUGINS_DIR,\n    )\n    await ds.invoke_startup()\n    # Check that ds.actions has only one of each\n    assert len([p for p in ds.actions.values() if p.abbr == \"abbr1\"]) == 1\n\n\n@pytest.mark.asyncio\nasync def test_hook_actors_from_ids():\n    # Without the hook should return default {\"id\": id} list\n    ds = Datasette()\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"actors_from_ids\")\n    await db.execute_write(\n        \"create table actors (id text primary key, name text, age int)\"\n    )\n    await db.execute_write(\n        \"insert into actors (id, name, age) values ('3', 'Cate Blanchett', 52)\"\n    )\n    await db.execute_write(\n        \"insert into actors (id, name, age) values ('5', 'Rooney Mara', 36)\"\n    )\n    await db.execute_write(\n        \"insert into actors (id, name, age) values ('7', 'Sarah Paulson', 46)\"\n    )\n    await db.execute_write(\n        \"insert into actors (id, name, age) values ('9', 'Helena Bonham Carter', 55)\"\n    )\n    table_names = await db.table_names()\n    assert table_names == [\"actors\"]\n    actors1 = await ds.actors_from_ids([\"3\", \"5\", \"7\"])\n    assert actors1 == {\n        \"3\": {\"id\": \"3\"},\n        \"5\": {\"id\": \"5\"},\n        \"7\": {\"id\": \"7\"},\n    }\n\n    class ActorsFromIdsPlugin:\n        __name__ = \"ActorsFromIdsPlugin\"\n\n        @hookimpl\n        def actors_from_ids(self, datasette, actor_ids):\n            db = datasette.get_database(\"actors_from_ids\")\n\n            async def inner():\n                sql = \"select id, name from actors where id in ({})\".format(\n                    \", \".join(\"?\" for _ in actor_ids)\n                )\n                actors = {}\n                result = await db.execute(sql, actor_ids)\n                for row in result.rows:\n                    actor = dict(row)\n                    actors[actor[\"id\"]] = actor\n                return actors\n\n            return inner\n\n    try:\n        ds.pm.register(ActorsFromIdsPlugin(), name=\"ActorsFromIdsPlugin\")\n        actors2 = await ds.actors_from_ids([\"3\", \"5\", \"7\"])\n        assert actors2 == {\n            \"3\": {\"id\": \"3\", \"name\": \"Cate Blanchett\"},\n            \"5\": {\"id\": \"5\", \"name\": \"Rooney Mara\"},\n            \"7\": {\"id\": \"7\", \"name\": \"Sarah Paulson\"},\n        }\n    finally:\n        ds.pm.unregister(name=\"ReturnNothingPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_plugin_is_installed():\n    datasette = Datasette(memory=True)\n\n    class DummyPlugin:\n        __name__ = \"DummyPlugin\"\n\n        @hookimpl\n        def actors_from_ids(self, datasette, actor_ids):\n            return {}\n\n    try:\n        datasette.pm.register(DummyPlugin(), name=\"DummyPlugin\")\n        response = await datasette.client.get(\"/-/plugins.json\")\n        assert response.status_code == 200\n        installed_plugins = {p[\"name\"] for p in response.json()}\n        assert \"DummyPlugin\" in installed_plugins\n\n    finally:\n        datasette.pm.unregister(name=\"DummyPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_jinja2_environment_from_request(tmpdir):\n    templates = pathlib.Path(tmpdir / \"templates\")\n    templates.mkdir()\n    (templates / \"index.html\").write_text(\"Hello museums!\", \"utf-8\")\n\n    class EnvironmentPlugin:\n        @hookimpl\n        def jinja2_environment_from_request(self, request, env):\n            if request and request.host == \"www.niche-museums.com\":\n                return env.overlay(\n                    loader=ChoiceLoader(\n                        [\n                            FileSystemLoader(str(templates)),\n                            env.loader,\n                        ]\n                    ),\n                    enable_async=True,\n                )\n            return env\n\n    datasette = Datasette(memory=True)\n\n    try:\n        datasette.pm.register(EnvironmentPlugin(), name=\"EnvironmentPlugin\")\n        response = await datasette.client.get(\"/\")\n        assert response.status_code == 200\n        assert \"Hello museums!\" not in response.text\n        # Try again with the hostname\n        response2 = await datasette.client.get(\n            \"/\", headers={\"host\": \"www.niche-museums.com\"}\n        )\n        assert response2.status_code == 200\n        assert \"Hello museums!\" in response2.text\n    finally:\n        datasette.pm.unregister(name=\"EnvironmentPlugin\")\n\n\nclass SlotPlugin:\n    __name__ = \"SlotPlugin\"\n\n    @hookimpl\n    def top_homepage(self, request):\n        return \"Xtop_homepage:\" + request.args[\"z\"]\n\n    @hookimpl\n    def top_database(self, request, database):\n        async def inner():\n            return \"Xtop_database:{}:{}\".format(database, request.args[\"z\"])\n\n        return inner\n\n    @hookimpl\n    def top_table(self, request, database, table):\n        return \"Xtop_table:{}:{}:{}\".format(database, table, request.args[\"z\"])\n\n    @hookimpl\n    def top_row(self, request, database, table, row):\n        return \"Xtop_row:{}:{}:{}:{}\".format(\n            database, table, row[\"name\"], request.args[\"z\"]\n        )\n\n    @hookimpl\n    def top_query(self, request, database, sql):\n        return \"Xtop_query:{}:{}:{}\".format(database, sql, request.args[\"z\"])\n\n    @hookimpl\n    def top_canned_query(self, request, database, query_name):\n        return \"Xtop_query:{}:{}:{}\".format(database, query_name, request.args[\"z\"])\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_homepage():\n    datasette = Datasette(memory=True)\n    try:\n        datasette.pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await datasette.client.get(\"/?z=foo\")\n        assert response.status_code == 200\n        assert \"Xtop_homepage:foo\" in response.text\n    finally:\n        datasette.pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_database():\n    datasette = Datasette(memory=True)\n    try:\n        datasette.pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await datasette.client.get(\"/_memory?z=bar\")\n        assert response.status_code == 200\n        assert \"Xtop_database:_memory:bar\" in response.text\n    finally:\n        datasette.pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_table(ds_client):\n    try:\n        ds_client.ds.pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await ds_client.get(\"/fixtures/facetable?z=baz\")\n        assert response.status_code == 200\n        assert \"Xtop_table:fixtures:facetable:baz\" in response.text\n    finally:\n        ds_client.ds.pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_row(ds_client):\n    try:\n        ds_client.ds.pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await ds_client.get(\"/fixtures/facet_cities/1?z=bax\")\n        assert response.status_code == 200\n        assert \"Xtop_row:fixtures:facet_cities:San Francisco:bax\" in response.text\n    finally:\n        ds_client.ds.pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_query(ds_client):\n    try:\n        pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await ds_client.get(\"/fixtures/-/query?sql=select+1&z=x\")\n        assert response.status_code == 200\n        assert \"Xtop_query:fixtures:select 1:x\" in response.text\n    finally:\n        pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_top_canned_query(ds_client):\n    try:\n        pm.register(SlotPlugin(), name=\"SlotPlugin\")\n        response = await ds_client.get(\"/fixtures/from_hook?z=xyz\")\n        assert response.status_code == 200\n        assert \"Xtop_query:fixtures:from_hook:xyz\" in response.text\n    finally:\n        pm.unregister(name=\"SlotPlugin\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_track_event():\n    datasette = Datasette(memory=True)\n    from .conftest import TrackEventPlugin\n\n    await datasette.invoke_startup()\n    await datasette.track_event(\n        TrackEventPlugin.OneEvent(actor=None, extra=\"extra extra\")\n    )\n    assert len(datasette._tracked_events) == 1\n    assert isinstance(datasette._tracked_events[0], TrackEventPlugin.OneEvent)\n    event = datasette._tracked_events[0]\n    assert event.name == \"one\"\n    assert event.properties() == {\"extra\": \"extra extra\"}\n    # Should have a recent created as well\n    created = event.created\n    assert isinstance(created, datetime.datetime)\n    assert created.tzinfo == datetime.timezone.utc\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_events():\n    datasette = Datasette(memory=True)\n    await datasette.invoke_startup()\n    assert any(k.__name__ == \"OneEvent\" for k in datasette.event_classes)\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_token_handler(ds_client):\n    handlers = ds_client.ds._token_handlers()\n    handler_names = [h.name for h in handlers]\n    # Both the default signed handler and the test hardcoded handler\n    assert \"signed\" in handler_names\n    assert \"hardcoded\" in handler_names\n\n    # Create a token using the hardcoded handler (first registered from plugins dir)\n    token = await ds_client.ds.create_token(\"test-user\")\n    assert token.startswith(\"dstok_hardcoded_token_\")\n\n    # Verify it\n    actor = await ds_client.ds.verify_token(token)\n    assert actor[\"id\"] == \"hardcoded-actor\"\n    assert actor[\"token\"] == \"hardcoded\"\n\n    # Create a token by explicitly requesting the hardcoded handler by name\n    token2 = await ds_client.ds.create_token(\"test-user\", handler=\"hardcoded\")\n    assert token2.startswith(\"dstok_hardcoded_token_\")\n    actor2 = await ds_client.ds.verify_token(token2)\n    assert actor2[\"id\"] == \"hardcoded-actor\"\n\n    # Create a token by explicitly requesting the signed handler by name\n    signed_token = await ds_client.ds.create_token(\"test-user\", handler=\"signed\")\n    assert signed_token.startswith(\"dstok_\")\n    assert not signed_token.startswith(\"dstok_hardcoded_token_\")\n    signed_actor = await ds_client.ds.verify_token(signed_token)\n    assert signed_actor[\"id\"] == \"test-user\"\n    assert signed_actor[\"token\"] == \"dstok\"\n\n\n@pytest.mark.asyncio\nasync def test_hook_write_wrapper():\n    datasette = Datasette(memory=True)\n    log = []\n\n    class WrapWritePlugin:\n        __name__ = \"WrapWritePlugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            if database != \"_memory\":\n                return None\n\n            def wrapper(conn):\n                log.append(\"before\")\n                yield\n                log.append(\"after\")\n\n            return wrapper\n\n    pm.register(WrapWritePlugin(), name=\"WrapWritePluginTest\")\n    try:\n        db = datasette.get_database(\"_memory\")\n        await db.execute_write(\"create table t (id integer primary key)\")\n        assert log == [\"before\", \"after\"]\n    finally:\n        pm.unregister(name=\"WrapWritePluginTest\")\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_actions_view_collection():\n    datasette = Datasette(memory=True, plugins_dir=PLUGINS_DIR)\n    await datasette.invoke_startup()\n    # Check that the custom action from my_plugin.py is registered\n    assert \"view-collection\" in datasette.actions\n    action = datasette.actions[\"view-collection\"]\n    assert action.abbr == \"vc\"\n    assert action.description == \"View a collection\"\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_actions_with_custom_resources():\n    \"\"\"\n    Test registering actions with custom Resource classes:\n    - A global action (no resource)\n    - A parent-level action (DocumentCollectionResource)\n    - A child-level action (DocumentResource)\n    \"\"\"\n    from datasette.permissions import Resource\n\n    # Define custom Resource classes\n    class DocumentCollectionResource(Resource):\n        \"\"\"A collection of documents.\"\"\"\n\n        name = \"document_collection\"\n        parent_class = None  # Top-level resource\n\n        def __init__(self, collection: str):\n            super().__init__(parent=collection, child=None)\n\n        @classmethod\n        async def resources_sql(cls, datasette, actor=None) -> str:\n            return \"\"\"\n                SELECT 'collection1' AS parent, NULL AS child\n                UNION ALL\n                SELECT 'collection2' AS parent, NULL AS child\n            \"\"\"\n\n    class DocumentResource(Resource):\n        \"\"\"A document in a collection.\"\"\"\n\n        name = \"document\"\n        parent_class = DocumentCollectionResource  # Child of DocumentCollectionResource\n\n        def __init__(self, collection: str, document: str):\n            super().__init__(parent=collection, child=document)\n\n        @classmethod\n        async def resources_sql(cls, datasette, actor=None) -> str:\n            return \"\"\"\n                SELECT 'collection1' AS parent, 'doc1' AS child\n                UNION ALL\n                SELECT 'collection1' AS parent, 'doc2' AS child\n                UNION ALL\n                SELECT 'collection2' AS parent, 'doc3' AS child\n            \"\"\"\n\n    # Define a test plugin that registers these actions\n    class TestPlugin:\n        __name__ = \"test_custom_resources_plugin\"\n\n        @hookimpl\n        def register_actions(self, datasette):\n            return [\n                # Global action - no resource_class\n                Action(\n                    name=\"manage-documents\",\n                    abbr=\"md\",\n                    description=\"Manage the document system\",\n                ),\n                # Parent-level action - collection only\n                Action(\n                    name=\"view-document-collection\",\n                    description=\"View a document collection\",\n                    resource_class=DocumentCollectionResource,\n                ),\n                # Child-level action - collection + document\n                Action(\n                    name=\"view-document\",\n                    abbr=\"vdoc\",\n                    description=\"View a document\",\n                    resource_class=DocumentResource,\n                ),\n            ]\n\n        @hookimpl\n        def permission_resources_sql(self, datasette, actor, action):\n            from datasette.permissions import PermissionSQL\n\n            # Grant user2 access to manage-documents globally\n            if actor and actor.get(\"id\") == \"user2\" and action == \"manage-documents\":\n                return PermissionSQL.allow(reason=\"user2 granted manage-documents\")\n\n            # Grant user2 access to view-document-collection globally\n            if (\n                actor\n                and actor.get(\"id\") == \"user2\"\n                and action == \"view-document-collection\"\n            ):\n                return PermissionSQL.allow(\n                    reason=\"user2 granted view-document-collection\"\n                )\n\n            # Default allow for view-document-collection (like other view-* actions)\n            if action == \"view-document-collection\":\n                return PermissionSQL.allow(\n                    reason=\"default allow for view-document-collection\"\n                )\n\n            # Default allow for view-document (like other view-* actions)\n            if action == \"view-document\":\n                return PermissionSQL.allow(reason=\"default allow for view-document\")\n\n    # Register the plugin temporarily\n    plugin = TestPlugin()\n    pm.register(plugin, name=\"test_custom_resources_plugin\")\n\n    try:\n        # Create datasette instance and invoke startup\n        datasette = Datasette(memory=True)\n        await datasette.invoke_startup()\n\n        # Test global action\n        manage_docs = datasette.actions[\"manage-documents\"]\n        assert manage_docs.name == \"manage-documents\"\n        assert manage_docs.abbr == \"md\"\n        assert manage_docs.resource_class is None\n        assert manage_docs.takes_parent is False\n        assert manage_docs.takes_child is False\n\n        # Test parent-level action\n        view_collection = datasette.actions[\"view-document-collection\"]\n        assert view_collection.name == \"view-document-collection\"\n        assert view_collection.abbr is None\n        assert view_collection.resource_class is DocumentCollectionResource\n        assert view_collection.takes_parent is True\n        assert view_collection.takes_child is False\n\n        # Test child-level action\n        view_doc = datasette.actions[\"view-document\"]\n        assert view_doc.name == \"view-document\"\n        assert view_doc.abbr == \"vdoc\"\n        assert view_doc.resource_class is DocumentResource\n        assert view_doc.takes_parent is True\n        assert view_doc.takes_child is True\n\n        # Verify the resource classes have correct hierarchy\n        assert DocumentCollectionResource.parent_class is None\n        assert DocumentResource.parent_class is DocumentCollectionResource\n\n        # Test that resources can be instantiated correctly\n        collection_resource = DocumentCollectionResource(collection=\"collection1\")\n        assert collection_resource.parent == \"collection1\"\n        assert collection_resource.child is None\n\n        doc_resource = DocumentResource(collection=\"collection1\", document=\"doc1\")\n        assert doc_resource.parent == \"collection1\"\n        assert doc_resource.child == \"doc1\"\n\n        # Test permission checks with restricted actors\n\n        # Test 1: Global action - no restrictions (custom actions default to deny)\n        unrestricted_actor = {\"id\": \"user1\"}\n        allowed = await datasette.allowed(\n            action=\"manage-documents\",\n            actor=unrestricted_actor,\n        )\n        assert allowed is False  # Custom actions have no default allow\n\n        # Test 2: Global action - user2 has explicit permission via plugin hook\n        restricted_global = {\"id\": \"user2\", \"_r\": {\"a\": [\"md\"]}}\n        allowed = await datasette.allowed(\n            action=\"manage-documents\",\n            actor=restricted_global,\n        )\n        assert allowed is True  # Granted by plugin hook for user2\n\n        # Test 3: Global action - restricted but not in allowlist\n        restricted_no_access = {\"id\": \"user3\", \"_r\": {\"a\": [\"vdc\"]}}\n        allowed = await datasette.allowed(\n            action=\"manage-documents\",\n            actor=restricted_no_access,\n        )\n        assert allowed is False  # Not in allowlist\n\n        # Test 4: Collection-level action - allowed for specific collection\n        collection_resource = DocumentCollectionResource(collection=\"collection1\")\n        # This one does not have an abbreviation:\n        restricted_collection = {\n            \"id\": \"user4\",\n            \"_r\": {\"d\": {\"collection1\": [\"view-document-collection\"]}},\n        }\n        allowed = await datasette.allowed(\n            action=\"view-document-collection\",\n            resource=collection_resource,\n            actor=restricted_collection,\n        )\n        assert allowed is True  # Allowed for collection1\n\n        # Test 5: Collection-level action - denied for different collection\n        collection2_resource = DocumentCollectionResource(collection=\"collection2\")\n        allowed = await datasette.allowed(\n            action=\"view-document-collection\",\n            resource=collection2_resource,\n            actor=restricted_collection,\n        )\n        assert allowed is False  # Not allowed for collection2\n\n        # Test 6: Document-level action - allowed for specific document\n        doc1_resource = DocumentResource(collection=\"collection1\", document=\"doc1\")\n        restricted_document = {\n            \"id\": \"user5\",\n            \"_r\": {\"r\": {\"collection1\": {\"doc1\": [\"vdoc\"]}}},\n        }\n        allowed = await datasette.allowed(\n            action=\"view-document\",\n            resource=doc1_resource,\n            actor=restricted_document,\n        )\n        assert allowed is True  # Allowed for collection1/doc1\n\n        # Test 7: Document-level action - denied for different document\n        doc2_resource = DocumentResource(collection=\"collection1\", document=\"doc2\")\n        allowed = await datasette.allowed(\n            action=\"view-document\",\n            resource=doc2_resource,\n            actor=restricted_document,\n        )\n        assert allowed is False  # Not allowed for collection1/doc2\n\n        # Test 8: Document-level action - globally allowed\n        doc_resource = DocumentResource(collection=\"collection2\", document=\"doc3\")\n        restricted_all_docs = {\"id\": \"user6\", \"_r\": {\"a\": [\"vdoc\"]}}\n        allowed = await datasette.allowed(\n            action=\"view-document\",\n            resource=doc_resource,\n            actor=restricted_all_docs,\n        )\n        assert allowed is True  # Globally allowed for all documents\n\n        # Test 9: Verify hierarchy - collection access doesn't grant document access\n        collection_only_actor = {\"id\": \"user7\", \"_r\": {\"d\": {\"collection1\": [\"vdc\"]}}}\n        doc_resource = DocumentResource(collection=\"collection1\", document=\"doc1\")\n        allowed = await datasette.allowed(\n            action=\"view-document\",\n            resource=doc_resource,\n            actor=collection_only_actor,\n        )\n        assert (\n            allowed is False\n        )  # Collection permission doesn't grant document permission\n\n    finally:\n        # Unregister the plugin\n        pm.unregister(plugin)\n\n\n@pytest.mark.skip(reason=\"TODO\")\n@pytest.mark.parametrize(\n    \"metadata,config,expected_metadata,expected_config\",\n    (\n        (\n            # Instance level\n            {\"plugins\": {\"datasette-foo\": \"bar\"}},\n            {},\n            {},\n            {\"plugins\": {\"datasette-foo\": \"bar\"}},\n        ),\n        (\n            # Database level\n            {\"databases\": {\"foo\": {\"plugins\": {\"datasette-foo\": \"bar\"}}}},\n            {},\n            {},\n            {\"databases\": {\"foo\": {\"plugins\": {\"datasette-foo\": \"bar\"}}}},\n        ),\n        (\n            # Table level\n            {\n                \"databases\": {\n                    \"foo\": {\"tables\": {\"bar\": {\"plugins\": {\"datasette-foo\": \"bar\"}}}}\n                }\n            },\n            {},\n            {},\n            {\n                \"databases\": {\n                    \"foo\": {\"tables\": {\"bar\": {\"plugins\": {\"datasette-foo\": \"bar\"}}}}\n                }\n            },\n        ),\n        (\n            # Keep other keys\n            {\"plugins\": {\"datasette-foo\": \"bar\"}, \"other\": \"key\"},\n            {\"original_config\": \"original\"},\n            {\"other\": \"key\"},\n            {\"original_config\": \"original\", \"plugins\": {\"datasette-foo\": \"bar\"}},\n        ),\n    ),\n)\ndef test_metadata_plugin_config_treated_as_config(\n    metadata, config, expected_metadata, expected_config\n):\n    ds = Datasette(metadata=metadata, config=config)\n    actual_metadata = ds.metadata()\n    assert \"plugins\" not in actual_metadata\n    assert actual_metadata == expected_metadata\n    assert ds.config == expected_config\n\n\n@pytest.mark.asyncio\nasync def test_hook_register_column_types():\n    ds = Datasette()\n    await ds.invoke_startup()\n    # Built-in column types should be registered\n    assert \"url\" in ds._column_types\n    assert \"email\" in ds._column_types\n    assert \"json\" in ds._column_types\n    assert \"nonexistent\" not in ds._column_types\n"
  },
  {
    "path": "tests/test_publish_cloudrun.py",
    "content": "from click.testing import CliRunner\nfrom datasette import cli\nfrom unittest import mock\nimport json\nimport os\nimport pytest\nimport textwrap\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\ndef test_publish_cloudrun_requires_gcloud(mock_which, tmp_path_factory):\n    mock_which.return_value = False\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(cli.cli, [\"publish\", \"cloudrun\", \"test.db\"])\n    assert result.exit_code == 1\n    assert \"Publishing to Google Cloud requires gcloud\" in result.output\n\n\n@mock.patch(\"shutil.which\")\ndef test_publish_cloudrun_invalid_database(mock_which):\n    mock_which.return_value = True\n    runner = CliRunner()\n    result = runner.invoke(cli.cli, [\"publish\", \"cloudrun\", \"woop.db\"])\n    assert result.exit_code == 2\n    assert \"Path 'woop.db' does not exist\" in result.output\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\n@mock.patch(\"datasette.publish.cloudrun.get_existing_services\")\ndef test_publish_cloudrun_prompts_for_service(\n    mock_get_existing_services, mock_call, mock_output, mock_which, tmp_path_factory\n):\n    mock_get_existing_services.return_value = [\n        {\"name\": \"existing\", \"created\": \"2019-01-01\", \"url\": \"http://www.example.com/\"}\n    ]\n    mock_output.return_value = \"myproject\"\n    mock_which.return_value = True\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli, [\"publish\", \"cloudrun\", \"test.db\"], input=\"input-service\"\n    )\n    assert (\n        \"Please provide a service name for this deployment\\n\\n\"\n        \"Using an existing service name will over-write it\\n\\n\"\n        \"Your existing services:\\n\\n\"\n        \"  existing - created 2019-01-01 - http://www.example.com/\\n\\n\"\n        \"Service name: input-service\"\n    ) == result.output.strip()\n    assert 0 == result.exit_code\n    tag = \"us-docker.pkg.dev/myproject/datasette/datasette-input-service\"\n    mock_call.assert_has_calls(\n        [\n            mock.call(\n                \"gcloud services enable artifactregistry.googleapis.com --project myproject --quiet\",\n                shell=True,\n            ),\n            mock.call(\n                \"gcloud artifacts repositories describe datasette --project myproject --location us --quiet\",\n                shell=True,\n            ),\n            mock.call(f\"gcloud builds submit --tag {tag}\", shell=True),\n            mock.call(\n                \"gcloud run deploy --allow-unauthenticated --platform=managed --image {} input-service --max-instances 1\".format(\n                    tag\n                ),\n                shell=True,\n            ),\n        ]\n    )\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\ndef test_publish_cloudrun(mock_call, mock_output, mock_which, tmp_path_factory):\n    mock_output.return_value = \"myproject\"\n    mock_which.return_value = True\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli, [\"publish\", \"cloudrun\", \"test.db\", \"--service\", \"test\"]\n    )\n    assert 0 == result.exit_code\n    tag = f\"us-docker.pkg.dev/{mock_output.return_value}/datasette/datasette-test\"\n    mock_call.assert_has_calls(\n        [\n            mock.call(\n                f\"gcloud services enable artifactregistry.googleapis.com --project {mock_output.return_value} --quiet\",\n                shell=True,\n            ),\n            mock.call(\n                f\"gcloud artifacts repositories describe datasette --project {mock_output.return_value} --location us --quiet\",\n                shell=True,\n            ),\n            mock.call(f\"gcloud builds submit --tag {tag}\", shell=True),\n            mock.call(\n                \"gcloud run deploy --allow-unauthenticated --platform=managed --image {} test --max-instances 1\".format(\n                    tag\n                ),\n                shell=True,\n            ),\n        ]\n    )\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\n@pytest.mark.parametrize(\n    \"memory,cpu,timeout,min_instances,max_instances,expected_gcloud_args\",\n    [\n        [\"1Gi\", None, None, None, None, \"--memory 1Gi\"],\n        [\"2G\", None, None, None, None, \"--memory 2G\"],\n        [\"256Mi\", None, None, None, None, \"--memory 256Mi\"],\n        [\n            \"4\",\n            None,\n            None,\n            None,\n            None,\n            None,\n        ],\n        [\n            \"GB\",\n            None,\n            None,\n            None,\n            None,\n            None,\n        ],\n        [None, 1, None, None, None, \"--cpu 1\"],\n        [None, 2, None, None, None, \"--cpu 2\"],\n        [None, 3, None, None, None, None],\n        [None, 4, None, None, None, \"--cpu 4\"],\n        [\"2G\", 4, None, None, None, \"--memory 2G --cpu 4\"],\n        [None, None, 1800, None, None, \"--timeout 1800\"],\n        [None, None, None, 2, None, \"--min-instances 2\"],\n        [None, None, None, 2, 4, \"--min-instances 2 --max-instances 4\"],\n        [None, 2, None, None, 4, \"--cpu 2 --max-instances 4\"],\n    ],\n)\ndef test_publish_cloudrun_memory_cpu(\n    mock_call,\n    mock_output,\n    mock_which,\n    memory,\n    cpu,\n    timeout,\n    min_instances,\n    max_instances,\n    expected_gcloud_args,\n    tmp_path_factory,\n):\n    mock_output.return_value = \"myproject\"\n    mock_which.return_value = True\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    args = [\"publish\", \"cloudrun\", \"test.db\", \"--service\", \"test\"]\n    if memory:\n        args.extend([\"--memory\", memory])\n    if cpu:\n        args.extend([\"--cpu\", str(cpu)])\n    if timeout:\n        args.extend([\"--timeout\", str(timeout)])\n    result = runner.invoke(cli.cli, args)\n    if expected_gcloud_args is None:\n        assert 2 == result.exit_code\n        return\n    assert 0 == result.exit_code\n    tag = f\"us-docker.pkg.dev/{mock_output.return_value}/datasette/datasette-test\"\n    expected_call = (\n        \"gcloud run deploy --allow-unauthenticated --platform=managed\"\n        \" --image {} test\".format(tag)\n    )\n    expected_build_call = f\"gcloud builds submit --tag {tag}\"\n    if memory:\n        expected_call += \" --memory {}\".format(memory)\n    if cpu:\n        expected_call += \" --cpu {}\".format(cpu)\n    if timeout:\n        expected_build_call += f\" --timeout {timeout}\"\n    # max_instances defaults to 1\n    expected_call += \" --max-instances 1\"\n    mock_call.assert_has_calls(\n        [\n            mock.call(\n                f\"gcloud services enable artifactregistry.googleapis.com --project {mock_output.return_value} --quiet\",\n                shell=True,\n            ),\n            mock.call(\n                f\"gcloud artifacts repositories describe datasette --project {mock_output.return_value} --location us --quiet\",\n                shell=True,\n            ),\n            mock.call(expected_build_call, shell=True),\n            mock.call(\n                expected_call,\n                shell=True,\n            ),\n        ]\n    )\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\ndef test_publish_cloudrun_plugin_secrets(\n    mock_call, mock_output, mock_which, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_output.return_value = \"myproject\"\n\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    with open(\"metadata.yml\", \"w\") as fp:\n        fp.write(textwrap.dedent(\"\"\"\n            title: Hello from metadata YAML\n            plugins:\n              datasette-auth-github:\n                foo: bar\n            \"\"\").strip())\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"publish\",\n            \"cloudrun\",\n            \"test.db\",\n            \"--metadata\",\n            \"metadata.yml\",\n            \"--service\",\n            \"datasette\",\n            \"--plugin-secret\",\n            \"datasette-auth-github\",\n            \"client_id\",\n            \"x-client-id\",\n            \"--show-files\",\n            \"--secret\",\n            \"x-secret\",\n        ],\n    )\n    assert result.exit_code == 0\n    dockerfile = (\n        result.output.split(\"==== Dockerfile ====\\n\")[1]\n        .split(\"\\n====================\\n\")[0]\n        .strip()\n    )\n    expected = textwrap.dedent(\n        r\"\"\"\n    FROM python:3.11.0-slim-bullseye\n    COPY . /app\n    WORKDIR /app\n\n    ENV DATASETTE_AUTH_GITHUB_CLIENT_ID 'x-client-id'\n    ENV DATASETTE_SECRET 'x-secret'\n    RUN pip install -U datasette\n    RUN datasette inspect test.db --inspect-file inspect-data.json\n    ENV PORT 8001\n    EXPOSE 8001\n    CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --metadata metadata.json --setting force_https_urls on --port $PORT\"\"\"\n    ).strip()\n    assert expected == dockerfile\n    metadata = (\n        result.output.split(\"=== metadata.json ===\\n\")[1]\n        .split(\"\\n==== Dockerfile ====\\n\")[0]\n        .strip()\n    )\n    assert {\n        \"title\": \"Hello from metadata YAML\",\n        \"plugins\": {\n            \"datasette-auth-github\": {\n                \"client_id\": {\"$env\": \"DATASETTE_AUTH_GITHUB_CLIENT_ID\"},\n                \"foo\": \"bar\",\n            },\n        },\n    } == json.loads(metadata)\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\ndef test_publish_cloudrun_apt_get_install(\n    mock_call, mock_output, mock_which, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_output.return_value = \"myproject\"\n\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"publish\",\n            \"cloudrun\",\n            \"test.db\",\n            \"--service\",\n            \"datasette\",\n            \"--show-files\",\n            \"--secret\",\n            \"x-secret\",\n            \"--apt-get-install\",\n            \"ripgrep\",\n            \"--spatialite\",\n        ],\n    )\n    assert result.exit_code == 0\n    dockerfile = (\n        result.output.split(\"==== Dockerfile ====\\n\")[1]\n        .split(\"\\n====================\\n\")[0]\n        .strip()\n    )\n    expected = textwrap.dedent(r\"\"\"\n    FROM python:3.11.0-slim-bullseye\n    COPY . /app\n    WORKDIR /app\n\n    RUN apt-get update && \\\n        apt-get install -y ripgrep python3-dev gcc libsqlite3-mod-spatialite && \\\n        rm -rf /var/lib/apt/lists/*\n\n    ENV DATASETTE_SECRET 'x-secret'\n    ENV SQLITE_EXTENSIONS '/usr/lib/x86_64-linux-gnu/mod_spatialite.so'\n    RUN pip install -U datasette\n    RUN datasette inspect test.db --inspect-file inspect-data.json\n    ENV PORT 8001\n    EXPOSE 8001\n    CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --setting force_https_urls on --port $PORT\n    \"\"\").strip()\n    assert expected == dockerfile\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.cloudrun.check_output\")\n@mock.patch(\"datasette.publish.cloudrun.check_call\")\n@pytest.mark.parametrize(\n    \"extra_options,expected\",\n    [\n        (\"\", \"--setting force_https_urls on\"),\n        (\n            \"--setting base_url /foo\",\n            \"--setting base_url /foo --setting force_https_urls on\",\n        ),\n        (\"--setting force_https_urls off\", \"--setting force_https_urls off\"),\n    ],\n)\ndef test_publish_cloudrun_extra_options(\n    mock_call, mock_output, mock_which, extra_options, expected, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_output.return_value = \"myproject\"\n\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"publish\",\n            \"cloudrun\",\n            \"test.db\",\n            \"--service\",\n            \"datasette\",\n            \"--show-files\",\n            \"--extra-options\",\n            extra_options,\n        ],\n    )\n    assert result.exit_code == 0\n    dockerfile = (\n        result.output.split(\"==== Dockerfile ====\\n\")[1]\n        .split(\"\\n====================\\n\")[0]\n        .strip()\n    )\n    last_line = dockerfile.split(\"\\n\")[-1]\n    extra_options = (\n        last_line.split(\"--inspect-file inspect-data.json\")[1]\n        .split(\"--port\")[0]\n        .strip()\n    )\n    assert extra_options == expected\n"
  },
  {
    "path": "tests/test_publish_heroku.py",
    "content": "from click.testing import CliRunner\nfrom datasette import cli\nfrom unittest import mock\nimport os\nimport pathlib\nimport pytest\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\ndef test_publish_heroku_requires_heroku(mock_which, tmp_path_factory):\n    mock_which.return_value = False\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(cli.cli, [\"publish\", \"heroku\", \"test.db\"])\n    assert result.exit_code == 1\n    assert \"Publishing to Heroku requires heroku\" in result.output\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.heroku.check_output\")\n@mock.patch(\"datasette.publish.heroku.call\")\ndef test_publish_heroku_installs_plugin(\n    mock_call, mock_check_output, mock_which, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_check_output.side_effect = lambda s: {\"['heroku', 'plugins']\": b\"\"}[repr(s)]\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"t.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(cli.cli, [\"publish\", \"heroku\", \"t.db\"], input=\"y\\n\")\n    assert 0 != result.exit_code\n    mock_check_output.assert_has_calls(\n        [mock.call([\"heroku\", \"plugins\"]), mock.call([\"heroku\", \"apps:list\", \"--json\"])]\n    )\n    mock_call.assert_has_calls(\n        [mock.call([\"heroku\", \"plugins:install\", \"heroku-builds\"])]\n    )\n\n\n@mock.patch(\"shutil.which\")\ndef test_publish_heroku_invalid_database(mock_which):\n    mock_which.return_value = True\n    runner = CliRunner()\n    result = runner.invoke(cli.cli, [\"publish\", \"heroku\", \"woop.db\"])\n    assert result.exit_code == 2\n    assert \"Path 'woop.db' does not exist\" in result.output\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.heroku.check_output\")\n@mock.patch(\"datasette.publish.heroku.call\")\ndef test_publish_heroku(mock_call, mock_check_output, mock_which, tmp_path_factory):\n    mock_which.return_value = True\n    mock_check_output.side_effect = lambda s: {\n        \"['heroku', 'plugins']\": b\"heroku-builds\",\n        \"['heroku', 'apps:list', '--json']\": b\"[]\",\n        \"['heroku', 'apps:create', 'datasette', '--json']\": b'{\"name\": \"f\"}',\n    }[repr(s)]\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(cli.cli, [\"publish\", \"heroku\", \"test.db\", \"--tar\", \"gtar\"])\n    assert 0 == result.exit_code, result.output\n    mock_call.assert_has_calls(\n        [\n            mock.call(\n                [\n                    \"heroku\",\n                    \"builds:create\",\n                    \"-a\",\n                    \"f\",\n                    \"--include-vcs-ignore\",\n                    \"--tar\",\n                    \"gtar\",\n                ]\n            ),\n        ]\n    )\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.heroku.check_output\")\n@mock.patch(\"datasette.publish.heroku.call\")\ndef test_publish_heroku_plugin_secrets(\n    mock_call, mock_check_output, mock_which, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_check_output.side_effect = lambda s: {\n        \"['heroku', 'plugins']\": b\"heroku-builds\",\n        \"['heroku', 'apps:list', '--json']\": b\"[]\",\n        \"['heroku', 'apps:create', 'datasette', '--json']\": b'{\"name\": \"f\"}',\n    }[repr(s)]\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"publish\",\n            \"heroku\",\n            \"test.db\",\n            \"--plugin-secret\",\n            \"datasette-auth-github\",\n            \"client_id\",\n            \"x-client-id\",\n        ],\n    )\n    assert 0 == result.exit_code, result.output\n    mock_call.assert_has_calls(\n        [\n            mock.call(\n                [\n                    \"heroku\",\n                    \"config:set\",\n                    \"-a\",\n                    \"f\",\n                    \"DATASETTE_AUTH_GITHUB_CLIENT_ID=x-client-id\",\n                ]\n            ),\n            mock.call([\"heroku\", \"builds:create\", \"-a\", \"f\", \"--include-vcs-ignore\"]),\n        ]\n    )\n\n\n@pytest.mark.serial\n@mock.patch(\"shutil.which\")\n@mock.patch(\"datasette.publish.heroku.check_output\")\n@mock.patch(\"datasette.publish.heroku.call\")\ndef test_publish_heroku_generate_dir(\n    mock_call, mock_check_output, mock_which, tmp_path_factory\n):\n    mock_which.return_value = True\n    mock_check_output.side_effect = lambda s: {\n        \"['heroku', 'plugins']\": b\"heroku-builds\",\n    }[repr(s)]\n    runner = CliRunner()\n    os.chdir(tmp_path_factory.mktemp(\"runner\"))\n    with open(\"test.db\", \"w\") as fp:\n        fp.write(\"data\")\n    output = str(tmp_path_factory.mktemp(\"generate_dir\") / \"output\")\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"publish\",\n            \"heroku\",\n            \"test.db\",\n            \"--generate-dir\",\n            output,\n        ],\n    )\n    assert result.exit_code == 0\n    path = pathlib.Path(output)\n    assert path.exists()\n    file_names = {str(r.relative_to(path)) for r in path.glob(\"*\")}\n    assert file_names == {\n        \"requirements.txt\",\n        \"bin\",\n        \"runtime.txt\",\n        \"Procfile\",\n        \"test.db\",\n    }\n    for name, expected in (\n        (\"requirements.txt\", \"datasette\"),\n        (\"runtime.txt\", \"python-3.11.0\"),\n        (\n            \"Procfile\",\n            (\n                \"web: datasette serve --host 0.0.0.0 -i test.db \"\n                \"--cors --port $PORT --inspect-file inspect-data.json\"\n            ),\n        ),\n    ):\n        with open(path / name) as fp:\n            assert fp.read().strip() == expected\n"
  },
  {
    "path": "tests/test_restriction_sql.py",
    "content": "import pytest\nfrom datasette.app import Datasette\nfrom datasette.permissions import PermissionSQL\nfrom datasette.resources import TableResource\n\n\n@pytest.mark.asyncio\nasync def test_multiple_restriction_sources_intersect():\n    \"\"\"\n    Test that when multiple plugins return restriction_sql, they are INTERSECTed.\n\n    This tests the case where both actor _r restrictions AND a plugin\n    provide restriction_sql - both must pass for access to be granted.\n    \"\"\"\n    from datasette import hookimpl\n\n    class RestrictivePlugin:\n        __name__ = \"RestrictivePlugin\"\n\n        @hookimpl\n        def permission_resources_sql(self, datasette, actor, action):\n            # Plugin adds additional restriction: only db1_multi_intersect allowed\n            if action == \"view-table\":\n                return PermissionSQL(\n                    restriction_sql=\"SELECT 'db1_multi_intersect' AS parent, NULL AS child\",\n                    params={},\n                )\n            return None\n\n    plugin = RestrictivePlugin()\n\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.pm.register(plugin, name=\"restrictive_plugin\")\n\n    try:\n        db1 = ds.add_memory_database(\"db1_multi_intersect\")\n        db2 = ds.add_memory_database(\"db2_multi_intersect\")\n        await db1.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n        await db2.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n        await ds._refresh_schemas()  # Populate catalog tables\n\n        # Actor has restrictions allowing both databases\n        # But plugin only allows db1_multi_intersect\n        # INTERSECT means only db1_multi_intersect/t1 should pass\n        actor = {\n            \"id\": \"user\",\n            \"_r\": {\"d\": {\"db1_multi_intersect\": [\"vt\"], \"db2_multi_intersect\": [\"vt\"]}},\n        }\n\n        page = await ds.allowed_resources(\"view-table\", actor)\n        resources = {(r.parent, r.child) for r in page.resources}\n\n        # Should only see db1_multi_intersect/t1 (intersection of actor restrictions and plugin restrictions)\n        assert (\"db1_multi_intersect\", \"t1\") in resources\n        assert (\"db2_multi_intersect\", \"t1\") not in resources\n    finally:\n        ds.pm.unregister(name=\"restrictive_plugin\")\n\n\n@pytest.mark.asyncio\nasync def test_restriction_sql_with_overlapping_databases_and_tables():\n    \"\"\"\n    Test actor with both database-level and table-level restrictions for same database.\n\n    When actor has:\n    - Database-level: db1_overlapping allowed (all tables)\n    - Table-level: db1_overlapping/t1 allowed\n\n    Both entries are UNION'd (OR'ed) within the actor's restrictions.\n    Database-level restriction allows ALL tables, so table-level is redundant.\n    \"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"db1_overlapping\")\n    await db.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n    await db.execute_write(\"CREATE TABLE t2 (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Actor has BOTH database-level (db1_overlapping all tables) AND table-level (db1_overlapping/t1 only)\n    actor = {\n        \"id\": \"user\",\n        \"_r\": {\n            \"d\": {\n                \"db1_overlapping\": [\"vt\"]\n            },  # Database-level: all tables in db1_overlapping\n            \"r\": {\n                \"db1_overlapping\": {\"t1\": [\"vt\"]}\n            },  # Table-level: only t1 in db1_overlapping\n        },\n    }\n\n    # Within actor restrictions, entries are UNION'd (OR'ed):\n    # - Database level allows: (db1_overlapping, NULL) → matches all tables via hierarchical matching\n    # - Table level allows: (db1_overlapping, t1) → redundant, already covered by database level\n    # Result: Both tables are allowed\n    page = await ds.allowed_resources(\"view-table\", actor)\n    resources = {(r.parent, r.child) for r in page.resources}\n\n    assert (\"db1_overlapping\", \"t1\") in resources\n    # Database-level restriction allows all tables, so t2 is also allowed\n    assert (\"db1_overlapping\", \"t2\") in resources\n\n\n@pytest.mark.asyncio\nasync def test_restriction_sql_empty_allowlist_query():\n    \"\"\"\n    Test the specific SQL query generated when action is not in allowlist.\n\n    actor_restrictions_sql() returns \"SELECT NULL AS parent, NULL AS child WHERE 0\"\n    Verify this produces an empty result set.\n    \"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"db1_empty_allowlist\")\n    await db.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Actor has restrictions but action not in allowlist\n    actor = {\"id\": \"user\", \"_r\": {\"r\": {\"db1_empty_allowlist\": {\"t1\": [\"vt\"]}}}}\n\n    # Try to view-database (only view-table is in allowlist)\n    page = await ds.allowed_resources(\"view-database\", actor)\n\n    # Should be empty\n    assert len(page.resources) == 0\n\n\n@pytest.mark.asyncio\nasync def test_restriction_sql_with_pagination():\n    \"\"\"\n    Test that restrictions work correctly with keyset pagination.\n    \"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"db1_pagination\")\n\n    # Create many tables\n    for i in range(10):\n        await db.execute_write(f\"CREATE TABLE t{i:02d} (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Actor restricted to only odd-numbered tables\n    restrictions = {\"r\": {\"db1_pagination\": {}}}\n    for i in range(10):\n        if i % 2 == 1:  # Only odd tables\n            restrictions[\"r\"][\"db1_pagination\"][f\"t{i:02d}\"] = [\"vt\"]\n\n    actor = {\"id\": \"user\", \"_r\": restrictions}\n\n    # Get first page with small limit\n    page1 = await ds.allowed_resources(\n        \"view-table\", actor, parent=\"db1_pagination\", limit=2\n    )\n    assert len(page1.resources) == 2\n    assert page1.next is not None\n\n    # Get second page using next token\n    page2 = await ds.allowed_resources(\n        \"view-table\", actor, parent=\"db1_pagination\", limit=2, next=page1.next\n    )\n    assert len(page2.resources) == 2\n\n    # Should have no overlap\n    page1_ids = {r.child for r in page1.resources}\n    page2_ids = {r.child for r in page2.resources}\n    assert page1_ids.isdisjoint(page2_ids)\n\n    # All should be odd-numbered tables\n    all_ids = page1_ids | page2_ids\n    for table_id in all_ids:\n        table_num = int(table_id[1:])  # Extract number from \"t01\", \"t03\", etc.\n        assert table_num % 2 == 1, f\"Table {table_id} should be odd-numbered\"\n\n\n@pytest.mark.asyncio\nasync def test_also_requires_with_restrictions():\n    \"\"\"\n    Test that also_requires actions properly respect restrictions.\n\n    execute-sql requires view-database. With restrictions, both must pass.\n    \"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.add_memory_database(\"db1_also_requires\")\n    ds.add_memory_database(\"db2_also_requires\")\n    await ds._refresh_schemas()\n\n    # Actor restricted to only db1_also_requires for view-database\n    # execute-sql requires view-database, so should only work on db1_also_requires\n    actor = {\n        \"id\": \"user\",\n        \"_r\": {\n            \"d\": {\n                \"db1_also_requires\": [\"vd\", \"es\"],\n                \"db2_also_requires\": [\n                    \"es\"\n                ],  # They have execute-sql but not view-database\n            }\n        },\n    }\n\n    # db1_also_requires should allow execute-sql\n    result = await ds.allowed(\n        action=\"execute-sql\",\n        resource=TableResource(\"db1_also_requires\", None),\n        actor=actor,\n    )\n    assert result is True\n\n    # db2_also_requires should not (they have execute-sql but not view-database)\n    result = await ds.allowed(\n        action=\"execute-sql\",\n        resource=TableResource(\"db2_also_requires\", None),\n        actor=actor,\n    )\n    assert result is False\n\n\n@pytest.mark.asyncio\nasync def test_restriction_abbreviations_and_full_names():\n    \"\"\"\n    Test that both abbreviations and full action names work in restrictions.\n    \"\"\"\n    ds = Datasette()\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"db1_abbrev\")\n    await db.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n    await ds._refresh_schemas()\n\n    # Test with abbreviation\n    actor_abbr = {\"id\": \"user\", \"_r\": {\"r\": {\"db1_abbrev\": {\"t1\": [\"vt\"]}}}}\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"db1_abbrev\", \"t1\"),\n        actor=actor_abbr,\n    )\n    assert result is True\n\n    # Test with full name\n    actor_full = {\"id\": \"user\", \"_r\": {\"r\": {\"db1_abbrev\": {\"t1\": [\"view-table\"]}}}}\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"db1_abbrev\", \"t1\"),\n        actor=actor_full,\n    )\n    assert result is True\n\n    # Test with mixed\n    actor_mixed = {\"id\": \"user\", \"_r\": {\"d\": {\"db1_abbrev\": [\"view-database\", \"vt\"]}}}\n    result = await ds.allowed(\n        action=\"view-table\",\n        resource=TableResource(\"db1_abbrev\", \"t1\"),\n        actor=actor_mixed,\n    )\n    assert result is True\n\n\n@pytest.mark.asyncio\nasync def test_permission_resources_sql_multiple_restriction_sources_intersect():\n    \"\"\"\n    Test that when multiple plugins return restriction_sql, they are INTERSECTed.\n\n    This tests the case where both actor _r restrictions AND a plugin\n    provide restriction_sql - both must pass for access to be granted.\n    \"\"\"\n    from datasette import hookimpl\n\n    class RestrictivePlugin:\n        __name__ = \"RestrictivePlugin\"\n\n        @hookimpl\n        def permission_resources_sql(self, datasette, actor, action):\n            # Plugin adds additional restriction: only db1_multi_restrictions allowed\n            if action == \"view-table\":\n                return PermissionSQL(\n                    restriction_sql=\"SELECT 'db1_multi_restrictions' AS parent, NULL AS child\",\n                    params={},\n                )\n            return None\n\n    plugin = RestrictivePlugin()\n\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.pm.register(plugin, name=\"restrictive_plugin\")\n\n    try:\n        db1 = ds.add_memory_database(\"db1_multi_restrictions\")\n        db2 = ds.add_memory_database(\"db2_multi_restrictions\")\n        await db1.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n        await db2.execute_write(\"CREATE TABLE t1 (id INTEGER)\")\n        await ds._refresh_schemas()  # Populate catalog tables\n\n        # Actor has restrictions allowing both databases\n        # But plugin only allows db1\n        # INTERSECT means only db1/t1 should pass\n        actor = {\n            \"id\": \"user\",\n            \"_r\": {\n                \"d\": {\n                    \"db1_multi_restrictions\": [\"vt\"],\n                    \"db2_multi_restrictions\": [\"vt\"],\n                }\n            },\n        }\n\n        page = await ds.allowed_resources(\"view-table\", actor)\n        resources = {(r.parent, r.child) for r in page.resources}\n\n        # Should only see db1/t1 (intersection of actor restrictions and plugin restrictions)\n        assert (\"db1_multi_restrictions\", \"t1\") in resources\n        assert (\"db2_multi_restrictions\", \"t1\") not in resources\n    finally:\n        ds.pm.unregister(name=\"restrictive_plugin\")\n"
  },
  {
    "path": "tests/test_routes.py",
    "content": "from datasette.app import Datasette, Database\nfrom datasette.utils import resolve_routes\nimport pytest\nimport pytest_asyncio\n\n\n@pytest.fixture(scope=\"session\")\ndef routes():\n    ds = Datasette()\n    return ds._routes()\n\n\n@pytest.mark.parametrize(\n    \"path,expected_name,expected_matches\",\n    (\n        (\"/\", \"IndexView\", {\"format\": None}),\n        (\"/foo\", \"DatabaseView\", {\"format\": None, \"database\": \"foo\"}),\n        (\"/foo.csv\", \"DatabaseView\", {\"format\": \"csv\", \"database\": \"foo\"}),\n        (\"/foo.json\", \"DatabaseView\", {\"format\": \"json\", \"database\": \"foo\"}),\n        (\"/foo.humbug\", \"DatabaseView\", {\"format\": \"humbug\", \"database\": \"foo\"}),\n        (\n            \"/foo/humbug\",\n            \"table_view\",\n            {\"database\": \"foo\", \"table\": \"humbug\", \"format\": None},\n        ),\n        (\n            \"/foo/humbug.json\",\n            \"table_view\",\n            {\"database\": \"foo\", \"table\": \"humbug\", \"format\": \"json\"},\n        ),\n        (\n            \"/foo/humbug.blah\",\n            \"table_view\",\n            {\"database\": \"foo\", \"table\": \"humbug\", \"format\": \"blah\"},\n        ),\n        (\n            \"/foo/humbug/1\",\n            \"RowView\",\n            {\"format\": None, \"database\": \"foo\", \"pks\": \"1\", \"table\": \"humbug\"},\n        ),\n        (\n            \"/foo/humbug/1.json\",\n            \"RowView\",\n            {\"format\": \"json\", \"database\": \"foo\", \"pks\": \"1\", \"table\": \"humbug\"},\n        ),\n    ),\n)\ndef test_routes(routes, path, expected_name, expected_matches):\n    match, view = resolve_routes(routes, path)\n    if expected_name is None:\n        assert match is None\n    else:\n        assert (\n            view.__name__ == expected_name or view.view_class.__name__ == expected_name\n        )\n        assert match.groupdict() == expected_matches\n\n\n@pytest_asyncio.fixture\nasync def ds_with_route():\n    ds = Datasette()\n    await ds.invoke_startup()\n    ds.remove_database(\"_memory\")\n    db = Database(ds, is_memory=True, memory_name=\"route-name-db\")\n    ds.add_database(db, name=\"original-name\", route=\"custom-route-name\")\n    await db.execute_write_script(\"\"\"\n        create table if not exists t (id integer primary key);\n        insert or replace into t (id) values (1);\n    \"\"\")\n    return ds\n\n\n@pytest.mark.asyncio\nasync def test_db_with_route_databases(ds_with_route):\n    response = await ds_with_route.client.get(\"/-/databases.json\")\n    assert response.json()[0] == {\n        \"name\": \"original-name\",\n        \"route\": \"custom-route-name\",\n        \"path\": None,\n        \"size\": 0,\n        \"is_mutable\": True,\n        \"is_memory\": True,\n        \"hash\": None,\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_status\",\n    (\n        (\"/\", 200),\n        (\"/original-name\", 404),\n        (\"/original-name/t\", 404),\n        (\"/original-name/t/1\", 404),\n        (\"/custom-route-name\", 200),\n        (\"/custom-route-name/-/query?sql=select+id+from+t\", 200),\n        (\"/custom-route-name/t\", 200),\n        (\"/custom-route-name/t/1\", 200),\n    ),\n)\nasync def test_db_with_route_that_does_not_match_name(\n    ds_with_route, path, expected_status\n):\n    response = await ds_with_route.client.get(path)\n    assert response.status_code == expected_status\n    # There should be links to custom-route-name but none to original-name\n    if response.status_code == 200:\n        assert \"/custom-route-name\" in response.text\n        assert \"/original-name\" not in response.text\n"
  },
  {
    "path": "tests/test_schema_endpoints.py",
    "content": "import pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\n\n\n@pytest_asyncio.fixture(scope=\"module\")\nasync def schema_ds():\n    \"\"\"Create a Datasette instance with test databases and permission config.\"\"\"\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"schema_private_db\": {\"allow\": {\"id\": \"root\"}},\n            }\n        }\n    )\n\n    # Create public database with multiple tables\n    public_db = ds.add_memory_database(\"schema_public_db\")\n    await public_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)\"\n    )\n    await public_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT)\"\n    )\n    await public_db.execute_write(\n        \"CREATE VIEW IF NOT EXISTS recent_posts AS SELECT * FROM posts ORDER BY id DESC\"\n    )\n\n    # Create a database with restricted access (requires root permission)\n    private_db = ds.add_memory_database(\"schema_private_db\")\n    await private_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS secret_data (id INTEGER PRIMARY KEY, value TEXT)\"\n    )\n\n    # Create an empty database\n    ds.add_memory_database(\"schema_empty_db\")\n\n    return ds\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"format_ext,expected_in_content\",\n    [\n        (\"json\", None),\n        (\"md\", [\"# Schema for\", \"```sql\"]),\n        (\"\", [\"Schema for\", \"CREATE TABLE\"]),\n    ],\n)\nasync def test_database_schema_formats(schema_ds, format_ext, expected_in_content):\n    \"\"\"Test /database/-/schema endpoint in different formats.\"\"\"\n    url = \"/schema_public_db/-/schema\"\n    if format_ext:\n        url += f\".{format_ext}\"\n    response = await schema_ds.client.get(url)\n    assert response.status_code == 200\n\n    if format_ext == \"json\":\n        data = response.json()\n        assert \"database\" in data\n        assert data[\"database\"] == \"schema_public_db\"\n        assert \"schema\" in data\n        assert \"CREATE TABLE users\" in data[\"schema\"]\n    else:\n        content = response.text\n        for expected in expected_in_content:\n            assert expected in content\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"format_ext,expected_in_content\",\n    [\n        (\"json\", None),\n        (\"md\", [\"# Schema for\", \"```sql\"]),\n        (\"\", [\"Schema for all databases\"]),\n    ],\n)\nasync def test_instance_schema_formats(schema_ds, format_ext, expected_in_content):\n    \"\"\"Test /-/schema endpoint in different formats.\"\"\"\n    url = \"/-/schema\"\n    if format_ext:\n        url += f\".{format_ext}\"\n    response = await schema_ds.client.get(url)\n    assert response.status_code == 200\n\n    if format_ext == \"json\":\n        data = response.json()\n        assert \"schemas\" in data\n        assert isinstance(data[\"schemas\"], list)\n        db_names = [item[\"database\"] for item in data[\"schemas\"]]\n        # Should see schema_public_db and schema_empty_db, but not schema_private_db (anonymous user)\n        assert \"schema_public_db\" in db_names\n        assert \"schema_empty_db\" in db_names\n        assert \"schema_private_db\" not in db_names\n        # Check schemas are present\n        for item in data[\"schemas\"]:\n            if item[\"database\"] == \"schema_public_db\":\n                assert \"CREATE TABLE users\" in item[\"schema\"]\n    else:\n        content = response.text\n        for expected in expected_in_content:\n            assert expected in content\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"format_ext,expected_in_content\",\n    [\n        (\"json\", None),\n        (\"md\", [\"# Schema for\", \"```sql\"]),\n        (\"\", [\"Schema for users\"]),\n    ],\n)\nasync def test_table_schema_formats(schema_ds, format_ext, expected_in_content):\n    \"\"\"Test /database/table/-/schema endpoint in different formats.\"\"\"\n    url = \"/schema_public_db/users/-/schema\"\n    if format_ext:\n        url += f\".{format_ext}\"\n    response = await schema_ds.client.get(url)\n    assert response.status_code == 200\n\n    if format_ext == \"json\":\n        data = response.json()\n        assert \"database\" in data\n        assert data[\"database\"] == \"schema_public_db\"\n        assert \"table\" in data\n        assert data[\"table\"] == \"users\"\n        assert \"schema\" in data\n        assert \"CREATE TABLE users\" in data[\"schema\"]\n    else:\n        content = response.text\n        for expected in expected_in_content:\n            assert expected in content\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"url\",\n    [\n        \"/schema_private_db/-/schema.json\",\n        \"/schema_private_db/secret_data/-/schema.json\",\n    ],\n)\nasync def test_schema_permission_enforcement(schema_ds, url):\n    \"\"\"Test that permissions are enforced for schema endpoints.\"\"\"\n    # Anonymous user should get 403\n    response = await schema_ds.client.get(url)\n    assert response.status_code == 403\n\n    # Authenticated user with permission should succeed\n    response = await schema_ds.client.get(\n        url,\n        cookies={\"ds_actor\": schema_ds.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n\n\n@pytest.mark.asyncio\nasync def test_instance_schema_respects_database_permissions(schema_ds):\n    \"\"\"Test that /-/schema only shows databases the user can view.\"\"\"\n    # Anonymous user should only see public databases\n    response = await schema_ds.client.get(\"/-/schema.json\")\n    assert response.status_code == 200\n    data = response.json()\n    db_names = [item[\"database\"] for item in data[\"schemas\"]]\n    assert \"schema_public_db\" in db_names\n    assert \"schema_empty_db\" in db_names\n    assert \"schema_private_db\" not in db_names\n\n    # Authenticated user should see all databases\n    response = await schema_ds.client.get(\n        \"/-/schema.json\",\n        cookies={\"ds_actor\": schema_ds.client.actor_cookie({\"id\": \"root\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n    db_names = [item[\"database\"] for item in data[\"schemas\"]]\n    assert \"schema_public_db\" in db_names\n    assert \"schema_empty_db\" in db_names\n    assert \"schema_private_db\" in db_names\n\n\n@pytest.mark.asyncio\nasync def test_database_schema_with_multiple_tables(schema_ds):\n    \"\"\"Test schema with multiple tables in a database.\"\"\"\n    response = await schema_ds.client.get(\"/schema_public_db/-/schema.json\")\n    assert response.status_code == 200\n    data = response.json()\n    schema = data[\"schema\"]\n\n    # All objects should be in the schema\n    assert \"CREATE TABLE users\" in schema\n    assert \"CREATE TABLE posts\" in schema\n    assert \"CREATE VIEW recent_posts\" in schema\n\n\n@pytest.mark.asyncio\nasync def test_empty_database_schema(schema_ds):\n    \"\"\"Test schema for an empty database.\"\"\"\n    response = await schema_ds.client.get(\"/schema_empty_db/-/schema.json\")\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"database\"] == \"schema_empty_db\"\n    assert data[\"schema\"] == \"\"\n\n\n@pytest.mark.asyncio\nasync def test_database_not_exists(schema_ds):\n    \"\"\"Test schema for a non-existent database returns 404.\"\"\"\n    # Test JSON format\n    response = await schema_ds.client.get(\"/nonexistent_db/-/schema.json\")\n    assert response.status_code == 404\n    data = response.json()\n    assert data[\"ok\"] is False\n    assert \"not found\" in data[\"error\"].lower()\n\n    # Test HTML format (returns text)\n    response = await schema_ds.client.get(\"/nonexistent_db/-/schema\")\n    assert response.status_code == 404\n    assert \"not found\" in response.text.lower()\n\n    # Test Markdown format (returns text)\n    response = await schema_ds.client.get(\"/nonexistent_db/-/schema.md\")\n    assert response.status_code == 404\n    assert \"not found\" in response.text.lower()\n\n\n@pytest.mark.asyncio\nasync def test_table_not_exists(schema_ds):\n    \"\"\"Test schema for a non-existent table returns 404.\"\"\"\n    # Test JSON format\n    response = await schema_ds.client.get(\"/schema_public_db/nonexistent/-/schema.json\")\n    assert response.status_code == 404\n    data = response.json()\n    assert data[\"ok\"] is False\n    assert \"not found\" in data[\"error\"].lower()\n\n    # Test HTML format (returns text)\n    response = await schema_ds.client.get(\"/schema_public_db/nonexistent/-/schema\")\n    assert response.status_code == 404\n    assert \"not found\" in response.text.lower()\n\n    # Test Markdown format (returns text)\n    response = await schema_ds.client.get(\"/schema_public_db/nonexistent/-/schema.md\")\n    assert response.status_code == 404\n    assert \"not found\" in response.text.lower()\n"
  },
  {
    "path": "tests/test_search_tables.py",
    "content": "\"\"\"\nTests for special endpoints in datasette/views/special.py\n\"\"\"\n\nimport pytest\nimport pytest_asyncio\nfrom datasette.app import Datasette\n\n\n@pytest_asyncio.fixture\nasync def ds_with_tables():\n    \"\"\"Create a Datasette instance with some tables for searching.\"\"\"\n    ds = Datasette(\n        config={\n            \"databases\": {\n                \"content\": {\n                    \"allow\": {\"id\": \"*\"},  # Allow all authenticated users\n                    \"tables\": {\n                        \"articles\": {\n                            \"allow\": {\"id\": \"editor\"},  # Only editor can view\n                        },\n                        \"comments\": {\n                            \"allow\": True,  # Everyone can view\n                        },\n                    },\n                },\n                \"private\": {\n                    \"allow\": False,  # Deny everyone\n                },\n            }\n        }\n    )\n    await ds.invoke_startup()\n\n    # Add content database with some tables\n    content_db = ds.add_memory_database(\"content\")\n    await content_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)\"\n    )\n    await content_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)\"\n    )\n    await content_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)\"\n    )\n\n    # Add private database with a table\n    private_db = ds.add_memory_database(\"private\")\n    await private_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)\"\n    )\n\n    # Add another public database\n    public_db = ds.add_memory_database(\"public\")\n    await public_db.execute_write(\n        \"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)\"\n    )\n\n    return ds\n\n\n# /-/tables.json tests\n@pytest.mark.asyncio\nasync def test_tables_basic_search(ds_with_tables):\n    \"\"\"Test basic table search functionality.\"\"\"\n    # Search for \"articles\" - should find it in both content and public databases\n    # but only return public.articles for anonymous user (content.articles requires auth)\n    response = await ds_with_tables.client.get(\"/-/tables.json?q=articles\")\n    assert response.status_code == 200\n    data = response.json()\n\n    # Should only see public.articles (content.articles restricted to authenticated users)\n    assert \"matches\" in data\n    assert len(data[\"matches\"]) == 1\n\n    match = data[\"matches\"][0]\n    assert \"url\" in match\n    assert \"name\" in match\n    assert match[\"name\"] == \"public: articles\"\n    assert \"/public/articles\" in match[\"url\"]\n\n\n@pytest.mark.asyncio\nasync def test_tables_search_with_auth(ds_with_tables):\n    \"\"\"Test that authenticated users see more tables.\"\"\"\n    # Editor user should see content.articles\n    response = await ds_with_tables.client.get(\n        \"/-/tables.json?q=articles\",\n        cookies={\"ds_actor\": ds_with_tables.client.actor_cookie({\"id\": \"editor\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Should see both content.articles and public.articles\n    assert len(data[\"matches\"]) == 2\n\n    names = {match[\"name\"] for match in data[\"matches\"]}\n    assert names == {\"content: articles\", \"public: articles\"}\n\n\n@pytest.mark.asyncio\nasync def test_tables_search_partial_match(ds_with_tables):\n    \"\"\"Test that search matches partial table names.\"\"\"\n    # Search for \"com\" should match \"comments\"\n    response = await ds_with_tables.client.get(\n        \"/-/tables.json?q=com\",\n        cookies={\"ds_actor\": ds_with_tables.client.actor_cookie({\"id\": \"user\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    assert len(data[\"matches\"]) == 1\n    assert data[\"matches\"][0][\"name\"] == \"content: comments\"\n\n\n@pytest.mark.asyncio\nasync def test_tables_search_respects_database_permissions(ds_with_tables):\n    \"\"\"Test that tables from denied databases are not shown.\"\"\"\n    # Search for \"secrets\" which is in the private database\n    # Even authenticated users shouldn't see it because database is denied\n    response = await ds_with_tables.client.get(\n        \"/-/tables.json?q=secrets\",\n        cookies={\"ds_actor\": ds_with_tables.client.actor_cookie({\"id\": \"user\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Should not see secrets table from private database\n    assert len(data[\"matches\"]) == 0\n\n\n@pytest.mark.asyncio\nasync def test_tables_search_respects_table_permissions(ds_with_tables):\n    \"\"\"Test that tables with specific permissions are filtered correctly.\"\"\"\n    # Regular authenticated user searching for \"users\"\n    response = await ds_with_tables.client.get(\n        \"/-/tables.json?q=users\",\n        cookies={\"ds_actor\": ds_with_tables.client.actor_cookie({\"id\": \"regular\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    # Should see content.users (authenticated users can view content database)\n    assert len(data[\"matches\"]) == 1\n    assert data[\"matches\"][0][\"name\"] == \"content: users\"\n\n\n@pytest.mark.asyncio\nasync def test_tables_search_response_structure(ds_with_tables):\n    \"\"\"Test that response has correct structure.\"\"\"\n    response = await ds_with_tables.client.get(\n        \"/-/tables.json?q=users\",\n        cookies={\"ds_actor\": ds_with_tables.client.actor_cookie({\"id\": \"user\"})},\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    assert \"matches\" in data\n    assert isinstance(data[\"matches\"], list)\n\n    if data[\"matches\"]:\n        match = data[\"matches\"][0]\n        assert \"url\" in match\n        assert \"name\" in match\n        assert isinstance(match[\"url\"], str)\n        assert isinstance(match[\"name\"], str)\n        # Name should be in format \"database: table\"\n        assert \": \" in match[\"name\"]\n"
  },
  {
    "path": "tests/test_spatialite.py",
    "content": "from datasette.app import Datasette\nfrom datasette.utils import find_spatialite, SpatialiteNotFound, SPATIALITE_FUNCTIONS\nfrom .utils import has_load_extension\nimport pytest\n\n\ndef has_spatialite():\n    try:\n        find_spatialite()\n        return True\n    except SpatialiteNotFound:\n        return False\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not has_spatialite(), reason=\"Requires SpatiaLite\")\n@pytest.mark.skipif(not has_load_extension(), reason=\"Requires enable_load_extension\")\nasync def test_spatialite_version_info():\n    ds = Datasette(sqlite_extensions=[\"spatialite\"])\n    response = await ds.client.get(\"/-/versions.json\")\n    assert response.status_code == 200\n    spatialite = response.json()[\"sqlite\"][\"extensions\"][\"spatialite\"]\n    assert set(SPATIALITE_FUNCTIONS) == set(spatialite)\n"
  },
  {
    "path": "tests/test_table_api.py",
    "content": "from datasette.utils import detect_json1\nfrom datasette.utils.sqlite import sqlite_version\nfrom .fixtures import generate_compound_rows, generate_sortable_rows, make_app_client\nimport json\nimport pytest\nimport urllib\n\n\n@pytest.mark.asyncio\nasync def test_table_json(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_extra=query\")\n    assert response.status_code == 200\n    data = response.json()\n    assert (\n        data[\"query\"][\"sql\"]\n        == \"select id, content from simple_primary_key order by id limit 51\"\n    )\n    assert data[\"query\"][\"params\"] == {}\n    assert data[\"rows\"] == [\n        {\"id\": 1, \"content\": \"hello\"},\n        {\"id\": 2, \"content\": \"world\"},\n        {\"id\": 3, \"content\": \"\"},\n        {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        {\"id\": 5, \"content\": \"RENDER_CELL_ASYNC\"},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_not_exists_json(ds_client):\n    assert (await ds_client.get(\"/fixtures/blah.json\")).json() == {\n        \"ok\": False,\n        \"error\": \"Table not found\",\n        \"status\": 404,\n        \"title\": None,\n    }\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_arrays(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_shape=arrays\")\n    assert response.json()[\"rows\"] == [\n        [1, \"hello\"],\n        [2, \"world\"],\n        [3, \"\"],\n        [4, \"RENDER_CELL_DEMO\"],\n        [5, \"RENDER_CELL_ASYNC\"],\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_arrayfirst(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?\"\n        + urllib.parse.urlencode(\n            {\n                \"sql\": \"select content from simple_primary_key order by id\",\n                \"_shape\": \"arrayfirst\",\n            }\n        )\n    )\n    assert response.json() == [\n        \"hello\",\n        \"world\",\n        \"\",\n        \"RENDER_CELL_DEMO\",\n        \"RENDER_CELL_ASYNC\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_objects(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_shape=objects\")\n    assert response.json()[\"rows\"] == [\n        {\"id\": 1, \"content\": \"hello\"},\n        {\"id\": 2, \"content\": \"world\"},\n        {\"id\": 3, \"content\": \"\"},\n        {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        {\"id\": 5, \"content\": \"RENDER_CELL_ASYNC\"},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_array(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_shape=array\")\n    assert response.json() == [\n        {\"id\": 1, \"content\": \"hello\"},\n        {\"id\": 2, \"content\": \"world\"},\n        {\"id\": 3, \"content\": \"\"},\n        {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        {\"id\": 5, \"content\": \"RENDER_CELL_ASYNC\"},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_array_nl(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/simple_primary_key.json?_shape=array&_nl=on\"\n    )\n    lines = response.text.split(\"\\n\")\n    results = [json.loads(line) for line in lines]\n    assert [\n        {\"id\": 1, \"content\": \"hello\"},\n        {\"id\": 2, \"content\": \"world\"},\n        {\"id\": 3, \"content\": \"\"},\n        {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        {\"id\": 5, \"content\": \"RENDER_CELL_ASYNC\"},\n    ] == results\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_invalid(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_shape=invalid\")\n    assert response.json() == {\n        \"ok\": False,\n        \"error\": \"Invalid _shape: invalid\",\n        \"status\": 400,\n        \"title\": None,\n    }\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_object(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key.json?_shape=object\")\n    assert response.json() == {\n        \"1\": {\"id\": 1, \"content\": \"hello\"},\n        \"2\": {\"id\": 2, \"content\": \"world\"},\n        \"3\": {\"id\": 3, \"content\": \"\"},\n        \"4\": {\"id\": 4, \"content\": \"RENDER_CELL_DEMO\"},\n        \"5\": {\"id\": 5, \"content\": \"RENDER_CELL_ASYNC\"},\n    }\n\n\n@pytest.mark.asyncio\nasync def test_table_shape_object_compound_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/compound_primary_key.json?_shape=object\")\n    assert response.json() == {\n        \"a,b\": {\"pk1\": \"a\", \"pk2\": \"b\", \"content\": \"c\"},\n        \"a~2Fb,~2Ec-d\": {\"pk1\": \"a/b\", \"pk2\": \".c-d\", \"content\": \"c\"},\n        \"d,e\": {\"pk1\": \"d\", \"pk2\": \"e\", \"content\": \"RENDER_CELL_DEMO\"},\n    }\n\n\n@pytest.mark.asyncio\nasync def test_table_with_slashes_in_name(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/table~2Fwith~2Fslashes~2Ecsv.json?_shape=objects\"\n    )\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"rows\"] == [{\"pk\": \"3\", \"content\": \"hey\"}]\n\n\n@pytest.mark.asyncio\nasync def test_table_with_reserved_word_name(ds_client):\n    response = await ds_client.get(\"/fixtures/select.json?_shape=objects\")\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"rows\"] == [\n        {\n            \"rowid\": 1,\n            \"group\": \"group\",\n            \"having\": \"having\",\n            \"and\": \"and\",\n            \"json\": '{\"href\": \"http://example.com/\", \"label\":\"Example\"}',\n        }\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_rows,expected_pages\",\n    [\n        (\"/fixtures/no_primary_key.json\", 202, 5),\n        (\"/fixtures/paginated_view.json\", 202, 9),\n        (\"/fixtures/no_primary_key.json?_size=25\", 202, 9),\n        (\"/fixtures/paginated_view.json?_size=50\", 202, 5),\n        (\"/fixtures/paginated_view.json?_size=max\", 202, 3),\n        (\"/fixtures/123_starts_with_digits.json\", 0, 1),\n        # Ensure faceting doesn't break pagination:\n        (\"/fixtures/compound_three_primary_keys.json?_facet=pk1\", 1001, 21),\n        # Paginating while sorted by an expanded foreign key should work\n        (\n            \"/fixtures/roadside_attraction_characteristics.json?_size=2&_sort=attraction_id&_labels=on\",\n            5,\n            3,\n        ),\n    ],\n)\nasync def test_paginate_tables_and_views(\n    ds_client, path, expected_rows, expected_pages\n):\n    fetched = []\n    count = 0\n    while path:\n        if \"?\" in path:\n            path += \"&_extra=next_url\"\n        else:\n            path += \"?_extra=next_url\"\n        response = await ds_client.get(path)\n        assert response.status_code == 200\n        count += 1\n        fetched.extend(response.json()[\"rows\"])\n        path = response.json()[\"next_url\"]\n        if path:\n            assert urllib.parse.urlencode({\"_next\": response.json()[\"next\"]}) in path\n            path = path.replace(\"http://localhost\", \"\")\n        assert count < 30, \"Possible infinite loop detected\"\n\n    assert expected_rows == len(fetched)\n    assert expected_pages == count\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_error\",\n    [\n        (\"/fixtures/no_primary_key.json?_size=-4\", \"_size must be a positive integer\"),\n        (\"/fixtures/no_primary_key.json?_size=dog\", \"_size must be a positive integer\"),\n        (\"/fixtures/no_primary_key.json?_size=1001\", \"_size must be <= 100\"),\n    ],\n)\nasync def test_validate_page_size(ds_client, path, expected_error):\n    response = await ds_client.get(path)\n    assert expected_error == response.json()[\"error\"]\n    assert response.status_code == 400\n\n\n@pytest.mark.asyncio\nasync def test_page_size_zero(ds_client):\n    \"\"\"For _size=0 we return the counts, empty rows and no continuation token\"\"\"\n    response = await ds_client.get(\n        \"/fixtures/no_primary_key.json?_size=0&_extra=count,next_url\"\n    )\n    assert response.status_code == 200\n    assert [] == response.json()[\"rows\"]\n    assert 202 == response.json()[\"count\"]\n    assert None is response.json()[\"next\"]\n    assert None is response.json()[\"next_url\"]\n\n\n@pytest.mark.asyncio\nasync def test_paginate_compound_keys(ds_client):\n    fetched = []\n    path = \"/fixtures/compound_three_primary_keys.json?_shape=objects&_extra=next_url\"\n    page = 0\n    while path:\n        page += 1\n        response = await ds_client.get(path)\n        fetched.extend(response.json()[\"rows\"])\n        path = response.json()[\"next_url\"]\n        if path:\n            path = path.replace(\"http://localhost\", \"\")\n        assert page < 100\n    assert 1001 == len(fetched)\n    assert 21 == page\n    # Should be correctly ordered\n    contents = [f[\"content\"] for f in fetched]\n    expected = [r[3] for r in generate_compound_rows(1001)]\n    assert expected == contents\n\n\n@pytest.mark.asyncio\nasync def test_paginate_compound_keys_with_extra_filters(ds_client):\n    fetched = []\n    path = \"/fixtures/compound_three_primary_keys.json?content__contains=d&_shape=objects&_extra=next_url\"\n    page = 0\n    while path:\n        page += 1\n        assert page < 100\n        response = await ds_client.get(path)\n        fetched.extend(response.json()[\"rows\"])\n        path = response.json()[\"next_url\"]\n        if path:\n            path = path.replace(\"http://localhost\", \"\")\n    assert 2 == page\n    expected = [r[3] for r in generate_compound_rows(1001) if \"d\" in r[3]]\n    assert expected == [f[\"content\"] for f in fetched]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"query_string,sort_key,human_description_en\",\n    [\n        (\"_sort=sortable\", lambda row: row[\"sortable\"], \"sorted by sortable\"),\n        (\n            \"_sort_desc=sortable\",\n            lambda row: -row[\"sortable\"],\n            \"sorted by sortable descending\",\n        ),\n        (\n            \"_sort=sortable_with_nulls\",\n            lambda row: (\n                1 if row[\"sortable_with_nulls\"] is not None else 0,\n                row[\"sortable_with_nulls\"],\n            ),\n            \"sorted by sortable_with_nulls\",\n        ),\n        (\n            \"_sort_desc=sortable_with_nulls\",\n            lambda row: (\n                1 if row[\"sortable_with_nulls\"] is None else 0,\n                (\n                    -row[\"sortable_with_nulls\"]\n                    if row[\"sortable_with_nulls\"] is not None\n                    else 0\n                ),\n                row[\"content\"],\n            ),\n            \"sorted by sortable_with_nulls descending\",\n        ),\n        # text column contains '$null' - ensure it doesn't confuse pagination:\n        (\"_sort=text\", lambda row: row[\"text\"], \"sorted by text\"),\n        # Still works if sort column removed using _col=\n        (\"_sort=text&_col=content\", lambda row: row[\"text\"], \"sorted by text\"),\n    ],\n)\nasync def test_sortable(ds_client, query_string, sort_key, human_description_en):\n    path = f\"/fixtures/sortable.json?_shape=objects&_extra=human_description_en,next_url&{query_string}\"\n    fetched = []\n    page = 0\n    while path:\n        page += 1\n        assert page < 100\n        response = await ds_client.get(path)\n        assert human_description_en == response.json()[\"human_description_en\"]\n        fetched.extend(response.json()[\"rows\"])\n        path = response.json()[\"next_url\"]\n        if path:\n            path = path.replace(\"http://localhost\", \"\")\n    assert page == 5\n    expected = list(generate_sortable_rows(201))\n    expected.sort(key=sort_key)\n    assert [r[\"content\"] for r in expected] == [r[\"content\"] for r in fetched]\n\n\n@pytest.mark.asyncio\nasync def test_sortable_and_filtered(ds_client):\n    path = (\n        \"/fixtures/sortable.json\"\n        \"?content__contains=d&_sort_desc=sortable&_shape=objects\"\n        \"&_extra=human_description_en,count\"\n    )\n    response = await ds_client.get(path)\n    fetched = response.json()[\"rows\"]\n    assert (\n        'where content contains \"d\" sorted by sortable descending'\n        == response.json()[\"human_description_en\"]\n    )\n    expected = [row for row in generate_sortable_rows(201) if \"d\" in row[\"content\"]]\n    assert len(expected) == response.json()[\"count\"]\n    expected.sort(key=lambda row: -row[\"sortable\"])\n    assert [r[\"content\"] for r in expected] == [r[\"content\"] for r in fetched]\n\n\n@pytest.mark.asyncio\nasync def test_sortable_argument_errors(ds_client):\n    response = await ds_client.get(\"/fixtures/sortable.json?_sort=badcolumn\")\n    assert \"Cannot sort table by badcolumn\" == response.json()[\"error\"]\n    response = await ds_client.get(\"/fixtures/sortable.json?_sort_desc=badcolumn2\")\n    assert \"Cannot sort table by badcolumn2\" == response.json()[\"error\"]\n    response = await ds_client.get(\n        \"/fixtures/sortable.json?_sort=sortable_with_nulls&_sort_desc=sortable\"\n    )\n    assert (\n        \"Cannot use _sort and _sort_desc at the same time\" == response.json()[\"error\"]\n    )\n\n\n@pytest.mark.asyncio\nasync def test_sortable_columns_metadata(ds_client):\n    response = await ds_client.get(\"/fixtures/sortable.json?_sort=content\")\n    assert \"Cannot sort table by content\" == response.json()[\"error\"]\n    # no_primary_key has ALL sort options disabled\n    for column in (\"content\", \"a\", \"b\", \"c\"):\n        response = await ds_client.get(f\"/fixtures/sortable.json?_sort={column}\")\n        assert f\"Cannot sort table by {column}\" == response.json()[\"error\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.xfail\n@pytest.mark.parametrize(\n    \"path,expected_rows\",\n    [\n        (\n            \"/fixtures/searchable.json?_shape=arrays&_search=dog\",\n            [\n                [1, \"barry cat\", \"terry dog\", \"panther\"],\n                [2, \"terry dog\", \"sara weasel\", \"puma\"],\n            ],\n        ),\n        (\n            # Special keyword shouldn't break FTS query\n            \"/fixtures/searchable.json?_shape=arrays&_search=AND\",\n            [],\n        ),\n        (\n            # Without _searchmode=raw this should return no results\n            \"/fixtures/searchable.json?_shape=arrays&_search=te*+AND+do*\",\n            [],\n        ),\n        (\n            # _searchmode=raw\n            \"/fixtures/searchable.json?_shape=arrays&_search=te*+AND+do*&_searchmode=raw\",\n            [\n                [1, \"barry cat\", \"terry dog\", \"panther\"],\n                [2, \"terry dog\", \"sara weasel\", \"puma\"],\n            ],\n        ),\n        (\n            # _searchmode=raw combined with _search_COLUMN\n            \"/fixtures/searchable.json?_shape=arrays&_search_text2=te*&_searchmode=raw\",\n            [\n                [1, \"barry cat\", \"terry dog\", \"panther\"],\n            ],\n        ),\n        (\n            \"/fixtures/searchable.json?_shape=arrays&_search=weasel\",\n            [[2, \"terry dog\", \"sara weasel\", \"puma\"]],\n        ),\n        (\n            \"/fixtures/searchable.json?_shape=arrays&_search_text2=dog\",\n            [[1, \"barry cat\", \"terry dog\", \"panther\"]],\n        ),\n        (\n            \"/fixtures/searchable.json?_shape=arrays&_search_name%20with%20.%20and%20spaces=panther\",\n            [[1, \"barry cat\", \"terry dog\", \"panther\"]],\n        ),\n    ],\n)\nasync def test_searchable(ds_client, path, expected_rows):\n    response = await ds_client.get(path)\n    assert expected_rows == response.json()[\"rows\"]\n\n\n_SEARCHMODE_RAW_RESULTS = [\n    [1, \"barry cat\", \"terry dog\", \"panther\"],\n    [2, \"terry dog\", \"sara weasel\", \"puma\"],\n]\n\n\n@pytest.mark.parametrize(\n    \"table_metadata,querystring,expected_rows\",\n    [\n        (\n            {},\n            \"_search=te*+AND+do*\",\n            [],\n        ),\n        (\n            {\"searchmode\": \"raw\"},\n            \"_search=te*+AND+do*\",\n            _SEARCHMODE_RAW_RESULTS,\n        ),\n        (\n            {},\n            \"_search=te*+AND+do*&_searchmode=raw\",\n            _SEARCHMODE_RAW_RESULTS,\n        ),\n        # Can be over-ridden with _searchmode=escaped\n        (\n            {\"searchmode\": \"raw\"},\n            \"_search=te*+AND+do*&_searchmode=escaped\",\n            [],\n        ),\n    ],\n)\ndef test_searchmode(table_metadata, querystring, expected_rows):\n    with make_app_client(\n        metadata={\"databases\": {\"fixtures\": {\"tables\": {\"searchable\": table_metadata}}}}\n    ) as client:\n        response = client.get(\"/fixtures/searchable.json?_shape=arrays&\" + querystring)\n        assert expected_rows == response.json[\"rows\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_rows\",\n    [\n        (\n            \"/fixtures/searchable_view_configured_by_metadata.json?_shape=arrays&_search=weasel\",\n            [[2, \"terry dog\", \"sara weasel\", \"puma\"]],\n        ),\n        # This should return all results because search is not configured:\n        (\n            \"/fixtures/searchable_view.json?_shape=arrays&_search=weasel\",\n            [\n                [1, \"barry cat\", \"terry dog\", \"panther\"],\n                [2, \"terry dog\", \"sara weasel\", \"puma\"],\n            ],\n        ),\n        (\n            \"/fixtures/searchable_view.json?_shape=arrays&_search=weasel&_fts_table=searchable_fts&_fts_pk=pk\",\n            [[2, \"terry dog\", \"sara weasel\", \"puma\"]],\n        ),\n    ],\n)\nasync def test_searchable_views(ds_client, path, expected_rows):\n    response = await ds_client.get(path)\n    assert response.json()[\"rows\"] == expected_rows\n\n\n@pytest.mark.asyncio\nasync def test_searchable_invalid_column(ds_client):\n    response = await ds_client.get(\"/fixtures/searchable.json?_search_invalid=x\")\n    assert response.status_code == 400\n    assert response.json() == {\n        \"ok\": False,\n        \"error\": \"Cannot search by that column\",\n        \"status\": 400,\n        \"title\": None,\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_rows\",\n    [\n        (\n            \"/fixtures/simple_primary_key.json?_shape=arrays&content=hello\",\n            [[1, \"hello\"]],\n        ),\n        (\n            \"/fixtures/simple_primary_key.json?_shape=arrays&content__contains=o\",\n            [\n                [1, \"hello\"],\n                [2, \"world\"],\n                [4, \"RENDER_CELL_DEMO\"],\n            ],\n        ),\n        (\n            \"/fixtures/simple_primary_key.json?_shape=arrays&content__exact=\",\n            [[3, \"\"]],\n        ),\n        (\n            \"/fixtures/simple_primary_key.json?_shape=arrays&content__not=world\",\n            [\n                [1, \"hello\"],\n                [3, \"\"],\n                [4, \"RENDER_CELL_DEMO\"],\n                [5, \"RENDER_CELL_ASYNC\"],\n            ],\n        ),\n    ],\n)\nasync def test_table_filter_queries(ds_client, path, expected_rows):\n    response = await ds_client.get(path)\n    assert response.json()[\"rows\"] == expected_rows\n\n\n@pytest.mark.asyncio\nasync def test_table_filter_queries_multiple_of_same_type(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/simple_primary_key.json?_shape=arrays&content__not=world&content__not=hello\"\n    )\n    assert [\n        [3, \"\"],\n        [4, \"RENDER_CELL_DEMO\"],\n        [5, \"RENDER_CELL_ASYNC\"],\n    ] == response.json()[\"rows\"]\n\n\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\n@pytest.mark.asyncio\nasync def test_table_filter_json_arraycontains(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_shape=arrays&tags__arraycontains=tag1\"\n    )\n    assert response.json()[\"rows\"] == [\n        [\n            1,\n            \"2019-01-14 08:00:00\",\n            1,\n            1,\n            \"CA\",\n            1,\n            \"Mission\",\n            '[\"tag1\", \"tag2\"]',\n            '[{\"foo\": \"bar\"}]',\n            \"one\",\n            \"n1\",\n        ],\n        [\n            2,\n            \"2019-01-14 08:00:00\",\n            1,\n            1,\n            \"CA\",\n            1,\n            \"Dogpatch\",\n            '[\"tag1\", \"tag3\"]',\n            \"[]\",\n            \"two\",\n            \"n2\",\n        ],\n    ]\n\n\n@pytest.mark.skipif(not detect_json1(), reason=\"Requires the SQLite json1 module\")\n@pytest.mark.asyncio\nasync def test_table_filter_json_arraynotcontains(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_shape=arrays&tags__arraynotcontains=tag3&tags__not=[]\"\n    )\n    assert response.json()[\"rows\"] == [\n        [\n            1,\n            \"2019-01-14 08:00:00\",\n            1,\n            1,\n            \"CA\",\n            1,\n            \"Mission\",\n            '[\"tag1\", \"tag2\"]',\n            '[{\"foo\": \"bar\"}]',\n            \"one\",\n            \"n1\",\n        ]\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_filter_extra_where(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_shape=arrays&_where=_neighborhood='Dogpatch'\"\n    )\n    assert [\n        [\n            2,\n            \"2019-01-14 08:00:00\",\n            1,\n            1,\n            \"CA\",\n            1,\n            \"Dogpatch\",\n            '[\"tag1\", \"tag3\"]',\n            \"[]\",\n            \"two\",\n            \"n2\",\n        ]\n    ] == response.json()[\"rows\"]\n\n\n@pytest.mark.asyncio\nasync def test_table_filter_extra_where_invalid(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_where=_neighborhood=Dogpatch'\"\n    )\n    assert response.status_code == 400\n    assert \"Invalid SQL\" == response.json()[\"title\"]\n\n\ndef test_table_filter_extra_where_disabled_if_no_sql_allowed():\n    with make_app_client(config={\"allow_sql\": {}}) as client:\n        response = client.get(\n            \"/fixtures/facetable.json?_where=_neighborhood='Dogpatch'\"\n        )\n        assert response.status_code == 403\n        assert \"_where= is not allowed\" == response.json[\"error\"]\n\n\n@pytest.mark.asyncio\nasync def test_table_through(ds_client):\n    # Just the museums:\n    response = await ds_client.get(\n        \"/fixtures/roadside_attractions.json?_shape=arrays\"\n        '&_through={\"table\":\"roadside_attraction_characteristics\",\"column\":\"characteristic_id\",\"value\":\"1\"}'\n        \"&_extra=human_description_en\"\n    )\n    assert response.json()[\"rows\"] == [\n        [\n            3,\n            \"Burlingame Museum of PEZ Memorabilia\",\n            \"214 California Drive, Burlingame, CA 94010\",\n            None,\n            37.5793,\n            -122.3442,\n        ],\n        [\n            4,\n            \"Bigfoot Discovery Museum\",\n            \"5497 Highway 9, Felton, CA 95018\",\n            \"https://www.bigfootdiscoveryproject.com/\",\n            37.0414,\n            -122.0725,\n        ],\n    ]\n\n    assert (\n        response.json()[\"human_description_en\"]\n        == 'where roadside_attraction_characteristics.characteristic_id = \"1\"'\n    )\n\n\n@pytest.mark.asyncio\nasync def test_max_returned_rows(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/-/query.json?sql=select+content+from+no_primary_key\"\n    )\n    data = response.json()\n    assert data[\"truncated\"]\n    assert 100 == len(data[\"rows\"])\n\n\n@pytest.mark.asyncio\nasync def test_view(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_view.json?_shape=objects\")\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"rows\"] == [\n        {\"upper_content\": \"HELLO\", \"content\": \"hello\"},\n        {\"upper_content\": \"WORLD\", \"content\": \"world\"},\n        {\"upper_content\": \"\", \"content\": \"\"},\n        {\"upper_content\": \"RENDER_CELL_DEMO\", \"content\": \"RENDER_CELL_DEMO\"},\n        {\"upper_content\": \"RENDER_CELL_ASYNC\", \"content\": \"RENDER_CELL_ASYNC\"},\n    ]\n\n\ndef test_page_size_matching_max_returned_rows(\n    app_client_returned_rows_matches_page_size,\n):\n    fetched = []\n    path = \"/fixtures/no_primary_key.json?_extra=next_url\"\n    while path:\n        response = app_client_returned_rows_matches_page_size.get(path)\n        fetched.extend(response.json[\"rows\"])\n        assert len(response.json[\"rows\"]) in (2, 50)\n        path = response.json[\"next_url\"]\n        if path:\n            path = path.replace(\"http://localhost\", \"\")\n    assert len(fetched) == 202\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_facet_results\",\n    [\n        (\n            \"/fixtures/facetable.json?_facet=state&_facet=_city_id\",\n            {\n                \"state\": {\n                    \"name\": \"state\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json?_facet=_city_id\",\n                    \"results\": [\n                        {\n                            \"value\": \"CA\",\n                            \"label\": \"CA\",\n                            \"count\": 10,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&state=CA\",\n                            \"selected\": False,\n                        },\n                        {\n                            \"value\": \"MI\",\n                            \"label\": \"MI\",\n                            \"count\": 4,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&state=MI\",\n                            \"selected\": False,\n                        },\n                        {\n                            \"value\": \"MC\",\n                            \"label\": \"MC\",\n                            \"count\": 1,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&state=MC\",\n                            \"selected\": False,\n                        },\n                    ],\n                    \"truncated\": False,\n                },\n                \"_city_id\": {\n                    \"name\": \"_city_id\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json?_facet=state\",\n                    \"results\": [\n                        {\n                            \"value\": 1,\n                            \"label\": \"San Francisco\",\n                            \"count\": 6,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&_city_id__exact=1\",\n                            \"selected\": False,\n                        },\n                        {\n                            \"value\": 2,\n                            \"label\": \"Los Angeles\",\n                            \"count\": 4,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&_city_id__exact=2\",\n                            \"selected\": False,\n                        },\n                        {\n                            \"value\": 3,\n                            \"label\": \"Detroit\",\n                            \"count\": 4,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&_city_id__exact=3\",\n                            \"selected\": False,\n                        },\n                        {\n                            \"value\": 4,\n                            \"label\": \"Memnonia\",\n                            \"count\": 1,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&_city_id__exact=4\",\n                            \"selected\": False,\n                        },\n                    ],\n                    \"truncated\": False,\n                },\n            },\n        ),\n        (\n            \"/fixtures/facetable.json?_facet=state&_facet=_city_id&state=MI\",\n            {\n                \"state\": {\n                    \"name\": \"state\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json?_facet=_city_id&state=MI\",\n                    \"results\": [\n                        {\n                            \"value\": \"MI\",\n                            \"label\": \"MI\",\n                            \"count\": 4,\n                            \"selected\": True,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id\",\n                        }\n                    ],\n                    \"truncated\": False,\n                },\n                \"_city_id\": {\n                    \"name\": \"_city_id\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json?_facet=state&state=MI\",\n                    \"results\": [\n                        {\n                            \"value\": 3,\n                            \"label\": \"Detroit\",\n                            \"count\": 4,\n                            \"selected\": False,\n                            \"toggle_url\": \"_facet=state&_facet=_city_id&state=MI&_city_id__exact=3\",\n                        }\n                    ],\n                    \"truncated\": False,\n                },\n            },\n        ),\n        (\n            \"/fixtures/facetable.json?_facet=planet_int\",\n            {\n                \"planet_int\": {\n                    \"name\": \"planet_int\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json\",\n                    \"results\": [\n                        {\n                            \"value\": 1,\n                            \"label\": 1,\n                            \"count\": 14,\n                            \"selected\": False,\n                            \"toggle_url\": \"_facet=planet_int&planet_int=1\",\n                        },\n                        {\n                            \"value\": 2,\n                            \"label\": 2,\n                            \"count\": 1,\n                            \"selected\": False,\n                            \"toggle_url\": \"_facet=planet_int&planet_int=2\",\n                        },\n                    ],\n                    \"truncated\": False,\n                }\n            },\n        ),\n        (\n            # planet_int is an integer field:\n            \"/fixtures/facetable.json?_facet=planet_int&planet_int=1\",\n            {\n                \"planet_int\": {\n                    \"name\": \"planet_int\",\n                    \"hideable\": True,\n                    \"type\": \"column\",\n                    \"toggle_url\": \"/fixtures/facetable.json?planet_int=1\",\n                    \"results\": [\n                        {\n                            \"value\": 1,\n                            \"label\": 1,\n                            \"count\": 14,\n                            \"selected\": True,\n                            \"toggle_url\": \"_facet=planet_int\",\n                        }\n                    ],\n                    \"truncated\": False,\n                }\n            },\n        ),\n    ],\n)\nasync def test_facets(ds_client, path, expected_facet_results):\n    response = await ds_client.get(path)\n    facet_results = response.json()[\"facet_results\"]\n    # We only compare the querystring portion of the taggle_url\n    for facet_name, facet_info in facet_results[\"results\"].items():\n        assert facet_name == facet_info[\"name\"]\n        assert False is facet_info[\"truncated\"]\n        for facet_value in facet_info[\"results\"]:\n            facet_value[\"toggle_url\"] = facet_value[\"toggle_url\"].split(\"?\")[1]\n    assert expected_facet_results == facet_results[\"results\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.skipif(not detect_json1(), reason=\"requires JSON1 extension\")\nasync def test_facets_array(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable.json?_facet_array=tags\")\n    facet_results = response.json()[\"facet_results\"]\n    assert facet_results[\"results\"][\"tags\"][\"results\"] == [\n        {\n            \"value\": \"tag1\",\n            \"label\": \"tag1\",\n            \"count\": 2,\n            \"toggle_url\": \"http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag1\",\n            \"selected\": False,\n        },\n        {\n            \"value\": \"tag2\",\n            \"label\": \"tag2\",\n            \"count\": 1,\n            \"toggle_url\": \"http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag2\",\n            \"selected\": False,\n        },\n        {\n            \"value\": \"tag3\",\n            \"label\": \"tag3\",\n            \"count\": 1,\n            \"toggle_url\": \"http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag3\",\n            \"selected\": False,\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_suggested_facets(ds_client):\n    suggestions = [\n        {\n            \"name\": suggestion[\"name\"],\n            \"querystring\": suggestion[\"toggle_url\"].split(\"?\")[-1],\n        }\n        for suggestion in (\n            await ds_client.get(\"/fixtures/facetable.json?_extra=suggested_facets\")\n        ).json()[\"suggested_facets\"]\n    ]\n    expected = [\n        {\"name\": \"created\", \"querystring\": \"_extra=suggested_facets&_facet=created\"},\n        {\n            \"name\": \"planet_int\",\n            \"querystring\": \"_extra=suggested_facets&_facet=planet_int\",\n        },\n        {\"name\": \"on_earth\", \"querystring\": \"_extra=suggested_facets&_facet=on_earth\"},\n        {\"name\": \"state\", \"querystring\": \"_extra=suggested_facets&_facet=state\"},\n        {\"name\": \"_city_id\", \"querystring\": \"_extra=suggested_facets&_facet=_city_id\"},\n        {\n            \"name\": \"_neighborhood\",\n            \"querystring\": \"_extra=suggested_facets&_facet=_neighborhood\",\n        },\n        {\"name\": \"tags\", \"querystring\": \"_extra=suggested_facets&_facet=tags\"},\n        {\n            \"name\": \"complex_array\",\n            \"querystring\": \"_extra=suggested_facets&_facet=complex_array\",\n        },\n        {\n            \"name\": \"created\",\n            \"querystring\": \"_extra=suggested_facets&_facet_date=created\",\n        },\n    ]\n    if detect_json1():\n        expected.append(\n            {\"name\": \"tags\", \"querystring\": \"_extra=suggested_facets&_facet_array=tags\"}\n        )\n    assert expected == suggestions\n\n\ndef test_allow_facet_off():\n    with make_app_client(settings={\"allow_facet\": False}) as client:\n        assert (\n            client.get(\n                \"/fixtures/facetable.json?_facet=planet_int&_extra=suggested_facets\"\n            ).status\n            == 400\n        )\n        data = client.get(\"/fixtures/facetable.json?_extra=suggested_facets\").json\n        # Should not suggest any facets either:\n        assert [] == data[\"suggested_facets\"]\n\n\ndef test_suggest_facets_off():\n    with make_app_client(settings={\"suggest_facets\": False}) as client:\n        # Now suggested_facets should be []\n        assert (\n            []\n            == client.get(\"/fixtures/facetable.json?_extra=suggested_facets\").json[\n                \"suggested_facets\"\n            ]\n        )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"nofacet\", (True, False))\nasync def test_nofacet(ds_client, nofacet):\n    path = \"/fixtures/facetable.json?_facet=state&_extra=suggested_facets\"\n    if nofacet:\n        path += \"&_nofacet=1\"\n    response = await ds_client.get(path)\n    if nofacet:\n        assert response.json()[\"suggested_facets\"] == []\n        assert response.json()[\"facet_results\"][\"results\"] == {}\n    else:\n        assert response.json()[\"suggested_facets\"] != []\n        assert response.json()[\"facet_results\"][\"results\"] != {}\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"nosuggest\", (True, False))\nasync def test_nosuggest(ds_client, nosuggest):\n    path = \"/fixtures/facetable.json?_facet=state&_extra=suggested_facets\"\n    if nosuggest:\n        path += \"&_nosuggest=1\"\n    response = await ds_client.get(path)\n    if nosuggest:\n        assert response.json()[\"suggested_facets\"] == []\n        # But facets should still be returned:\n        assert response.json()[\"facet_results\"] != {}\n    else:\n        assert response.json()[\"suggested_facets\"] != []\n        assert response.json()[\"facet_results\"] != {}\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"nocount,expected_count\", ((True, None), (False, 15)))\nasync def test_nocount(ds_client, nocount, expected_count):\n    path = \"/fixtures/facetable.json?_extra=count\"\n    if nocount:\n        path += \"&_nocount=1\"\n    response = await ds_client.get(path)\n    assert response.json()[\"count\"] == expected_count\n\n\ndef test_nocount_nofacet_if_shape_is_object(app_client_with_trace):\n    response = app_client_with_trace.get(\n        \"/fixtures/facetable.json?_trace=1&_shape=object\"\n    )\n    assert \"count(*)\" not in response.text\n\n\n@pytest.mark.asyncio\nasync def test_expand_labels(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable.json?_shape=object&_labels=1&_size=2\"\n        \"&_neighborhood__contains=c\"\n    )\n    assert response.json() == {\n        \"2\": {\n            \"pk\": 2,\n            \"created\": \"2019-01-14 08:00:00\",\n            \"planet_int\": 1,\n            \"on_earth\": 1,\n            \"state\": \"CA\",\n            \"_city_id\": {\"value\": 1, \"label\": \"San Francisco\"},\n            \"_neighborhood\": \"Dogpatch\",\n            \"tags\": '[\"tag1\", \"tag3\"]',\n            \"complex_array\": \"[]\",\n            \"distinct_some_null\": \"two\",\n            \"n\": \"n2\",\n        },\n        \"13\": {\n            \"pk\": 13,\n            \"created\": \"2019-01-17 08:00:00\",\n            \"planet_int\": 1,\n            \"on_earth\": 1,\n            \"state\": \"MI\",\n            \"_city_id\": {\"value\": 3, \"label\": \"Detroit\"},\n            \"_neighborhood\": \"Corktown\",\n            \"tags\": \"[]\",\n            \"complex_array\": \"[]\",\n            \"distinct_some_null\": None,\n            \"n\": None,\n        },\n    }\n\n\n@pytest.mark.asyncio\nasync def test_expand_label(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/foreign_key_references.json?_shape=object\"\n        \"&_label=foreign_key_with_label&_size=1\"\n    )\n    assert response.json() == {\n        \"1\": {\n            \"pk\": \"1\",\n            \"foreign_key_with_label\": {\"value\": 1, \"label\": \"hello\"},\n            \"foreign_key_with_blank_label\": 3,\n            \"foreign_key_with_no_label\": \"1\",\n            \"foreign_key_compound_pk1\": \"a\",\n            \"foreign_key_compound_pk2\": \"b\",\n        }\n    }\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_cache_control\",\n    [\n        (\"/fixtures/facetable.json\", \"max-age=5\"),\n        (\"/fixtures/facetable.json?_ttl=invalid\", \"max-age=5\"),\n        (\"/fixtures/facetable.json?_ttl=10\", \"max-age=10\"),\n        (\"/fixtures/facetable.json?_ttl=0\", \"no-cache\"),\n    ],\n)\nasync def test_ttl_parameter(ds_client, path, expected_cache_control):\n    response = await ds_client.get(path)\n    assert response.headers[\"Cache-Control\"] == expected_cache_control\n\n\n@pytest.mark.asyncio\nasync def test_infinity_returned_as_null(ds_client):\n    response = await ds_client.get(\"/fixtures/infinity.json?_shape=array\")\n    assert response.json() == [\n        {\"rowid\": 1, \"value\": None},\n        {\"rowid\": 2, \"value\": None},\n        {\"rowid\": 3, \"value\": 1.5},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_infinity_returned_as_invalid_json_if_requested(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/infinity.json?_shape=array&_json_infinity=1\"\n    )\n    assert response.json() == [\n        {\"rowid\": 1, \"value\": float(\"inf\")},\n        {\"rowid\": 2, \"value\": float(\"-inf\")},\n        {\"rowid\": 3, \"value\": 1.5},\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_custom_query_with_unicode_characters(ds_client):\n    # /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬.json\n    response = await ds_client.get(\n        \"/fixtures/~F0~9D~90~9C~F0~9D~90~A2~F0~9D~90~AD~F0~9D~90~A2~F0~9D~90~9E~F0~9D~90~AC.json?_shape=array\"\n    )\n    assert response.json() == [{\"id\": 1, \"name\": \"San Francisco\"}]\n\n\n@pytest.mark.asyncio\nasync def test_null_and_compound_foreign_keys_are_not_expanded(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/foreign_key_references.json?_shape=array&_labels=on\"\n    )\n    assert response.json() == [\n        {\n            \"pk\": \"1\",\n            \"foreign_key_with_label\": {\"value\": 1, \"label\": \"hello\"},\n            \"foreign_key_with_blank_label\": {\"value\": 3, \"label\": \"\"},\n            \"foreign_key_with_no_label\": {\"value\": \"1\", \"label\": \"1\"},\n            \"foreign_key_compound_pk1\": \"a\",\n            \"foreign_key_compound_pk2\": \"b\",\n        },\n        {\n            \"pk\": \"2\",\n            \"foreign_key_with_label\": None,\n            \"foreign_key_with_blank_label\": None,\n            \"foreign_key_with_no_label\": None,\n            \"foreign_key_compound_pk1\": None,\n            \"foreign_key_compound_pk2\": None,\n        },\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_json,expected_text\",\n    [\n        (\n            \"/fixtures/binary_data.json?_shape=array\",\n            [\n                {\"rowid\": 1, \"data\": {\"$base64\": True, \"encoded\": \"FRwCx60F/g==\"}},\n                {\"rowid\": 2, \"data\": {\"$base64\": True, \"encoded\": \"FRwDx60F/g==\"}},\n                {\"rowid\": 3, \"data\": None},\n            ],\n            None,\n        ),\n        (\n            \"/fixtures/binary_data.json?_shape=array&_nl=on\",\n            None,\n            (\n                '{\"rowid\": 1, \"data\": {\"$base64\": true, \"encoded\": \"FRwCx60F/g==\"}}\\n'\n                '{\"rowid\": 2, \"data\": {\"$base64\": true, \"encoded\": \"FRwDx60F/g==\"}}\\n'\n                '{\"rowid\": 3, \"data\": null}'\n            ),\n        ),\n    ],\n)\nasync def test_binary_data_in_json(ds_client, path, expected_json, expected_text):\n    response = await ds_client.get(path)\n    if expected_json:\n        assert response.json() == expected_json\n    else:\n        assert response.text == expected_text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"qs\",\n    [\n        \"\",\n        \"?_shape=arrays\",\n        \"?_shape=arrayfirst\",\n        \"?_shape=object\",\n        \"?_shape=objects\",\n        \"?_shape=array\",\n        \"?_shape=array&_nl=on\",\n    ],\n)\nasync def test_paginate_using_link_header(ds_client, qs):\n    path = f\"/fixtures/compound_three_primary_keys.json{qs}\"\n    num_pages = 0\n    while path:\n        response = await ds_client.get(path)\n        assert response.status_code == 200\n        num_pages += 1\n        link = response.headers.get(\"link\")\n        if link:\n            assert link.startswith(\"<\")\n            assert link.endswith('>; rel=\"next\"')\n            path = link[1:].split(\">\")[0]\n            path = path.replace(\"http://localhost\", \"\")\n        else:\n            path = None\n    assert num_pages == 21\n\n\n@pytest.mark.skipif(\n    sqlite_version() < (3, 31, 0),\n    reason=\"generated columns were added in SQLite 3.31.0\",\n)\ndef test_generated_columns_are_visible_in_datasette():\n    with make_app_client(extra_databases={\"generated.db\": \"\"\"\n                CREATE TABLE generated_columns (\n                    body TEXT,\n                    id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED,\n                    consideration INT GENERATED ALWAYS AS (json_extract(body, '$.string')) STORED\n                );\n                INSERT INTO generated_columns (body) VALUES (\n                    '{\"number\": 1, \"string\": \"This is a string\"}'\n                );\"\"\"}) as client:\n        response = client.get(\"/generated/generated_columns.json?_shape=array\")\n        assert response.json == [\n            {\n                \"rowid\": 1,\n                \"body\": '{\"number\": 1, \"string\": \"This is a string\"}',\n                \"id\": 1,\n                \"consideration\": \"This is a string\",\n            }\n        ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_columns\",\n    (\n        (\"/fixtures/facetable.json?_col=created\", [\"pk\", \"created\"]),\n        (\n            \"/fixtures/facetable.json?_nocol=created\",\n            [\n                \"pk\",\n                \"planet_int\",\n                \"on_earth\",\n                \"state\",\n                \"_city_id\",\n                \"_neighborhood\",\n                \"tags\",\n                \"complex_array\",\n                \"distinct_some_null\",\n                \"n\",\n            ],\n        ),\n        (\n            \"/fixtures/facetable.json?_col=state&_col=created\",\n            [\"pk\", \"state\", \"created\"],\n        ),\n        (\n            \"/fixtures/facetable.json?_col=state&_col=state\",\n            [\"pk\", \"state\"],\n        ),\n        (\n            \"/fixtures/facetable.json?_col=state&_col=created&_nocol=created\",\n            [\"pk\", \"state\"],\n        ),\n        (\n            # Ensure faceting doesn't break, https://github.com/simonw/datasette/issues/1345\n            \"/fixtures/facetable.json?_nocol=state&_facet=state\",\n            [\n                \"pk\",\n                \"created\",\n                \"planet_int\",\n                \"on_earth\",\n                \"_city_id\",\n                \"_neighborhood\",\n                \"tags\",\n                \"complex_array\",\n                \"distinct_some_null\",\n                \"n\",\n            ],\n        ),\n        (\n            \"/fixtures/simple_view.json?_nocol=content\",\n            [\"upper_content\"],\n        ),\n        (\"/fixtures/simple_view.json?_col=content\", [\"content\"]),\n    ),\n)\nasync def test_col_nocol(ds_client, path, expected_columns):\n    response = await ds_client.get(path + \"&_extra=columns\")\n    assert response.status_code == 200\n    columns = response.json()[\"columns\"]\n    assert columns == expected_columns\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_error\",\n    (\n        (\"/fixtures/facetable.json?_col=bad\", \"_col=bad - invalid columns\"),\n        (\"/fixtures/facetable.json?_nocol=bad\", \"_nocol=bad - invalid columns\"),\n        (\"/fixtures/facetable.json?_nocol=pk\", \"_nocol=pk - invalid columns\"),\n        (\"/fixtures/simple_view.json?_col=bad\", \"_col=bad - invalid columns\"),\n    ),\n)\nasync def test_col_nocol_errors(ds_client, path, expected_error):\n    response = await ds_client.get(path)\n    assert response.status_code == 400\n    assert response.json()[\"error\"] == expected_error\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"extra,expected_json\",\n    (\n        (\n            \"columns\",\n            {\n                \"ok\": True,\n                \"next\": None,\n                \"columns\": [\"id\", \"content\", \"content2\"],\n                \"rows\": [{\"id\": \"1\", \"content\": \"hey\", \"content2\": \"world\"}],\n                \"truncated\": False,\n            },\n        ),\n        (\n            \"count\",\n            {\n                \"ok\": True,\n                \"next\": None,\n                \"rows\": [{\"id\": \"1\", \"content\": \"hey\", \"content2\": \"world\"}],\n                \"truncated\": False,\n                \"count\": 1,\n            },\n        ),\n    ),\n)\nasync def test_table_extras(ds_client, extra, expected_json):\n    response = await ds_client.get(\n        \"/fixtures/primary_key_multiple_columns.json?_extra=\" + extra\n    )\n    assert response.status_code == 200\n    assert response.json() == expected_json\n\n\n@pytest.mark.asyncio\nasync def test_extra_render_cell():\n    \"\"\"Test that _extra=render_cell returns rendered HTML from render_cell plugin hook\"\"\"\n    from datasette import hookimpl\n    from datasette.app import Datasette\n\n    class TestRenderCellPlugin:\n        __name__ = \"TestRenderCellPlugin\"\n\n        @hookimpl\n        def render_cell(self, value, column, table, database):\n            # Only modify cells in our test table\n            if table == \"test_render\" and column == \"name\":\n                return f\"<strong>{value}</strong>\"\n            return None\n\n    ds = Datasette(memory=True)\n    await ds.invoke_startup()\n    db = ds.add_memory_database(\"test_table_render\")\n    await db.execute_write(\n        \"create table test_render (id integer primary key, name text)\"\n    )\n    await db.execute_write(\"insert into test_render values (1, 'Alice')\")\n    await db.execute_write(\"insert into test_render values (2, 'Bob')\")\n\n    # Register our test plugin\n    ds.pm.register(TestRenderCellPlugin(), name=\"TestRenderCellPlugin\")\n\n    try:\n        # Request with _extra=render_cell\n        response = await ds.client.get(\n            \"/test_table_render/test_render.json?_extra=render_cell\"\n        )\n        assert response.status_code == 200\n        data = response.json()\n\n        # Verify the response structure\n        assert \"render_cell\" in data\n        assert \"rows\" in data\n\n        # render_cell should be a list of rows, each row being a dict of column -> rendered HTML\n        # Only columns modified by plugins are included (sparse output)\n        render_cell = data[\"render_cell\"]\n        assert len(render_cell) == 2\n\n        # First row: id=1, name='Alice'\n        # The 'name' column should be rendered by our plugin as <strong>Alice</strong>\n        assert render_cell[0][\"name\"] == \"<strong>Alice</strong>\"\n        # The 'id' column is not included since no plugin modified it\n        assert \"id\" not in render_cell[0]\n\n        # Second row: id=2, name='Bob'\n        assert render_cell[1][\"name\"] == \"<strong>Bob</strong>\"\n        assert \"id\" not in render_cell[1]\n\n        # The regular rows should still contain raw values\n        assert data[\"rows\"] == [\n            {\"id\": 1, \"name\": \"Alice\"},\n            {\"id\": 2, \"name\": \"Bob\"},\n        ]\n\n    finally:\n        ds.pm.unregister(name=\"TestRenderCellPlugin\")\n"
  },
  {
    "path": "tests/test_table_html.py",
    "content": "from datasette.app import Datasette\nfrom bs4 import BeautifulSoup as Soup\nfrom .fixtures import make_app_client\nimport pathlib\nimport pytest\nimport urllib.parse\nfrom .utils import inner_html\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_definition_sql\",\n    [\n        (\n            \"/fixtures/facet_cities\",\n            \"\"\"\nCREATE TABLE facet_cities (\n    id integer primary key,\n    name text\n);\n        \"\"\".strip(),\n        ),\n        (\n            \"/fixtures/compound_three_primary_keys\",\n            \"\"\"\nCREATE TABLE compound_three_primary_keys (\n  pk1 varchar(30),\n  pk2 varchar(30),\n  pk3 varchar(30),\n  content text,\n  PRIMARY KEY (pk1, pk2, pk3)\n);\nCREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);\n            \"\"\".strip(),\n        ),\n    ],\n)\nasync def test_table_definition_sql(path, expected_definition_sql, ds_client):\n    response = await ds_client.get(path)\n    pre = Soup(response.text, \"html.parser\").select_one(\"pre.wrapped-sql\")\n    assert expected_definition_sql == pre.string\n\n\ndef test_table_cell_truncation():\n    with make_app_client(settings={\"truncate_cells_html\": 5}) as client:\n        response = client.get(\"/fixtures/facetable\")\n        assert response.status == 200\n        table = Soup(response.body, \"html.parser\").find(\"table\")\n        assert table[\"class\"] == [\"rows-and-columns\"]\n        assert [\n            \"Missi…\",\n            \"Dogpa…\",\n            \"SOMA\",\n            \"Tende…\",\n            \"Berna…\",\n            \"Hayes…\",\n            \"Holly…\",\n            \"Downt…\",\n            \"Los F…\",\n            \"Korea…\",\n            \"Downt…\",\n            \"Greek…\",\n            \"Corkt…\",\n            \"Mexic…\",\n            \"Arcad…\",\n        ] == [\n            td.string\n            for td in table.find_all(\"td\", {\"class\": \"col-neighborhood-b352a7\"})\n        ]\n        # URLs should be truncated too\n        response2 = client.get(\"/fixtures/roadside_attractions\")\n        assert response2.status == 200\n        table = Soup(response2.body, \"html.parser\").find(\"table\")\n        tds = table.find_all(\"td\", {\"class\": \"col-url\"})\n        assert [str(td) for td in tds] == [\n            '<td class=\"col-url type-str\"><a href=\"https://www.mysteryspot.com/\">http…</a></td>',\n            '<td class=\"col-url type-str\"><a href=\"https://winchestermysteryhouse.com/\">http…</a></td>',\n            '<td class=\"col-url type-none\">\\xa0</td>',\n            '<td class=\"col-url type-str\"><a href=\"https://www.bigfootdiscoveryproject.com/\">http…</a></td>',\n        ]\n\n\n@pytest.mark.asyncio\nasync def test_add_filter_redirects(ds_client):\n    filter_args = urllib.parse.urlencode(\n        {\"_filter_column\": \"content\", \"_filter_op\": \"startswith\", \"_filter_value\": \"x\"}\n    )\n    path_base = \"/fixtures/simple_primary_key\"\n    path = path_base + \"?\" + filter_args\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"].endswith(\"?content__startswith=x\")\n\n    # Adding a redirect to an existing query string:\n    path = path_base + \"?foo=bar&\" + filter_args\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"].endswith(\"?foo=bar&content__startswith=x\")\n\n    # Test that op with a __x suffix overrides the filter value\n    path = (\n        path_base\n        + \"?\"\n        + urllib.parse.urlencode(\n            {\n                \"_filter_column\": \"content\",\n                \"_filter_op\": \"isnull__5\",\n                \"_filter_value\": \"x\",\n            }\n        )\n    )\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"].endswith(\"?content__isnull=5\")\n\n\n@pytest.mark.asyncio\nasync def test_existing_filter_redirects(ds_client):\n    filter_args = {\n        \"_filter_column_1\": \"name\",\n        \"_filter_op_1\": \"contains\",\n        \"_filter_value_1\": \"hello\",\n        \"_filter_column_2\": \"age\",\n        \"_filter_op_2\": \"gte\",\n        \"_filter_value_2\": \"22\",\n        \"_filter_column_3\": \"age\",\n        \"_filter_op_3\": \"lt\",\n        \"_filter_value_3\": \"30\",\n        \"_filter_column_4\": \"name\",\n        \"_filter_op_4\": \"contains\",\n        \"_filter_value_4\": \"world\",\n    }\n    path_base = \"/fixtures/simple_primary_key\"\n    path = path_base + \"?\" + urllib.parse.urlencode(filter_args)\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert_querystring_equal(\n        \"name__contains=hello&age__gte=22&age__lt=30&name__contains=world\",\n        response.headers[\"Location\"].split(\"?\")[1],\n    )\n\n    # Setting _filter_column_3 to empty string should remove *_3 entirely\n    filter_args[\"_filter_column_3\"] = \"\"\n    path = path_base + \"?\" + urllib.parse.urlencode(filter_args)\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert_querystring_equal(\n        \"name__contains=hello&age__gte=22&name__contains=world\",\n        response.headers[\"Location\"].split(\"?\")[1],\n    )\n\n    # ?_filter_op=exact should be removed if unaccompanied by _fiter_column\n    response = await ds_client.get(path_base + \"?_filter_op=exact\")\n    assert response.status_code == 302\n    assert \"?\" not in response.headers[\"Location\"]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"qs,expected_hidden\",\n    (\n        # Things that should be reflected in hidden form fields:\n        (\"_facet=_neighborhood\", {\"_facet\": \"_neighborhood\"}),\n        (\"_where=1+=+1&_col=_city_id\", {\"_where\": \"1 = 1\", \"_col\": \"_city_id\"}),\n        # Things that should NOT be reflected in hidden form fields:\n        (\n            \"_facet=_neighborhood&_neighborhood__exact=Downtown\",\n            {\"_facet\": \"_neighborhood\"},\n        ),\n        (\"_facet=_neighborhood&_city_id__gt=1\", {\"_facet\": \"_neighborhood\"}),\n    ),\n)\nasync def test_reflected_hidden_form_fields(ds_client, qs, expected_hidden):\n    # https://github.com/simonw/datasette/issues/1527\n    response = await ds_client.get(\"/fixtures/facetable?{}\".format(qs))\n    # In this case we should NOT have a hidden _neighborhood__exact=Downtown field\n    form = Soup(response.text, \"html.parser\").find(\"form\")\n    hidden_inputs = {\n        input[\"name\"]: input[\"value\"] for input in form.select(\"input[type=hidden]\")\n    }\n    assert hidden_inputs == expected_hidden\n\n\n@pytest.mark.asyncio\nasync def test_empty_search_parameter_gets_removed(ds_client):\n    path_base = \"/fixtures/simple_primary_key\"\n    path = (\n        path_base\n        + \"?\"\n        + urllib.parse.urlencode(\n            {\n                \"_search\": \"\",\n                \"_filter_column\": \"name\",\n                \"_filter_op\": \"exact\",\n                \"_filter_value\": \"chidi\",\n            }\n        )\n    )\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"].endswith(\"?name__exact=chidi\")\n\n\n@pytest.mark.asyncio\nasync def test_searchable_view_persists_fts_table(ds_client):\n    # The search form should persist ?_fts_table as a hidden field\n    response = await ds_client.get(\n        \"/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk\"\n    )\n    inputs = Soup(response.text, \"html.parser\").find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(\"_fts_table\", \"searchable_fts\"), (\"_fts_pk\", \"pk\")] == [\n        (hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_sort_by_desc_redirects(ds_client):\n    path_base = \"/fixtures/sortable\"\n    path = (\n        path_base\n        + \"?\"\n        + urllib.parse.urlencode({\"_sort\": \"sortable\", \"_sort_by_desc\": \"1\"})\n    )\n    response = await ds_client.get(path)\n    assert response.status_code == 302\n    assert response.headers[\"Location\"].endswith(\"?_sort_desc=sortable\")\n\n\n@pytest.mark.asyncio\nasync def test_sort_links(ds_client):\n    response = await ds_client.get(\"/fixtures/sortable?_sort=sortable\")\n    assert response.status_code == 200\n    ths = Soup(response.text, \"html.parser\").find_all(\"th\")\n    attrs_and_link_attrs = [\n        {\n            \"attrs\": th.attrs,\n            \"a_href\": (th.find(\"a\")[\"href\"] if th.find(\"a\") else None),\n        }\n        for th in ths\n    ]\n    assert attrs_and_link_attrs == [\n        {\n            \"attrs\": {\n                \"class\": [\"col-Link\"],\n                \"scope\": \"col\",\n                \"data-column\": \"Link\",\n                \"data-column-type\": \"\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n                \"data-is-link-column\": \"1\",\n            },\n            \"a_href\": None,\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-pk1\"],\n                \"scope\": \"col\",\n                \"data-column\": \"pk1\",\n                \"data-column-type\": \"varchar(30)\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"1\",\n            },\n            \"a_href\": None,\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-pk2\"],\n                \"scope\": \"col\",\n                \"data-column\": \"pk2\",\n                \"data-column-type\": \"varchar(30)\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"1\",\n            },\n            \"a_href\": None,\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-content\"],\n                \"scope\": \"col\",\n                \"data-column\": \"content\",\n                \"data-column-type\": \"text\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n            },\n            \"a_href\": None,\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-sortable\"],\n                \"scope\": \"col\",\n                \"data-column\": \"sortable\",\n                \"data-column-type\": \"integer\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n            },\n            \"a_href\": \"/fixtures/sortable?_sort_desc=sortable\",\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-sortable_with_nulls\"],\n                \"scope\": \"col\",\n                \"data-column\": \"sortable_with_nulls\",\n                \"data-column-type\": \"real\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n            },\n            \"a_href\": \"/fixtures/sortable?_sort=sortable_with_nulls\",\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-sortable_with_nulls_2\"],\n                \"scope\": \"col\",\n                \"data-column\": \"sortable_with_nulls_2\",\n                \"data-column-type\": \"real\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n            },\n            \"a_href\": \"/fixtures/sortable?_sort=sortable_with_nulls_2\",\n        },\n        {\n            \"attrs\": {\n                \"class\": [\"col-text\"],\n                \"scope\": \"col\",\n                \"data-column\": \"text\",\n                \"data-column-type\": \"text\",\n                \"data-column-not-null\": \"0\",\n                \"data-is-pk\": \"0\",\n            },\n            \"a_href\": \"/fixtures/sortable?_sort=text\",\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_facet_display(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable?_facet=planet_int&_facet=_city_id&_facet=on_earth\"\n    )\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    divs = soup.find(\"div\", {\"class\": \"facet-results\"}).find_all(\"div\")\n    actual = []\n    for div in divs:\n        actual.append(\n            {\n                \"name\": div.find(\"strong\").text.split()[0],\n                \"items\": [\n                    {\n                        \"name\": a.text,\n                        \"qs\": a[\"href\"].split(\"?\")[-1],\n                        \"count\": int(str(a.parent).split(\"</a>\")[1].split(\"<\")[0]),\n                    }\n                    for a in div.find(\"ul\").find_all(\"a\")\n                ],\n            }\n        )\n    assert actual == [\n        {\n            \"name\": \"_city_id\",\n            \"items\": [\n                {\n                    \"name\": \"San Francisco\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&_city_id__exact=1\",\n                    \"count\": 6,\n                },\n                {\n                    \"name\": \"Los Angeles\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&_city_id__exact=2\",\n                    \"count\": 4,\n                },\n                {\n                    \"name\": \"Detroit\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&_city_id__exact=3\",\n                    \"count\": 4,\n                },\n                {\n                    \"name\": \"Memnonia\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&_city_id__exact=4\",\n                    \"count\": 1,\n                },\n            ],\n        },\n        {\n            \"name\": \"planet_int\",\n            \"items\": [\n                {\n                    \"name\": \"1\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&planet_int=1\",\n                    \"count\": 14,\n                },\n                {\n                    \"name\": \"2\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&planet_int=2\",\n                    \"count\": 1,\n                },\n            ],\n        },\n        {\n            \"name\": \"on_earth\",\n            \"items\": [\n                {\n                    \"name\": \"1\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&on_earth=1\",\n                    \"count\": 14,\n                },\n                {\n                    \"name\": \"0\",\n                    \"qs\": \"_facet=planet_int&_facet=_city_id&_facet=on_earth&on_earth=0\",\n                    \"count\": 1,\n                },\n            ],\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_facets_persist_through_filter_form(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable?_facet=planet_int&_facet=_city_id&_facet_array=tags\"\n    )\n    assert response.status_code == 200\n    inputs = Soup(response.text, \"html.parser\").find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens] == [\n        (\"_facet\", \"planet_int\"),\n        (\"_facet\", \"_city_id\"),\n        (\"_facet_array\", \"tags\"),\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_next_does_not_persist_in_hidden_field(ds_client):\n    response = await ds_client.get(\"/fixtures/searchable?_size=1&_next=1\")\n    assert response.status_code == 200\n    inputs = Soup(response.text, \"html.parser\").find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens] == [\n        (\"_size\", \"1\"),\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_html_simple_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key?_size=3\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert table[\"class\"] == [\"rows-and-columns\"]\n    ths = table.find_all(\"th\")\n    assert \"id\\xa0▼\" == ths[0].find(\"a\").string.strip()\n    for expected_col, th in zip((\"content\",), ths[1:]):\n        a = th.find(\"a\")\n        assert expected_col == a.string\n        assert a[\"href\"].endswith(f\"/simple_primary_key?_size=3&_sort={expected_col}\")\n        assert [\"nofollow\"] == a[\"rel\"]\n    assert [\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/simple_primary_key/1\">1</a></td>',\n            '<td class=\"col-content type-str\">hello</td>',\n        ],\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/simple_primary_key/2\">2</a></td>',\n            '<td class=\"col-content type-str\">world</td>',\n        ],\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/simple_primary_key/3\">3</a></td>',\n            '<td class=\"col-content type-str\">\\xa0</td>',\n        ],\n    ] == [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n\n\n@pytest.mark.asyncio\nasync def test_table_csv_json_export_interface(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key?id__gt=2\")\n    assert response.status_code == 200\n    # The links at the top of the page\n    links = (\n        Soup(response.text, \"html.parser\")\n        .find(\"p\", {\"class\": \"export-links\"})\n        .find_all(\"a\")\n    )\n    actual = [link[\"href\"] for link in links]\n    expected = [\n        \"/fixtures/simple_primary_key.json?id__gt=2\",\n        \"/fixtures/simple_primary_key.testall?id__gt=2\",\n        \"/fixtures/simple_primary_key.testnone?id__gt=2\",\n        \"/fixtures/simple_primary_key.testresponse?id__gt=2\",\n        \"/fixtures/simple_primary_key.csv?id__gt=2&_size=max\",\n        \"#export\",\n    ]\n    assert expected == actual\n    # And the advanced export box at the bottom:\n    div = Soup(response.text, \"html.parser\").find(\"div\", {\"class\": \"advanced-export\"})\n    json_links = [a[\"href\"] for a in div.find(\"p\").find_all(\"a\")]\n    assert [\n        \"/fixtures/simple_primary_key.json?id__gt=2\",\n        \"/fixtures/simple_primary_key.json?id__gt=2&_shape=array\",\n        \"/fixtures/simple_primary_key.json?id__gt=2&_shape=array&_nl=on\",\n        \"/fixtures/simple_primary_key.json?id__gt=2&_shape=object\",\n    ] == json_links\n    # And the CSV form\n    form = div.find(\"form\")\n    assert form[\"action\"].endswith(\"/simple_primary_key.csv\")\n    inputs = [str(input) for input in form.find_all(\"input\")]\n    assert [\n        '<input name=\"_dl\" type=\"checkbox\"/>',\n        '<input type=\"submit\" value=\"Export CSV\"/>',\n        '<input name=\"id__gt\" type=\"hidden\" value=\"2\"/>',\n        '<input name=\"_size\" type=\"hidden\" value=\"max\"/>',\n    ] == inputs\n\n\n@pytest.mark.asyncio\nasync def test_csv_json_export_links_include_labels_if_foreign_keys(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable\")\n    assert response.status_code == 200\n    links = (\n        Soup(response.text, \"html.parser\")\n        .find(\"p\", {\"class\": \"export-links\"})\n        .find_all(\"a\")\n    )\n    actual = [link[\"href\"] for link in links]\n    expected = [\n        \"/fixtures/facetable.json?_labels=on\",\n        \"/fixtures/facetable.testall?_labels=on\",\n        \"/fixtures/facetable.testnone?_labels=on\",\n        \"/fixtures/facetable.testresponse?_labels=on\",\n        \"/fixtures/facetable.csv?_labels=on&_size=max\",\n        \"#export\",\n    ]\n    assert expected == actual\n\n\n@pytest.mark.asyncio\nasync def test_table_not_exists(ds_client):\n    assert \"Table not found\" in (await ds_client.get(\"/fixtures/blah\")).text\n\n\n@pytest.mark.asyncio\nasync def test_table_html_no_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/no_primary_key\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    # We have disabled sorting for this table using metadata.json\n    assert [\"content\", \"a\", \"b\", \"c\"] == [\n        th.string.strip() for th in table.select(\"thead th\")[2:]\n    ]\n    expected = [\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/no_primary_key/{}\">{}</a></td>'.format(\n                i, i\n            ),\n            f'<td class=\"col-rowid type-int\">{i}</td>',\n            f'<td class=\"col-content type-str\">{i}</td>',\n            f'<td class=\"col-a type-str\">a{i}</td>',\n            f'<td class=\"col-b type-str\">b{i}</td>',\n            f'<td class=\"col-c type-str\">c{i}</td>',\n        ]\n        for i in range(1, 51)\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_rowid_sortable_no_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/no_primary_key\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert table[\"class\"] == [\"rows-and-columns\"]\n    ths = table.find_all(\"th\")\n    assert \"rowid\\xa0▼\" == ths[1].find(\"a\").string.strip()\n\n\n@pytest.mark.asyncio\nasync def test_table_html_compound_primary_key(ds_client):\n    response = await ds_client.get(\"/fixtures/compound_primary_key\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    ths = table.find_all(\"th\")\n    assert \"Link\" == ths[0].string.strip()\n    for expected_col, th in zip((\"pk1\", \"pk2\", \"content\"), ths[1:]):\n        a = th.find(\"a\")\n        assert expected_col == a.string\n        assert th[\"class\"] == [f\"col-{expected_col}\"]\n        assert a[\"href\"].endswith(f\"/compound_primary_key?_sort={expected_col}\")\n    expected = [\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/compound_primary_key/a,b\">a,b</a></td>',\n            '<td class=\"col-pk1 type-str\">a</td>',\n            '<td class=\"col-pk2 type-str\">b</td>',\n            '<td class=\"col-content type-str\">c</td>',\n        ],\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/compound_primary_key/a~2Fb,~2Ec-d\">a/b,.c-d</a></td>',\n            '<td class=\"col-pk1 type-str\">a/b</td>',\n            '<td class=\"col-pk2 type-str\">.c-d</td>',\n            '<td class=\"col-content type-str\">c</td>',\n        ],\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/compound_primary_key/d,e\">d,e</a></td>',\n            '<td class=\"col-pk1 type-str\">d</td>',\n            '<td class=\"col-pk2 type-str\">e</td>',\n            '<td class=\"col-content type-str\">{\"row\": {\"pk1\": \"d\", \"pk2\": \"e\", \"content\": \"RENDER_CELL_DEMO\"}, \"column\": \"content\", \"table\": \"compound_primary_key\", \"database\": \"fixtures\", \"pks\": [\"pk1\", \"pk2\"], \"config\": {\"depth\": \"database\"}}</td>',\n        ],\n    ]\n    assert [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ] == expected\n\n\n@pytest.mark.asyncio\nasync def test_table_html_foreign_key_links(ds_client):\n    response = await ds_client.get(\"/fixtures/foreign_key_references\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    actual = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    assert actual == [\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/foreign_key_references/1\">1</a></td>',\n            '<td class=\"col-foreign_key_with_label type-int\"><a href=\"/fixtures/simple_primary_key/1\">hello</a>\\xa0<em>1</em></td>',\n            '<td class=\"col-foreign_key_with_blank_label type-int\"><a href=\"/fixtures/simple_primary_key/3\">-</a>\\xa0<em>3</em></td>',\n            '<td class=\"col-foreign_key_with_no_label type-str\"><a href=\"/fixtures/primary_key_multiple_columns/1\">1</a></td>',\n            '<td class=\"col-foreign_key_compound_pk1 type-str\">a</td>',\n            '<td class=\"col-foreign_key_compound_pk2 type-str\">b</td>',\n        ],\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/foreign_key_references/2\">2</a></td>',\n            '<td class=\"col-foreign_key_with_label type-none\">\\xa0</td>',\n            '<td class=\"col-foreign_key_with_blank_label type-none\">\\xa0</td>',\n            '<td class=\"col-foreign_key_with_no_label type-none\">\\xa0</td>',\n            '<td class=\"col-foreign_key_compound_pk1 type-none\">\\xa0</td>',\n            '<td class=\"col-foreign_key_compound_pk2 type-none\">\\xa0</td>',\n        ],\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_html_foreign_key_facets(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/foreign_key_references?_facet=foreign_key_with_blank_label\"\n    )\n    assert response.status_code == 200\n    assert (\n        '<li><a href=\"http://localhost/fixtures/foreign_key_references?_facet=foreign_key_with_blank_label&amp;foreign_key_with_blank_label=3\"'\n        ' data-facet-value=\"3\">-</a> 1</li>'\n    ) in response.text\n\n\n@pytest.mark.asyncio\nasync def test_table_html_disable_foreign_key_links_with_labels(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/foreign_key_references?_labels=off&_size=1\"\n    )\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    actual = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    assert actual == [\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/foreign_key_references/1\">1</a></td>',\n            '<td class=\"col-foreign_key_with_label type-int\">1</td>',\n            '<td class=\"col-foreign_key_with_blank_label type-int\">3</td>',\n            '<td class=\"col-foreign_key_with_no_label type-str\">1</td>',\n            '<td class=\"col-foreign_key_compound_pk1 type-str\">a</td>',\n            '<td class=\"col-foreign_key_compound_pk2 type-str\">b</td>',\n        ]\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_html_foreign_key_custom_label_column(ds_client):\n    response = await ds_client.get(\"/fixtures/custom_foreign_key_label\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    expected = [\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/custom_foreign_key_label/1\">1</a></td>',\n            '<td class=\"col-foreign_key_with_custom_label type-str\"><a href=\"/fixtures/primary_key_multiple_columns_explicit_label/1\">world2</a>\\xa0<em>1</em></td>',\n        ]\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_column_options\",\n    [\n        (\"/fixtures/infinity\", [\"- column -\", \"rowid\", \"value\"]),\n        (\n            \"/fixtures/primary_key_multiple_columns\",\n            [\"- column -\", \"id\", \"content\", \"content2\"],\n        ),\n        (\"/fixtures/compound_primary_key\", [\"- column -\", \"pk1\", \"pk2\", \"content\"]),\n    ],\n)\nasync def test_table_html_filter_form_column_options(\n    path, expected_column_options, ds_client\n):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    form = Soup(response.text, \"html.parser\").find(\"form\")\n    column_options = [\n        o.attrs.get(\"value\") or o.string\n        for o in form.select(\"select[name=_filter_column] option\")\n    ]\n    assert expected_column_options == column_options\n\n\n@pytest.mark.asyncio\nasync def test_table_html_filter_form_still_shows_nocol_columns(ds_client):\n    # https://github.com/simonw/datasette/issues/1503\n    response = await ds_client.get(\"/fixtures/sortable?_nocol=sortable\")\n    assert response.status_code == 200\n    form = Soup(response.text, \"html.parser\").find(\"form\")\n    assert [\n        o.string\n        for o in form.select(\"select[name='_filter_column']\")[0].select(\"option\")\n    ] == [\n        \"- column -\",\n        \"pk1\",\n        \"pk2\",\n        \"content\",\n        \"sortable_with_nulls\",\n        \"sortable_with_nulls_2\",\n        \"text\",\n        # Moved to the end because it is no longer returned by the query:\n        \"sortable\",\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_column_chooser_present(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # Web component should be present\n    chooser = soup.find(\"column-chooser\")\n    assert chooser is not None\n    # Script block should contain column data as JSON\n\n    scripts = soup.find_all(\"script\")\n    chooser_script = [s for s in scripts if \"_columnChooserData\" in (s.string or \"\")]\n    assert len(chooser_script) == 1\n    script_text = chooser_script[0].string\n    # Extract the JSON data\n    assert \"allColumns\" in script_text\n    assert \"selectedColumns\" in script_text\n    assert \"primaryKeys\" in script_text\n\n\n@pytest.mark.asyncio\nasync def test_mobile_column_actions_present(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    button = soup.select_one(\"button.column-actions-mobile.small-screen-only\")\n    assert button is not None\n    assert button.text.strip() == \"Column actions\"\n    assert button.find(\"svg\") is not None\n    assert any(\n        \"mobile-column-actions.js\" in (script.get(\"src\") or \"\")\n        for script in soup.find_all(\"script\")\n    )\n\n\n@pytest.mark.asyncio\nasync def test_column_chooser_data_reflects_col_filtering(ds_client):\n    response = await ds_client.get(\"/fixtures/facetable?_col=state&_col=created\")\n    assert response.status_code == 200\n    import json\n    import re\n\n    soup = Soup(response.text, \"html.parser\")\n    chooser = soup.find(\"column-chooser\")\n    assert chooser is not None\n    scripts = soup.find_all(\"script\")\n    chooser_script = [s for s in scripts if \"_columnChooserData\" in (s.string or \"\")]\n    script_text = chooser_script[0].string\n    # Parse the JSON object from the script\n    match = re.search(\n        r\"window\\._columnChooserData\\s*=\\s*({.*?});\", script_text, re.DOTALL\n    )\n    data = json.loads(match.group(1))\n    # All non-PK columns should still be listed in allColumns\n    assert \"state\" in data[\"allColumns\"]\n    assert \"created\" in data[\"allColumns\"]\n    assert \"planet_int\" in data[\"allColumns\"]\n    # Only state and created should be in selectedColumns (plus pk)\n    non_pk_selected = [\n        c for c in data[\"selectedColumns\"] if c not in data[\"primaryKeys\"]\n    ]\n    assert \"state\" in non_pk_selected\n    assert \"created\" in non_pk_selected\n    assert \"planet_int\" not in non_pk_selected\n\n\n@pytest.mark.asyncio\nasync def test_column_chooser_shown_for_views(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_view\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    chooser = soup.find(\"column-chooser\")\n    assert chooser is not None\n    scripts = soup.find_all(\"script\")\n    chooser_script = [s for s in scripts if \"_columnChooserData\" in (s.string or \"\")]\n    assert len(chooser_script) == 1\n\n\n@pytest.mark.asyncio\nasync def test_compound_primary_key_with_foreign_key_references(ds_client):\n    # e.g. a many-to-many table with a compound primary key on the two columns\n    response = await ds_client.get(\"/fixtures/searchable_tags\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    expected = [\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/searchable_tags/1,feline\">1,feline</a></td>',\n            '<td class=\"col-searchable_id type-int\"><a href=\"/fixtures/searchable/1\">1</a>\\xa0<em>1</em></td>',\n            '<td class=\"col-tag type-str\"><a href=\"/fixtures/tags/feline\">feline</a></td>',\n        ],\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/searchable_tags/2,canine\">2,canine</a></td>',\n            '<td class=\"col-searchable_id type-int\"><a href=\"/fixtures/searchable/2\">2</a>\\xa0<em>2</em></td>',\n            '<td class=\"col-tag type-str\"><a href=\"/fixtures/tags/canine\">canine</a></td>',\n        ],\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_view_html(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_view?_size=3\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    ths = table.select(\"thead th\")\n    assert 2 == len(ths)\n    assert ths[0].find(\"a\") is not None\n    assert ths[0].find(\"a\")[\"href\"].endswith(\"/simple_view?_size=3&_sort=content\")\n    assert ths[0].find(\"a\").string.strip() == \"content\"\n    assert ths[1].find(\"a\") is None\n    assert ths[1].string.strip() == \"upper_content\"\n    expected = [\n        [\n            '<td class=\"col-content type-str\">hello</td>',\n            '<td class=\"col-upper_content type-str\">HELLO</td>',\n        ],\n        [\n            '<td class=\"col-content type-str\">world</td>',\n            '<td class=\"col-upper_content type-str\">WORLD</td>',\n        ],\n        [\n            '<td class=\"col-content type-str\">\\xa0</td>',\n            '<td class=\"col-upper_content type-str\">\\xa0</td>',\n        ],\n    ]\n    assert expected == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_table_metadata(ds_client):\n    response = await ds_client.get(\"/fixtures/simple_primary_key\")\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # Page title should be custom and should be HTML escaped\n    assert \"This &lt;em&gt;HTML&lt;/em&gt; is escaped\" == inner_html(soup.find(\"h1\"))\n    # Description should be custom and NOT escaped (we used description_html)\n    assert \"Simple <em>primary</em> key\" == inner_html(\n        soup.find(\"div\", {\"class\": \"metadata-description\"})\n    )\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,has_object,has_stream,has_expand\",\n    [\n        (\"/fixtures/no_primary_key\", False, True, False),\n        (\"/fixtures/complex_foreign_keys\", True, False, True),\n    ],\n)\nasync def test_advanced_export_box(ds_client, path, has_object, has_stream, has_expand):\n    response = await ds_client.get(path)\n    assert response.status_code == 200\n    soup = Soup(response.text, \"html.parser\")\n    # JSON shape options\n    expected_json_shapes = [\"default\", \"array\", \"newline-delimited\"]\n    if has_object:\n        expected_json_shapes.append(\"object\")\n    div = soup.find(\"div\", {\"class\": \"advanced-export\"})\n    assert expected_json_shapes == [a.text for a in div.find(\"p\").find_all(\"a\")]\n    # \"stream all rows\" option\n    if has_stream:\n        assert \"stream all rows\" in str(div)\n    # \"expand labels\" option\n    if has_expand:\n        assert \"expand labels\" in str(div)\n\n\n@pytest.mark.asyncio\nasync def test_extra_where_clauses(ds_client):\n    response = await ds_client.get(\n        \"/fixtures/facetable?_where=_neighborhood='Dogpatch'&_where=_city_id=1\"\n    )\n    soup = Soup(response.text, \"html.parser\")\n    div = soup.select(\".extra-wheres\")[0]\n    assert \"2 extra where clauses\" == div.find(\"h3\").text\n    hrefs = [a[\"href\"] for a in div.find_all(\"a\")]\n    assert [\n        \"/fixtures/facetable?_where=_city_id%3D1\",\n        \"/fixtures/facetable?_where=_neighborhood%3D%27Dogpatch%27\",\n    ] == hrefs\n    # These should also be persisted as hidden fields\n    inputs = soup.find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(\"_where\", \"_neighborhood='Dogpatch'\"), (\"_where\", \"_city_id=1\")] == [\n        (hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens\n    ]\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_hidden\",\n    [\n        (\"/fixtures/facetable?_size=10\", [(\"_size\", \"10\")]),\n        (\n            \"/fixtures/facetable?_size=10&_ignore=1&_ignore=2\",\n            [\n                (\"_size\", \"10\"),\n                (\"_ignore\", \"1\"),\n                (\"_ignore\", \"2\"),\n            ],\n        ),\n    ],\n)\nasync def test_other_hidden_form_fields(ds_client, path, expected_hidden):\n    response = await ds_client.get(path)\n    soup = Soup(response.text, \"html.parser\")\n    inputs = soup.find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens] == expected_hidden\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected_hidden\",\n    [\n        (\"/fixtures/searchable?_search=terry\", []),\n        (\"/fixtures/searchable?_sort=text2\", []),\n        (\"/fixtures/searchable?_sort_desc=text2\", []),\n        (\"/fixtures/searchable?_sort=text2&_where=1\", [(\"_where\", \"1\")]),\n    ],\n)\nasync def test_search_and_sort_fields_not_duplicated(ds_client, path, expected_hidden):\n    # https://github.com/simonw/datasette/issues/1214\n    response = await ds_client.get(path)\n    soup = Soup(response.text, \"html.parser\")\n    inputs = soup.find(\"form\").find_all(\"input\")\n    hiddens = [i for i in inputs if i[\"type\"] == \"hidden\"]\n    assert [(hidden[\"name\"], hidden[\"value\"]) for hidden in hiddens] == expected_hidden\n\n\n@pytest.mark.asyncio\nasync def test_binary_data_display_in_table(ds_client):\n    response = await ds_client.get(\"/fixtures/binary_data\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    expected_tds = [\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/binary_data/1\">1</a></td>',\n            '<td class=\"col-rowid type-int\">1</td>',\n            '<td class=\"col-data type-bytes\"><a class=\"blob-download\" href=\"/fixtures/binary_data/1.blob?_blob_column=data\">&lt;Binary:\\xa07\\xa0bytes&gt;</a></td>',\n        ],\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/binary_data/2\">2</a></td>',\n            '<td class=\"col-rowid type-int\">2</td>',\n            '<td class=\"col-data type-bytes\"><a class=\"blob-download\" href=\"/fixtures/binary_data/2.blob?_blob_column=data\">&lt;Binary:\\xa07\\xa0bytes&gt;</a></td>',\n        ],\n        [\n            '<td class=\"col-Link type-pk\"><a href=\"/fixtures/binary_data/3\">3</a></td>',\n            '<td class=\"col-rowid type-int\">3</td>',\n            '<td class=\"col-data type-none\">\\xa0</td>',\n        ],\n    ]\n    assert expected_tds == [\n        [str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")\n    ]\n\n\ndef test_custom_table_include():\n    with make_app_client(\n        template_dir=str(pathlib.Path(__file__).parent / \"test_templates\")\n    ) as client:\n        response = client.get(\"/fixtures/complex_foreign_keys\")\n        assert response.status == 200\n        assert (\n            '<div class=\"custom-table-row\">'\n            '1 - 2 - <a href=\"/fixtures/simple_primary_key/1\">hello</a> <em>1</em>'\n            \"</div>\"\n        ) == str(Soup(response.text, \"html.parser\").select_one(\"div.custom-table-row\"))\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"json\", (True, False))\n@pytest.mark.parametrize(\n    \"params,error\",\n    (\n        (\"?_sort=bad\", \"Cannot sort table by bad\"),\n        (\"?_sort_desc=bad\", \"Cannot sort table by bad\"),\n        (\n            \"?_sort=state&_sort_desc=state\",\n            \"Cannot use _sort and _sort_desc at the same time\",\n        ),\n    ),\n)\nasync def test_sort_errors(ds_client, json, params, error):\n    path = \"/fixtures/facetable{}{}\".format(\n        \".json\" if json else \"\",\n        params,\n    )\n    response = await ds_client.get(path)\n    assert response.status_code == 400\n    if json:\n        assert response.json() == {\n            \"ok\": False,\n            \"error\": error,\n            \"status\": 400,\n            \"title\": None,\n        }\n    else:\n        assert error in response.text\n\n\n@pytest.mark.asyncio\nasync def test_metadata_sort(ds_client):\n    response = await ds_client.get(\"/fixtures/facet_cities\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert table[\"class\"] == [\"rows-and-columns\"]\n    ths = table.find_all(\"th\")\n    assert [\"id\", \"name\\xa0▼\"] == [th.find(\"a\").string.strip() for th in ths]\n    rows = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    expected = [\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/facet_cities/3\">3</a></td>',\n            '<td class=\"col-name type-str\">Detroit</td>',\n        ],\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/facet_cities/2\">2</a></td>',\n            '<td class=\"col-name type-str\">Los Angeles</td>',\n        ],\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/facet_cities/4\">4</a></td>',\n            '<td class=\"col-name type-str\">Memnonia</td>',\n        ],\n        [\n            '<td class=\"col-id type-pk\"><a href=\"/fixtures/facet_cities/1\">1</a></td>',\n            '<td class=\"col-name type-str\">San Francisco</td>',\n        ],\n    ]\n    assert expected == rows\n    # Make sure you can reverse that sort order\n    response = await ds_client.get(\"/fixtures/facet_cities?_sort_desc=name\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    rows = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    assert list(reversed(expected)) == rows\n\n\n@pytest.mark.asyncio\nasync def test_metadata_sort_desc(ds_client):\n    response = await ds_client.get(\"/fixtures/attraction_characteristic\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    assert table[\"class\"] == [\"rows-and-columns\"]\n    ths = table.find_all(\"th\")\n    assert [\"pk\\xa0▲\", \"name\"] == [th.find(\"a\").string.strip() for th in ths]\n    rows = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    expected = [\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/attraction_characteristic/2\">2</a></td>',\n            '<td class=\"col-name type-str\">Paranormal</td>',\n        ],\n        [\n            '<td class=\"col-pk type-pk\"><a href=\"/fixtures/attraction_characteristic/1\">1</a></td>',\n            '<td class=\"col-name type-str\">Museum</td>',\n        ],\n    ]\n    assert expected == rows\n    # Make sure you can reverse that sort order\n    response = await ds_client.get(\"/fixtures/attraction_characteristic?_sort=pk\")\n    assert response.status_code == 200\n    table = Soup(response.text, \"html.parser\").find(\"table\")\n    rows = [[str(td) for td in tr.select(\"td\")] for tr in table.select(\"tbody tr\")]\n    assert list(reversed(expected)) == rows\n\n\n@pytest.mark.parametrize(\n    \"max_returned_rows,path,expected_num_facets,expected_ellipses,expected_ellipses_url\",\n    (\n        (\n            5,\n            # Default should show 2 facets\n            \"/fixtures/facetable?_facet=_neighborhood\",\n            2,\n            True,\n            \"/fixtures/facetable?_facet=_neighborhood&_facet_size=max\",\n        ),\n        # _facet_size above max_returned_rows should show max_returned_rows (5)\n        (\n            5,\n            \"/fixtures/facetable?_facet=_neighborhood&_facet_size=50\",\n            5,\n            True,\n            \"/fixtures/facetable?_facet=_neighborhood&_facet_size=max\",\n        ),\n        # If max_returned_rows is high enough, should return all\n        (\n            20,\n            \"/fixtures/facetable?_facet=_neighborhood&_facet_size=max\",\n            14,\n            False,\n            None,\n        ),\n        # If num facets > max_returned_rows, show ... without a link\n        # _facet_size above max_returned_rows should show max_returned_rows (5)\n        (\n            5,\n            \"/fixtures/facetable?_facet=_neighborhood&_facet_size=max\",\n            5,\n            True,\n            None,\n        ),\n    ),\n)\ndef test_facet_more_links(\n    max_returned_rows,\n    path,\n    expected_num_facets,\n    expected_ellipses,\n    expected_ellipses_url,\n):\n    with make_app_client(\n        settings={\"max_returned_rows\": max_returned_rows, \"default_facet_size\": 2}\n    ) as client:\n        response = client.get(path)\n        soup = Soup(response.body, \"html.parser\")\n        lis = soup.select(\"#facet-neighborhood-b352a7 ul li:not(.facet-truncated)\")\n        facet_truncated = soup.select_one(\".facet-truncated\")\n        assert len(lis) == expected_num_facets\n        if not expected_ellipses:\n            assert facet_truncated is None\n        else:\n            if expected_ellipses_url:\n                assert facet_truncated.find(\"a\")[\"href\"] == expected_ellipses_url\n            else:\n                assert facet_truncated.find(\"a\") is None\n\n\ndef test_unavailable_table_does_not_break_sort_relationships():\n    # https://github.com/simonw/datasette/issues/1305\n    with make_app_client(\n        config={\n            \"databases\": {\n                \"fixtures\": {\"tables\": {\"foreign_key_references\": {\"allow\": False}}}\n            }\n        }\n    ) as client:\n        response = client.get(\"/?_sort=relationships\")\n        assert response.status == 200\n\n\n@pytest.mark.asyncio\nasync def test_column_metadata(ds_client):\n    response = await ds_client.get(\"/fixtures/roadside_attractions\")\n    soup = Soup(response.text, \"html.parser\")\n    dl = soup.find(\"dl\")\n    assert [(dt.text, dt.next_sibling.text) for dt in dl.find_all(\"dt\")] == [\n        (\"address\", \"The street address for the attraction\"),\n        (\"name\", \"The name of the attraction\"),\n    ]\n    assert (\n        soup.select(\"th[data-column=name]\")[0][\"data-column-description\"]\n        == \"The name of the attraction\"\n    )\n    assert (\n        soup.select(\"th[data-column=address]\")[0][\"data-column-description\"]\n        == \"The street address for the attraction\"\n    )\n\n\ndef test_facet_total():\n    # https://github.com/simonw/datasette/issues/1423\n    # https://github.com/simonw/datasette/issues/1556\n    with make_app_client(settings={\"max_returned_rows\": 100}) as client:\n        path = \"/fixtures/sortable?_facet=content&_facet=pk1\"\n        response = client.get(path)\n        assert response.status == 200\n    fragments = (\n        '<span class=\"facet-info-total\">&gt;30</span>',\n        '<span class=\"facet-info-total\">8</span>',\n    )\n    for fragment in fragments:\n        assert fragment in response.text\n\n\n@pytest.mark.asyncio\nasync def test_sort_rowid_with_next(ds_client):\n    # https://github.com/simonw/datasette/issues/1470\n    response = await ds_client.get(\"/fixtures/binary_data?_size=1&_next=1&_sort=rowid\")\n    assert response.status_code == 200\n\n\ndef assert_querystring_equal(expected, actual):\n    assert sorted(expected.split(\"&\")) == sorted(actual.split(\"&\"))\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"path,expected\",\n    (\n        (\n            \"/fixtures/facetable\",\n            \"fixtures: facetable: 15 rows\",\n        ),\n        (\n            \"/fixtures/facetable?on_earth__exact=1\",\n            \"fixtures: facetable: 14 rows where on_earth = 1\",\n        ),\n    ),\n)\nasync def test_table_page_title(ds_client, path, expected):\n    response = await ds_client.get(path)\n    title = Soup(response.text, \"html.parser\").find(\"title\").text\n    assert title == expected\n\n\n@pytest.mark.asyncio\nasync def test_table_post_method_not_allowed(ds_client):\n    response = await ds_client.post(\"/fixtures/facetable\")\n    assert response.status_code == 405\n    assert \"Method not allowed\" in response.text\n\n\n@pytest.mark.parametrize(\"allow_facet\", (True, False))\ndef test_allow_facet_off(allow_facet):\n    with make_app_client(settings={\"allow_facet\": allow_facet}) as client:\n        response = client.get(\"/fixtures/facetable\")\n        expected = \"DATASETTE_ALLOW_FACET = {};\".format(\n            \"true\" if allow_facet else \"false\"\n        )\n        assert expected in response.text\n        if allow_facet:\n            assert \"Suggested facets\" in response.text\n        else:\n            assert \"Suggested facets\" not in response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"size,title,length_bytes\",\n    (\n        (2000, ' title=\"2.0 KB\"', \"2,000\"),\n        (20000, ' title=\"19.5 KB\"', \"20,000\"),\n        (20, \"\", \"20\"),\n    ),\n)\nasync def test_format_of_binary_links(size, title, length_bytes):\n    ds = Datasette()\n    db_name = \"binary-links-{}\".format(size)\n    db = ds.add_memory_database(db_name)\n    sql = \"select zeroblob({}) as blob\".format(size)\n    await db.execute_write(\"create table blobs as {}\".format(sql))\n    response = await ds.client.get(\"/{}/blobs\".format(db_name))\n    assert response.status_code == 200\n    expected = \"{}>&lt;Binary:&nbsp;{}&nbsp;bytes&gt;</a>\".format(title, length_bytes)\n    assert expected in response.text\n    # And test with arbitrary SQL query too\n    sql_response = await ds.client.get(\n        \"{}/-/query\".format(db_name), params={\"sql\": sql}\n    )\n    assert sql_response.status_code == 200\n    assert expected in sql_response.text\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"config\",\n    (\n        # Blocked at table level\n        {\n            \"databases\": {\n                \"foreign_key_labels\": {\n                    \"tables\": {\n                        # Table a is only visible to root\n                        \"a\": {\"allow\": {\"id\": \"root\"}},\n                    }\n                }\n            }\n        },\n        # Blocked at database level\n        {\n            \"databases\": {\n                \"foreign_key_labels\": {\n                    # Only root can view this database\n                    \"allow\": {\"id\": \"root\"},\n                    \"tables\": {\n                        # But table b is visible to everyone\n                        \"b\": {\"allow\": True},\n                    },\n                }\n            }\n        },\n        # Blocked at the instance level\n        {\n            \"allow\": {\"id\": \"root\"},\n            \"databases\": {\n                \"foreign_key_labels\": {\n                    \"tables\": {\n                        # Table b is visible to everyone\n                        \"b\": {\"allow\": True},\n                    }\n                }\n            },\n        },\n    ),\n)\nasync def test_foreign_key_labels_obey_permissions(config):\n    ds = Datasette(config=config)\n    db = ds.add_memory_database(\"foreign_key_labels\")\n    await db.execute_write(\n        \"create table if not exists a(id integer primary key, name text)\"\n    )\n    await db.execute_write(\"insert or replace into a (id, name) values (1, 'hello')\")\n    await db.execute_write(\n        \"create table if not exists b(id integer primary key, name text, a_id integer references a(id))\"\n    )\n    await db.execute_write(\n        \"insert or replace into b (id, name, a_id) values (1, 'world', 1)\"\n    )\n    # Anonymous user can see table b but not table a\n    await ds.client.get(\"/foreign_key_labels.json\")\n    anon_a = await ds.client.get(\"/foreign_key_labels/a.json?_labels=on\")\n    assert anon_a.status_code == 403\n    anon_b = await ds.client.get(\"/foreign_key_labels/b.json?_labels=on\")\n    assert anon_b.status_code == 200\n    # root user can see both\n    cookies = {\"ds_actor\": ds.sign({\"a\": {\"id\": \"root\"}}, \"actor\")}\n    root_a = await ds.client.get(\n        \"/foreign_key_labels/a.json?_labels=on\", cookies=cookies\n    )\n    assert root_a.status_code == 200\n    root_b = await ds.client.get(\n        \"/foreign_key_labels/b.json?_labels=on\", cookies=cookies\n    )\n    assert root_b.status_code == 200\n    # Labels should have been expanded for root\n    assert root_b.json() == {\n        \"ok\": True,\n        \"next\": None,\n        \"rows\": [{\"id\": 1, \"name\": \"world\", \"a_id\": {\"value\": 1, \"label\": \"hello\"}}],\n        \"truncated\": False,\n    }\n    # But not for anon\n    assert anon_b.json() == {\n        \"ok\": True,\n        \"next\": None,\n        \"rows\": [{\"id\": 1, \"name\": \"world\", \"a_id\": 1}],\n        \"truncated\": False,\n    }\n\n\ndef test_foreign_keys_special_character_in_database_name(app_client_with_dot):\n    # https://github.com/simonw/datasette/pull/2476\n    response = app_client_with_dot.get(\"/fixtures~2Edot/complex_foreign_keys\")\n    assert '<a href=\"/fixtures~2Edot/simple_primary_key/2\">world</a>' in response.text\n"
  },
  {
    "path": "tests/test_templates/_table.html",
    "content": "{% for row in display_rows %}\n    <div class=\"custom-table-row\">{{ row[\"f1\"] }} - {{ row[\"f2\"] }} - {{ row.display(\"f3\") }}</div>\n{% endfor %}\n"
  },
  {
    "path": "tests/test_templates/pages/202.html",
    "content": "{{ custom_status(202) }}202!"
  },
  {
    "path": "tests/test_templates/pages/about.html",
    "content": "ABOUT! view_name:{{ view_name }}"
  },
  {
    "path": "tests/test_templates/pages/atom.html",
    "content": "{{ custom_header(\"content-type\", \"application/xml\") }}<?xml ...>"
  },
  {
    "path": "tests/test_templates/pages/headers.html",
    "content": "{{ custom_header(\"x-this-is-foo\", \"foo\") }}FOO{{ custom_header(\"x-this-is-bar\", \"bar\") }}BAR"
  },
  {
    "path": "tests/test_templates/pages/nested/nest.html",
    "content": "Nest!"
  },
  {
    "path": "tests/test_templates/pages/redirect.html",
    "content": "{{ custom_redirect(\"/example\") }}"
  },
  {
    "path": "tests/test_templates/pages/redirect2.html",
    "content": "{{ custom_redirect(\"/example\", 301) }}"
  },
  {
    "path": "tests/test_templates/pages/request.html",
    "content": "path:{{ request.path }}"
  },
  {
    "path": "tests/test_templates/pages/route_{name}.html",
    "content": "{% if name == \"OhNo\" %}{{ raise_404(\"Oh no\") }}{% endif %}\n<p>Hello from {{ name }}</p>"
  },
  {
    "path": "tests/test_templates/pages/topic_{topic}/{slug}.html",
    "content": "Slug: {{ slug }}, Topic: {{ topic }}"
  },
  {
    "path": "tests/test_templates/pages/topic_{topic}.html",
    "content": "Topic page for {{ topic }}"
  },
  {
    "path": "tests/test_templates/show_json.html",
    "content": "{% extends \"base.html\" %}\n\n{% block content %}\n{{ super() }}\nTest data for extra_template_vars:\n<pre class=\"extra_template_vars\">{{ extra_template_vars|safe }}</pre>\n<pre class=\"extra_template_vars_from_awaitable\">{{ extra_template_vars_from_awaitable|safe }}</pre>\n<pre class=\"extra_from_awaitable_function\">{{ query_database(\"select sqlite_version();\") }}</pre>\n{% endblock %}\n"
  },
  {
    "path": "tests/test_token_handler.py",
    "content": "\"\"\"\nTests for the register_token_handler plugin hook.\n\"\"\"\n\nfrom datasette.app import Datasette\nfrom datasette.hookspecs import hookimpl\nfrom datasette.plugins import pm\nfrom datasette.tokens import TokenHandler, TokenRestrictions, SignedTokenHandler\nimport pytest\n\n\n@pytest.fixture\ndef datasette():\n    return Datasette()\n\n\n@pytest.mark.asyncio\nasync def test_default_signed_handler_registered(datasette):\n    \"\"\"The default SignedTokenHandler should be registered automatically.\"\"\"\n    handlers = datasette._token_handlers()\n    assert len(handlers) >= 1\n    assert any(isinstance(h, SignedTokenHandler) for h in handlers)\n    assert any(h.name == \"signed\" for h in handlers)\n\n\n@pytest.mark.asyncio\nasync def test_create_token_default(datasette):\n    \"\"\"create_token() with handler='signed' should create a signed token.\"\"\"\n    token = await datasette.create_token(\"test_actor\", handler=\"signed\")\n    assert token.startswith(\"dstok_\")\n\n\n@pytest.mark.asyncio\nasync def test_create_token_with_restrictions(datasette):\n    \"\"\"create_token() should handle restriction parameters.\"\"\"\n    token = await datasette.create_token(\n        \"test_actor\",\n        handler=\"signed\",\n        expires_after=3600,\n        restrictions=TokenRestrictions().allow_all(\"view-instance\"),\n    )\n    assert token.startswith(\"dstok_\")\n    # Verify the token contains the expected data\n    decoded = datasette.unsign(token[len(\"dstok_\") :], namespace=\"token\")\n    assert decoded[\"a\"] == \"test_actor\"\n    assert decoded[\"d\"] == 3600\n    assert \"_r\" in decoded\n    assert \"a\" in decoded[\"_r\"]\n\n\n@pytest.mark.asyncio\nasync def test_verify_token_default(datasette):\n    \"\"\"verify_token() should verify signed tokens.\"\"\"\n    token = await datasette.create_token(\"test_actor\", handler=\"signed\")\n    actor = await datasette.verify_token(token)\n    assert actor is not None\n    assert actor[\"id\"] == \"test_actor\"\n    assert actor[\"token\"] == \"dstok\"\n\n\n@pytest.mark.asyncio\nasync def test_verify_token_unknown_returns_none(datasette):\n    \"\"\"verify_token() should return None for unrecognized tokens.\"\"\"\n    result = await datasette.verify_token(\"unknown_token_format_xyz\")\n    assert result is None\n\n\n@pytest.mark.asyncio\nasync def test_verify_token_bad_signature_returns_none(datasette):\n    \"\"\"verify_token() should return None for tokens with bad signatures.\"\"\"\n    result = await datasette.verify_token(\"dstok_tampered_data_here\")\n    assert result is None\n\n\n@pytest.mark.asyncio\nasync def test_create_token_with_named_handler(datasette):\n    \"\"\"create_token(handler='signed') should select the signed handler.\"\"\"\n    token = await datasette.create_token(\"test_actor\", handler=\"signed\")\n    assert token.startswith(\"dstok_\")\n\n\n@pytest.mark.asyncio\nasync def test_create_token_unknown_handler_raises(datasette):\n    \"\"\"create_token(handler='nonexistent') should raise ValueError.\"\"\"\n    with pytest.raises(ValueError, match=\"Token handler 'nonexistent' not found\"):\n        await datasette.create_token(\"test_actor\", handler=\"nonexistent\")\n\n\n@pytest.mark.asyncio\nasync def test_custom_token_handler(datasette):\n    \"\"\"A custom token handler should be usable for both create and verify.\"\"\"\n\n    class CustomHandler(TokenHandler):\n        name = \"custom\"\n\n        async def create_token(self, datasette, actor_id, **kwargs):\n            return f\"custom_{actor_id}\"\n\n        async def verify_token(self, datasette, token):\n            if token.startswith(\"custom_\"):\n                return {\"id\": token[len(\"custom_\") :], \"token\": \"custom\"}\n            return None\n\n    class Plugin:\n        __name__ = \"CustomTokenPlugin\"\n\n        @staticmethod\n        @hookimpl\n        def register_token_handler(datasette):\n            return CustomHandler()\n\n    pm.register(Plugin(), name=\"test_custom_handler\")\n    try:\n        handlers = datasette._token_handlers()\n        assert any(h.name == \"custom\" for h in handlers)\n\n        # Create with custom handler\n        token = await datasette.create_token(\"alice\", handler=\"custom\")\n        assert token == \"custom_alice\"\n\n        # Verify custom token\n        actor = await datasette.verify_token(\"custom_alice\")\n        assert actor is not None\n        assert actor[\"id\"] == \"alice\"\n        assert actor[\"token\"] == \"custom\"\n\n        # Signed tokens should still work\n        signed_token = await datasette.create_token(\"bob\", handler=\"signed\")\n        assert signed_token.startswith(\"dstok_\")\n        actor = await datasette.verify_token(signed_token)\n        assert actor[\"id\"] == \"bob\"\n    finally:\n        pm.unregister(name=\"test_custom_handler\")\n\n\n@pytest.mark.asyncio\nasync def test_verify_token_tries_all_handlers(datasette):\n    \"\"\"verify_token() should try each handler until one matches.\"\"\"\n\n    class HandlerA(TokenHandler):\n        name = \"handler_a\"\n\n        async def create_token(self, datasette, actor_id, **kwargs):\n            return f\"a_{actor_id}\"\n\n        async def verify_token(self, datasette, token):\n            if token.startswith(\"a_\"):\n                return {\"id\": token[2:], \"token\": \"handler_a\"}\n            return None\n\n    class HandlerB(TokenHandler):\n        name = \"handler_b\"\n\n        async def create_token(self, datasette, actor_id, **kwargs):\n            return f\"b_{actor_id}\"\n\n        async def verify_token(self, datasette, token):\n            if token.startswith(\"b_\"):\n                return {\"id\": token[2:], \"token\": \"handler_b\"}\n            return None\n\n    class PluginA:\n        __name__ = \"PluginA\"\n\n        @staticmethod\n        @hookimpl\n        def register_token_handler(datasette):\n            return HandlerA()\n\n    class PluginB:\n        __name__ = \"PluginB\"\n\n        @staticmethod\n        @hookimpl\n        def register_token_handler(datasette):\n            return HandlerB()\n\n    pm.register(PluginA(), name=\"test_handler_a\")\n    pm.register(PluginB(), name=\"test_handler_b\")\n    try:\n        # Both handler tokens should verify\n        actor_a = await datasette.verify_token(\"a_alice\")\n        assert actor_a is not None\n        assert actor_a[\"id\"] == \"alice\"\n        assert actor_a[\"token\"] == \"handler_a\"\n\n        actor_b = await datasette.verify_token(\"b_bob\")\n        assert actor_b is not None\n        assert actor_b[\"id\"] == \"bob\"\n        assert actor_b[\"token\"] == \"handler_b\"\n\n        # Unknown token should return None\n        assert await datasette.verify_token(\"c_charlie\") is None\n    finally:\n        pm.unregister(name=\"test_handler_a\")\n        pm.unregister(name=\"test_handler_b\")\n\n\n@pytest.mark.asyncio\nasync def test_token_handler_via_http(datasette):\n    \"\"\"Default signed tokens should work through HTTP auth.\"\"\"\n    token = await datasette.create_token(\"http_user\", handler=\"signed\")\n    response = await datasette.client.get(\n        \"/-/actor.json\",\n        headers={\"Authorization\": f\"Bearer {token}\"},\n    )\n    assert response.status_code == 200\n    actor = response.json()[\"actor\"]\n    assert actor[\"id\"] == \"http_user\"\n    assert actor[\"token\"] == \"dstok\"\n\n\n@pytest.mark.asyncio\nasync def test_custom_handler_via_http(datasette):\n    \"\"\"Custom handler tokens should work through HTTP auth.\"\"\"\n\n    class CustomHandler(TokenHandler):\n        name = \"custom_http\"\n\n        async def create_token(self, datasette, actor_id, **kwargs):\n            return f\"chttp_{actor_id}\"\n\n        async def verify_token(self, datasette, token):\n            if token.startswith(\"chttp_\"):\n                return {\"id\": token[len(\"chttp_\") :], \"token\": \"custom_http\"}\n            return None\n\n    class Plugin:\n        __name__ = \"CustomHTTPPlugin\"\n\n        @staticmethod\n        @hookimpl\n        def register_token_handler(datasette):\n            return CustomHandler()\n\n    pm.register(Plugin(), name=\"test_custom_http\")\n    try:\n        token = await datasette.create_token(\"web_user\", handler=\"custom_http\")\n        response = await datasette.client.get(\n            \"/-/actor.json\",\n            headers={\"Authorization\": f\"Bearer {token}\"},\n        )\n        assert response.status_code == 200\n        actor = response.json()[\"actor\"]\n        assert actor[\"id\"] == \"web_user\"\n        assert actor[\"token\"] == \"custom_http\"\n    finally:\n        pm.unregister(name=\"test_custom_http\")\n\n\n@pytest.mark.asyncio\nasync def test_token_handler_base_class_raises():\n    \"\"\"TokenHandler base class methods should raise NotImplementedError.\"\"\"\n    handler = TokenHandler()\n    ds = Datasette()\n    with pytest.raises(NotImplementedError):\n        await handler.create_token(ds, \"test\")\n    with pytest.raises(NotImplementedError):\n        await handler.verify_token(ds, \"test\")\n\n\n@pytest.mark.asyncio\nasync def test_restrictions_round_trip(datasette):\n    \"\"\"Tokens with database/resource restrictions should round-trip correctly.\"\"\"\n    restrictions = (\n        TokenRestrictions()\n        .allow_all(\"view-instance\")\n        .allow_database(\"docs\", \"view-query\")\n        .allow_resource(\"docs\", \"attachments\", \"insert-row\")\n    )\n    token = await datasette.create_token(\n        \"test_actor\", handler=\"signed\", restrictions=restrictions\n    )\n    actor = await datasette.verify_token(token)\n    assert actor is not None\n    assert actor[\"id\"] == \"test_actor\"\n    assert actor[\"_r\"][\"a\"] == [\"view-instance\"]\n    assert actor[\"_r\"][\"d\"] == {\"docs\": [\"view-query\"]}\n    assert actor[\"_r\"][\"r\"] == {\"docs\": {\"attachments\": [\"insert-row\"]}}\n\n\n@pytest.mark.asyncio\nasync def test_expires_after_round_trip(datasette):\n    \"\"\"Tokens with expires_after should include token_expires in the actor.\"\"\"\n    token = await datasette.create_token(\n        \"test_actor\", handler=\"signed\", expires_after=3600\n    )\n    actor = await datasette.verify_token(token)\n    assert actor is not None\n    assert actor[\"id\"] == \"test_actor\"\n    assert \"token_expires\" in actor\n\n\n@pytest.mark.asyncio\nasync def test_signed_tokens_disabled():\n    \"\"\"create_token and verify_token should fail/skip when signed tokens are disabled.\"\"\"\n    ds = Datasette(settings={\"allow_signed_tokens\": False})\n    with pytest.raises(ValueError, match=\"Signed tokens are not enabled\"):\n        await ds.create_token(\"test_actor\", handler=\"signed\")\n    # verify_token should return None rather than raising\n    assert await ds.verify_token(\"dstok_anything\") is None\n"
  },
  {
    "path": "tests/test_tracer.py",
    "content": "import pytest\nfrom .fixtures import make_app_client\n\n\n@pytest.mark.parametrize(\"trace_debug\", (True, False))\ndef test_trace(trace_debug):\n    with make_app_client(settings={\"trace_debug\": trace_debug}) as client:\n        response = client.get(\"/fixtures/simple_primary_key.json?_trace=1\")\n        assert response.status == 200\n\n    data = response.json\n    if not trace_debug:\n        assert \"_trace\" not in data\n        return\n\n    assert \"_trace\" in data\n    trace_info = data[\"_trace\"]\n    assert isinstance(trace_info[\"request_duration_ms\"], float)\n    assert isinstance(trace_info[\"sum_trace_duration_ms\"], float)\n    assert isinstance(trace_info[\"num_traces\"], int)\n    assert isinstance(trace_info[\"traces\"], list)\n    traces = trace_info[\"traces\"]\n    assert len(traces) == trace_info[\"num_traces\"]\n    for trace in traces:\n        assert isinstance(trace[\"type\"], str)\n        assert isinstance(trace[\"start\"], float)\n        assert isinstance(trace[\"end\"], float)\n        assert trace[\"duration_ms\"] == (trace[\"end\"] - trace[\"start\"]) * 1000\n        assert isinstance(trace[\"traceback\"], list)\n        assert isinstance(trace[\"database\"], str)\n        assert isinstance(trace[\"sql\"], str)\n        assert isinstance(trace.get(\"params\"), (list, dict, None.__class__))\n\n    sqls = [trace[\"sql\"] for trace in traces if \"sql\" in trace]\n    # There should be SQL statements from request handling in the trace.\n    # Note: CREATE TABLE, INSERT OR REPLACE, executescript, and executemany\n    # are not expected here because internal tables are now created and\n    # populated during invoke_startup(), before the request is traced.\n    assert any(sql.startswith(\"select \") for sql in sqls), \"No select statements traced\"\n\n\ndef test_trace_silently_fails_for_large_page():\n    # Max HTML size is 256KB\n    with make_app_client(settings={\"trace_debug\": True}) as client:\n        # Small response should have trace\n        small_response = client.get(\"/fixtures/simple_primary_key.json?_trace=1\")\n        assert small_response.status == 200\n        assert \"_trace\" in small_response.json\n\n        # Big response should not\n        big_response = client.get(\n            \"/fixtures/-/query.json\",\n            params={\"_trace\": 1, \"sql\": \"select zeroblob(1024 * 256)\"},\n        )\n        assert big_response.status == 200\n        assert \"_trace\" not in big_response.json\n\n\ndef test_trace_query_errors():\n    with make_app_client(settings={\"trace_debug\": True}) as client:\n        response = client.get(\n            \"/fixtures/-/query.json\",\n            params={\"_trace\": 1, \"sql\": \"select * from non_existent_table\"},\n        )\n        assert response.status == 400\n\n    data = response.json\n    assert \"_trace\" in data\n    trace_info = data[\"_trace\"]\n    assert trace_info[\"traces\"][-1][\"error\"] == \"no such table: non_existent_table\"\n\n\ndef test_trace_parallel_queries():\n    with make_app_client(settings={\"trace_debug\": True}) as client:\n        response = client.get(\"/parallel-queries?_trace=1\")\n        assert response.status == 200\n\n    data = response.json\n    assert data[\"one\"] == 1\n    assert data[\"two\"] == 2\n    trace_info = data[\"_trace\"]\n    traces = [trace for trace in trace_info[\"traces\"] if \"sql\" in trace]\n    one, two = traces\n    # \"two\" should have started before \"one\" ended\n    assert two[\"start\"] < one[\"end\"]\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "\"\"\"\nTests for various datasette helper functions.\n\"\"\"\n\nfrom datasette.app import Datasette\nfrom datasette import utils\nfrom datasette.utils.asgi import Request\nfrom datasette.utils.sqlite import sqlite3\nimport json\nimport os\nimport pathlib\nimport pytest\nimport tempfile\nfrom unittest.mock import patch\n\n\n@pytest.mark.parametrize(\n    \"path,expected\",\n    [\n        (\"foo\", [\"foo\"]),\n        (\"foo,bar\", [\"foo\", \"bar\"]),\n        (\"123,433,112\", [\"123\", \"433\", \"112\"]),\n        (\"123~2C433,112\", [\"123,433\", \"112\"]),\n        (\"123~2F433~2F112\", [\"123/433/112\"]),\n    ],\n)\ndef test_urlsafe_components(path, expected):\n    assert expected == utils.urlsafe_components(path)\n\n\n@pytest.mark.parametrize(\n    \"path,added_args,expected\",\n    [\n        (\"/foo\", {\"bar\": 1}, \"/foo?bar=1\"),\n        (\"/foo?bar=1\", {\"baz\": 2}, \"/foo?bar=1&baz=2\"),\n        (\"/foo?bar=1&bar=2\", {\"baz\": 3}, \"/foo?bar=1&bar=2&baz=3\"),\n        (\"/foo?bar=1\", {\"bar\": None}, \"/foo\"),\n        # Test order is preserved\n        (\n            \"/?_facet=prim_state&_facet=area_name\",\n            ((\"prim_state\", \"GA\"),),\n            \"/?_facet=prim_state&_facet=area_name&prim_state=GA\",\n        ),\n        (\n            \"/?_facet=state&_facet=city&state=MI\",\n            ((\"city\", \"Detroit\"),),\n            \"/?_facet=state&_facet=city&state=MI&city=Detroit\",\n        ),\n        (\n            \"/?_facet=state&_facet=city\",\n            ((\"_facet\", \"planet_int\"),),\n            \"/?_facet=state&_facet=city&_facet=planet_int\",\n        ),\n    ],\n)\ndef test_path_with_added_args(path, added_args, expected):\n    request = Request.fake(path)\n    actual = utils.path_with_added_args(request, added_args)\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\n    \"path,args,expected\",\n    [\n        (\"/foo?bar=1\", {\"bar\"}, \"/foo\"),\n        (\"/foo?bar=1&baz=2\", {\"bar\"}, \"/foo?baz=2\"),\n        (\"/foo?bar=1&bar=2&bar=3\", {\"bar\": \"2\"}, \"/foo?bar=1&bar=3\"),\n    ],\n)\ndef test_path_with_removed_args(path, args, expected):\n    request = Request.fake(path)\n    actual = utils.path_with_removed_args(request, args)\n    assert expected == actual\n    # Run the test again but this time use the path= argument\n    request = Request.fake(\"/\")\n    actual = utils.path_with_removed_args(request, args, path=path)\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\n    \"path,args,expected\",\n    [\n        (\"/foo?bar=1\", {\"bar\": 2}, \"/foo?bar=2\"),\n        (\"/foo?bar=1&baz=2\", {\"bar\": None}, \"/foo?baz=2\"),\n    ],\n)\ndef test_path_with_replaced_args(path, args, expected):\n    request = Request.fake(path)\n    actual = utils.path_with_replaced_args(request, args)\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\n    \"row,pks,expected_path\",\n    [\n        ({\"A\": \"foo\", \"B\": \"bar\"}, [\"A\", \"B\"], \"foo,bar\"),\n        ({\"A\": \"f,o\", \"B\": \"bar\"}, [\"A\", \"B\"], \"f~2Co,bar\"),\n        ({\"A\": 123}, [\"A\"], \"123\"),\n        (\n            utils.CustomRow(\n                [\"searchable_id\", \"tag\"],\n                [\n                    (\"searchable_id\", {\"value\": 1, \"label\": \"1\"}),\n                    (\"tag\", {\"value\": \"feline\", \"label\": \"feline\"}),\n                ],\n            ),\n            [\"searchable_id\", \"tag\"],\n            \"1,feline\",\n        ),\n    ],\n)\ndef test_path_from_row_pks(row, pks, expected_path):\n    actual_path = utils.path_from_row_pks(row, pks, False)\n    assert expected_path == actual_path\n\n\n@pytest.mark.parametrize(\n    \"obj,expected\",\n    [\n        (\n            {\n                \"Description\": \"Soft drinks\",\n                \"Picture\": b\"\\x15\\x1c\\x02\\xc7\\xad\\x05\\xfe\",\n                \"CategoryID\": 1,\n            },\n            \"\"\"\n        {\"CategoryID\": 1, \"Description\": \"Soft drinks\", \"Picture\": {\"$base64\": true, \"encoded\": \"FRwCx60F/g==\"}}\n    \"\"\".strip(),\n        )\n    ],\n)\ndef test_custom_json_encoder(obj, expected):\n    actual = json.dumps(obj, cls=utils.CustomJSONEncoder, sort_keys=True)\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\n    \"bad_sql\",\n    [\n        \"update blah;\",\n        \"-- sql comment to skip\\nupdate blah;\",\n        \"update blah set some_column='# Hello there\\n\\n* This is a list\\n* of items\\n--\\n[And a link](https://github.com/simonw/datasette-render-markdown).'\\nas demo_markdown\",\n        \"PRAGMA case_sensitive_like = true\",\n        \"SELECT * FROM pragma_not_on_allow_list('idx52')\",\n        \"/* This comment is not valid. select 1\",\n        \"/**/\\nupdate foo set bar = 1\\n/* test */ select 1\",\n    ],\n)\ndef test_validate_sql_select_bad(bad_sql):\n    with pytest.raises(utils.InvalidSql):\n        utils.validate_sql_select(bad_sql)\n\n\n@pytest.mark.parametrize(\n    \"good_sql\",\n    [\n        \"select count(*) from airports\",\n        \"select foo from bar\",\n        \"--sql comment to skip\\nselect foo from bar\",\n        \"select '# Hello there\\n\\n* This is a list\\n* of items\\n--\\n[And a link](https://github.com/simonw/datasette-render-markdown).'\\nas demo_markdown\",\n        \"select 1 + 1\",\n        \"explain select 1 + 1\",\n        \"explain\\nselect 1 + 1\",\n        \"explain query plan select 1 + 1\",\n        \"explain  query  plan\\nselect 1 + 1\",\n        \"SELECT\\nblah FROM foo\",\n        \"WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT 10) SELECT x FROM cnt;\",\n        \"explain WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT 10) SELECT x FROM cnt;\",\n        \"explain query plan WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT 10) SELECT x FROM cnt;\",\n        \"SELECT * FROM pragma_index_info('idx52')\",\n        \"select * from pragma_table_xinfo('table')\",\n        # Various types of comment\n        \"-- comment\\nselect 1\",\n        \"-- one line\\n  -- two line\\nselect 1\",\n        \"  /* comment */\\nselect 1\",\n        \"  /* comment */select 1\",\n        \"/* comment */\\n -- another\\n /* one more */ select 1\",\n        \"/* This comment \\n has multiple lines */\\nselect 1\",\n    ],\n)\ndef test_validate_sql_select_good(good_sql):\n    utils.validate_sql_select(good_sql)\n\n\n@pytest.mark.parametrize(\"open_quote,close_quote\", [('\"', '\"'), (\"[\", \"]\")])\ndef test_detect_fts(open_quote, close_quote):\n    sql = \"\"\"\n    CREATE TABLE \"Dumb_Table\" (\n      \"TreeID\" INTEGER,\n      \"qSpecies\" TEXT\n    );\n    CREATE TABLE \"Street_Tree_List\" (\n      \"TreeID\" INTEGER,\n      \"qSpecies\" TEXT,\n      \"qAddress\" TEXT,\n      \"SiteOrder\" INTEGER,\n      \"qSiteInfo\" TEXT,\n      \"PlantType\" TEXT,\n      \"qCaretaker\" TEXT\n    );\n    CREATE VIEW Test_View AS SELECT * FROM Dumb_Table;\n    CREATE VIRTUAL TABLE {open}Street_Tree_List_fts{close} USING FTS4 (\"qAddress\", \"qCaretaker\", \"qSpecies\", content={open}Street_Tree_List{close});\n    CREATE VIRTUAL TABLE r USING rtree(a, b, c);\n    \"\"\".format(open=open_quote, close=close_quote)\n    conn = utils.sqlite3.connect(\":memory:\")\n    conn.executescript(sql)\n    assert None is utils.detect_fts(conn, \"Dumb_Table\")\n    assert None is utils.detect_fts(conn, \"Test_View\")\n    assert None is utils.detect_fts(conn, \"r\")\n    assert \"Street_Tree_List_fts\" == utils.detect_fts(conn, \"Street_Tree_List\")\n\n\n@pytest.mark.parametrize(\"table\", (\"regular\", \"has'single quote\"))\ndef test_detect_fts_different_table_names(table):\n    sql = \"\"\"\n    CREATE TABLE [{table}] (\n      \"TreeID\" INTEGER,\n      \"qSpecies\" TEXT\n    );\n    CREATE VIRTUAL TABLE [{table}_fts] USING FTS4 (\"qSpecies\", content=\"{table}\");\n    \"\"\".format(table=table)\n    conn = utils.sqlite3.connect(\":memory:\")\n    conn.executescript(sql)\n    assert \"{table}_fts\".format(table=table) == utils.detect_fts(conn, table)\n\n\n@pytest.mark.parametrize(\n    \"url,expected\",\n    [\n        (\"http://www.google.com/\", True),\n        (\"https://example.com/\", True),\n        (\"www.google.com\", False),\n        (\"http://www.google.com/ is a search engine\", False),\n    ],\n)\ndef test_is_url(url, expected):\n    assert expected == utils.is_url(url)\n\n\n@pytest.mark.parametrize(\n    \"s,expected\",\n    [\n        (\"simple\", \"simple\"),\n        (\"MixedCase\", \"MixedCase\"),\n        (\"-no-leading-hyphens\", \"no-leading-hyphens-65bea6\"),\n        (\"_no-leading-underscores\", \"no-leading-underscores-b921bc\"),\n        (\"no spaces\", \"no-spaces-7088d7\"),\n        (\"-\", \"336d5e\"),\n        (\"no $ characters\", \"no--characters-59e024\"),\n    ],\n)\ndef test_to_css_class(s, expected):\n    assert expected == utils.to_css_class(s)\n\n\ndef test_temporary_docker_directory_uses_hard_link():\n    with tempfile.TemporaryDirectory() as td:\n        os.chdir(td)\n        with open(\"hello\", \"w\") as fp:\n            fp.write(\"world\")\n        # Default usage of this should use symlink\n        with utils.temporary_docker_directory(\n            files=[\"hello\"],\n            name=\"t\",\n            metadata=None,\n            extra_options=None,\n            branch=None,\n            template_dir=None,\n            plugins_dir=None,\n            static=[],\n            install=[],\n            spatialite=False,\n            version_note=None,\n            secret=\"secret\",\n        ) as temp_docker:\n            hello = os.path.join(temp_docker, \"hello\")\n            with open(hello) as fp:\n                assert \"world\" == fp.read()\n            # It should be a hard link\n            assert 2 == os.stat(hello).st_nlink\n\n\n@patch(\"os.link\")\ndef test_temporary_docker_directory_uses_copy_if_hard_link_fails(mock_link):\n    # Copy instead if os.link raises OSError (normally due to different device)\n    mock_link.side_effect = OSError\n    with tempfile.TemporaryDirectory() as td:\n        os.chdir(td)\n        with open(\"hello\", \"w\") as fp:\n            fp.write(\"world\")\n        # Default usage of this should use symlink\n        with utils.temporary_docker_directory(\n            files=[\"hello\"],\n            name=\"t\",\n            metadata=None,\n            extra_options=None,\n            branch=None,\n            template_dir=None,\n            plugins_dir=None,\n            static=[],\n            install=[],\n            spatialite=False,\n            version_note=None,\n            secret=None,\n        ) as temp_docker:\n            hello = os.path.join(temp_docker, \"hello\")\n            with open(hello) as fp:\n                assert \"world\" == fp.read()\n            # It should be a copy, not a hard link\n            assert 1 == os.stat(hello).st_nlink\n\n\ndef test_temporary_docker_directory_quotes_args():\n    with tempfile.TemporaryDirectory() as td:\n        os.chdir(td)\n        with open(\"hello\", \"w\") as fp:\n            fp.write(\"world\")\n        with utils.temporary_docker_directory(\n            files=[\"hello\"],\n            name=\"t\",\n            metadata=None,\n            extra_options=\"--$HOME\",\n            branch=None,\n            template_dir=None,\n            plugins_dir=None,\n            static=[],\n            install=[],\n            spatialite=False,\n            version_note=\"$PWD\",\n            secret=\"secret\",\n        ) as temp_docker:\n            df = os.path.join(temp_docker, \"Dockerfile\")\n            with open(df) as fp:\n                df_contents = fp.read()\n            assert \"'$PWD'\" in df_contents\n            assert \"'--$HOME'\" in df_contents\n            assert \"ENV DATASETTE_SECRET 'secret'\" in df_contents\n\n\ndef test_compound_keys_after_sql():\n    assert \"((a > :p0))\" == utils.compound_keys_after_sql([\"a\"])\n    assert \"\"\"\n((a > :p0)\n  or\n(a = :p0 and b > :p1))\n    \"\"\".strip() == utils.compound_keys_after_sql([\"a\", \"b\"])\n    assert \"\"\"\n((a > :p0)\n  or\n(a = :p0 and b > :p1)\n  or\n(a = :p0 and b = :p1 and c > :p2))\n    \"\"\".strip() == utils.compound_keys_after_sql([\"a\", \"b\", \"c\"])\n\n\ndef test_table_columns():\n    conn = sqlite3.connect(\":memory:\")\n    conn.executescript(\"\"\"\n    create table places (id integer primary key, name text, bob integer)\n    \"\"\")\n    assert [\"id\", \"name\", \"bob\"] == utils.table_columns(conn, \"places\")\n\n\n@pytest.mark.parametrize(\n    \"path,format,extra_qs,expected\",\n    [\n        (\"/foo?sql=select+1\", \"csv\", {}, \"/foo.csv?sql=select+1\"),\n        (\"/foo?sql=select+1\", \"json\", {}, \"/foo.json?sql=select+1\"),\n        (\"/foo/bar\", \"json\", {}, \"/foo/bar.json\"),\n        (\"/foo/bar\", \"csv\", {}, \"/foo/bar.csv\"),\n        (\"/foo/bar\", \"csv\", {\"_dl\": 1}, \"/foo/bar.csv?_dl=1\"),\n        (\n            \"/sf-trees/Street_Tree_List?_search=cherry&_size=1000\",\n            \"csv\",\n            {\"_dl\": 1},\n            \"/sf-trees/Street_Tree_List.csv?_search=cherry&_size=1000&_dl=1\",\n        ),\n    ],\n)\ndef test_path_with_format(path, format, extra_qs, expected):\n    request = Request.fake(path)\n    actual = utils.path_with_format(request=request, format=format, extra_qs=extra_qs)\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\n    \"bytes,expected\",\n    [\n        (120, \"120 bytes\"),\n        (1024, \"1.0 KB\"),\n        (1024 * 1024, \"1.0 MB\"),\n        (1024 * 1024 * 1024, \"1.0 GB\"),\n        (1024 * 1024 * 1024 * 1.3, \"1.3 GB\"),\n        (1024 * 1024 * 1024 * 1024, \"1.0 TB\"),\n    ],\n)\ndef test_format_bytes(bytes, expected):\n    assert expected == utils.format_bytes(bytes)\n\n\n@pytest.mark.parametrize(\n    \"query,expected\",\n    [\n        (\"dog\", '\"dog\"'),\n        (\"cat,\", '\"cat,\"'),\n        (\"cat dog\", '\"cat\" \"dog\"'),\n        # If a phrase is already double quoted, leave it so\n        ('\"cat dog\"', '\"cat dog\"'),\n        ('\"cat dog\" fish', '\"cat dog\" \"fish\"'),\n        # Sensibly handle unbalanced double quotes\n        ('cat\"', '\"cat\"'),\n        ('\"cat dog\" \"fish', '\"cat dog\" \"fish\"'),\n    ],\n)\ndef test_escape_fts(query, expected):\n    assert expected == utils.escape_fts(query)\n\n\n@pytest.mark.parametrize(\n    \"input,expected\",\n    [\n        (\"dog\", \"dog\"),\n        ('dateutil_parse(\"1/2/2020\")', r\"dateutil_parse(\\0000221/2/2020\\000022)\"),\n        (\"this\\r\\nand\\r\\nthat\", r\"this\\00000Aand\\00000Athat\"),\n    ],\n)\ndef test_escape_css_string(input, expected):\n    assert expected == utils.escape_css_string(input)\n\n\ndef test_check_connection_spatialite_raises():\n    path = str(pathlib.Path(__file__).parent / \"spatialite.db\")\n    conn = sqlite3.connect(path)\n    with pytest.raises(utils.SpatialiteConnectionProblem):\n        utils.check_connection(conn)\n\n\ndef test_check_connection_passes():\n    conn = sqlite3.connect(\":memory:\")\n    utils.check_connection(conn)\n\n\ndef test_call_with_supported_arguments():\n    def foo(a, b):\n        return f\"{a}+{b}\"\n\n    assert \"1+2\" == utils.call_with_supported_arguments(foo, a=1, b=2)\n    assert \"1+2\" == utils.call_with_supported_arguments(foo, a=1, b=2, c=3)\n\n    with pytest.raises(TypeError):\n        utils.call_with_supported_arguments(foo, a=1)\n\n\n@pytest.mark.parametrize(\n    \"data,should_raise\",\n    [\n        ([[\"foo\", \"bar\"], [\"foo\", \"baz\"]], False),\n        ([(\"foo\", \"bar\"), (\"foo\", \"baz\")], False),\n        (([\"foo\", \"bar\"], [\"foo\", \"baz\"]), False),\n        ([[\"foo\", \"bar\"], [\"foo\", \"baz\", \"bax\"]], True),\n        ({\"foo\": [\"bar\", \"baz\"]}, False),\n        ({\"foo\": (\"bar\", \"baz\")}, False),\n        ({\"foo\": \"bar\"}, True),\n    ],\n)\ndef test_multi_params(data, should_raise):\n    if should_raise:\n        with pytest.raises(AssertionError):\n            utils.MultiParams(data)\n        return\n    p1 = utils.MultiParams(data)\n    assert \"bar\" == p1[\"foo\"]\n    assert [\"bar\", \"baz\"] == list(p1.getlist(\"foo\"))\n\n\n@pytest.mark.parametrize(\n    \"actor,allow,expected\",\n    [\n        # Default is to allow:\n        (None, None, True),\n        # {} means deny-all:\n        (None, {}, False),\n        ({\"id\": \"root\"}, {}, False),\n        # true means allow-all\n        ({\"id\": \"root\"}, True, True),\n        (None, True, True),\n        # false means deny-all\n        ({\"id\": \"root\"}, False, False),\n        (None, False, False),\n        # Special case for \"unauthenticated\": true\n        (None, {\"unauthenticated\": True}, True),\n        (None, {\"unauthenticated\": False}, False),\n        # Match on just one property:\n        (None, {\"id\": \"root\"}, False),\n        ({\"id\": \"root\"}, None, True),\n        ({\"id\": \"simon\", \"staff\": True}, {\"staff\": True}, True),\n        ({\"id\": \"simon\", \"staff\": False}, {\"staff\": True}, False),\n        # Special \"*\" value for any key:\n        ({\"id\": \"root\"}, {\"id\": \"*\"}, True),\n        ({}, {\"id\": \"*\"}, False),\n        ({\"name\": \"root\"}, {\"id\": \"*\"}, False),\n        # Supports single strings or list of values:\n        ({\"id\": \"root\"}, {\"id\": \"bob\"}, False),\n        ({\"id\": \"root\"}, {\"id\": [\"bob\"]}, False),\n        ({\"id\": \"root\"}, {\"id\": \"root\"}, True),\n        ({\"id\": \"root\"}, {\"id\": [\"root\"]}, True),\n        # Any matching role will work:\n        ({\"id\": \"garry\", \"roles\": [\"staff\", \"dev\"]}, {\"roles\": [\"staff\"]}, True),\n        ({\"id\": \"garry\", \"roles\": [\"staff\", \"dev\"]}, {\"roles\": [\"dev\"]}, True),\n        ({\"id\": \"garry\", \"roles\": [\"staff\", \"dev\"]}, {\"roles\": [\"otter\"]}, False),\n        ({\"id\": \"garry\", \"roles\": [\"staff\", \"dev\"]}, {\"roles\": [\"dev\", \"otter\"]}, True),\n        ({\"id\": \"garry\", \"roles\": []}, {\"roles\": [\"staff\"]}, False),\n        ({\"id\": \"garry\"}, {\"roles\": [\"staff\"]}, False),\n        # Any single matching key works:\n        ({\"id\": \"root\"}, {\"bot_id\": \"my-bot\", \"id\": [\"root\"]}, True),\n    ],\n)\ndef test_actor_matches_allow(actor, allow, expected):\n    assert expected == utils.actor_matches_allow(actor, allow)\n\n\n@pytest.mark.parametrize(\n    \"config,expected\",\n    [\n        ({\"foo\": \"bar\"}, {\"foo\": \"bar\"}),\n        ({\"$env\": \"FOO\"}, \"x\"),\n        ({\"k\": {\"$env\": \"FOO\"}}, {\"k\": \"x\"}),\n        ([{\"k\": {\"$env\": \"FOO\"}}, {\"z\": {\"$env\": \"FOO\"}}], [{\"k\": \"x\"}, {\"z\": \"x\"}]),\n        ({\"k\": [{\"in_a_list\": {\"$env\": \"FOO\"}}]}, {\"k\": [{\"in_a_list\": \"x\"}]}),\n    ],\n)\ndef test_resolve_env_secrets(config, expected):\n    assert expected == utils.resolve_env_secrets(config, {\"FOO\": \"x\"})\n\n\n@pytest.mark.parametrize(\n    \"actor,expected\",\n    [\n        ({\"id\": \"blah\"}, \"blah\"),\n        ({\"id\": \"blah\", \"login\": \"l\"}, \"l\"),\n        ({\"id\": \"blah\", \"login\": \"l\"}, \"l\"),\n        ({\"id\": \"blah\", \"login\": \"l\", \"username\": \"u\"}, \"u\"),\n        ({\"login\": \"l\", \"name\": \"n\"}, \"n\"),\n        (\n            {\"id\": \"blah\", \"login\": \"l\", \"username\": \"u\", \"name\": \"n\", \"display\": \"d\"},\n            \"d\",\n        ),\n        ({\"weird\": \"shape\"}, \"{'weird': 'shape'}\"),\n    ],\n)\ndef test_display_actor(actor, expected):\n    assert expected == utils.display_actor(actor)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"dbs,expected_path\",\n    [\n        ([\"one_table\"], \"/one/one\"),\n        ([\"two_tables\"], \"/two\"),\n        ([\"one_table\", \"two_tables\"], \"/\"),\n    ],\n)\nasync def test_initial_path_for_datasette(tmp_path_factory, dbs, expected_path):\n    db_dir = tmp_path_factory.mktemp(\"dbs\")\n    one_table = str(db_dir / \"one.db\")\n    sqlite3.connect(one_table).execute(\"create table one (id integer primary key)\")\n    two_tables = str(db_dir / \"two.db\")\n    sqlite3.connect(two_tables).execute(\"create table two (id integer primary key)\")\n    sqlite3.connect(two_tables).execute(\"create table three (id integer primary key)\")\n    datasette = Datasette(\n        [{\"one_table\": one_table, \"two_tables\": two_tables}[db] for db in dbs]\n    )\n    path = await utils.initial_path_for_datasette(datasette)\n    assert path == expected_path\n\n\n@pytest.mark.parametrize(\n    \"content,expected\",\n    (\n        (\"title: Hello\", {\"title\": \"Hello\"}),\n        ('{\"title\": \"Hello\"}', {\"title\": \"Hello\"}),\n        (\"{{ this }} is {{ bad }}\", None),\n    ),\n)\ndef test_parse_metadata(content, expected):\n    if expected is None:\n        with pytest.raises(utils.BadMetadataError):\n            utils.parse_metadata(content)\n    else:\n        assert utils.parse_metadata(content) == expected\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"sql,expected\",\n    (\n        (\"select 1\", []),\n        (\"select 1 + :one\", [\"one\"]),\n        (\"select 1 + :one + :two\", [\"one\", \"two\"]),\n        (\"select 'bob' || '0:00' || :cat\", [\"cat\"]),\n        (\"select this is invalid :one, :two, :three\", [\"one\", \"two\", \"three\"]),\n    ),\n)\n@pytest.mark.parametrize(\"use_async_version\", (False, True))\nasync def test_named_parameters(sql, expected, use_async_version):\n    ds = Datasette([], memory=True)\n    db = ds.get_database(\"_memory\")\n    if use_async_version:\n        params = await utils.derive_named_parameters(db, sql)\n    else:\n        params = utils.named_parameters(sql)\n    assert params == expected\n\n\n@pytest.mark.parametrize(\n    \"original,expected\",\n    (\n        (\"abc\", \"abc\"),\n        (\"/foo/bar\", \"~2Ffoo~2Fbar\"),\n        (\"/-/bar\", \"~2F-~2Fbar\"),\n        (\"-/db-/table.csv\", \"-~2Fdb-~2Ftable~2Ecsv\"),\n        (r\"%~-/\", \"~25~7E-~2F\"),\n        (\"~25~7E~2D~2F\", \"~7E25~7E7E~7E2D~7E2F\"),\n        (\"with space\", \"with+space\"),\n    ),\n)\ndef test_tilde_encoding(original, expected):\n    actual = utils.tilde_encode(original)\n    assert actual == expected\n    # And test round-trip\n    assert original == utils.tilde_decode(actual)\n\n\n@pytest.mark.parametrize(\n    \"url,length,expected\",\n    (\n        (\"https://example.com/\", 5, \"http…\"),\n        (\"https://example.com/foo/bar\", 15, \"https://exampl…\"),\n        (\"https://example.com/foo/bar/baz.jpg\", 30, \"https://example.com/foo/ba….jpg\"),\n        # Extensions longer than 4 characters are not treated specially:\n        (\"https://example.com/foo/bar/baz.jpeg2\", 30, \"https://example.com/foo/bar/b…\"),\n        (\n            \"https://example.com/foo/bar/baz.jpeg2\",\n            None,\n            \"https://example.com/foo/bar/baz.jpeg2\",\n        ),\n    ),\n)\ndef test_truncate_url(url, length, expected):\n    actual = utils.truncate_url(url, length)\n    assert actual == expected\n\n\n@pytest.mark.parametrize(\n    \"pairs,expected\",\n    (\n        # Simple nested objects\n        ([(\"a\", \"b\")], {\"a\": \"b\"}),\n        ([(\"a.b\", \"c\")], {\"a\": {\"b\": \"c\"}}),\n        # JSON literals\n        ([(\"a.b\", \"true\")], {\"a\": {\"b\": True}}),\n        ([(\"a.b\", \"false\")], {\"a\": {\"b\": False}}),\n        ([(\"a.b\", \"null\")], {\"a\": {\"b\": None}}),\n        ([(\"a.b\", \"1\")], {\"a\": {\"b\": 1}}),\n        ([(\"a.b\", \"1.1\")], {\"a\": {\"b\": 1.1}}),\n        # Nested JSON literals\n        ([(\"a.b\", '{\"foo\": \"bar\"}')], {\"a\": {\"b\": {\"foo\": \"bar\"}}}),\n        ([(\"a.b\", \"[1, 2, 3]\")], {\"a\": {\"b\": [1, 2, 3]}}),\n        # JSON strings are preserved\n        ([(\"a.b\", '\"true\"')], {\"a\": {\"b\": \"true\"}}),\n        ([(\"a.b\", '\"[1, 2, 3]\"')], {\"a\": {\"b\": \"[1, 2, 3]\"}}),\n        # Later keys over-ride the previous\n        (\n            [\n                (\"a\", \"b\"),\n                (\"a.b\", \"c\"),\n            ],\n            {\"a\": {\"b\": \"c\"}},\n        ),\n        (\n            [\n                (\"settings.trace_debug\", \"true\"),\n                (\"plugins.datasette-ripgrep.path\", \"/etc\"),\n                (\"settings.trace_debug\", \"false\"),\n            ],\n            {\n                \"settings\": {\n                    \"trace_debug\": False,\n                },\n                \"plugins\": {\n                    \"datasette-ripgrep\": {\n                        \"path\": \"/etc\",\n                    }\n                },\n            },\n        ),\n    ),\n)\ndef test_pairs_to_nested_config(pairs, expected):\n    actual = utils.pairs_to_nested_config(pairs)\n    assert actual == expected\n\n\n@pytest.mark.asyncio\nasync def test_calculate_etag(tmp_path):\n    path = tmp_path / \"test.txt\"\n    path.write_text(\"hello\")\n    etag = '\"5d41402abc4b2a76b9719d911017c592\"'\n    assert etag == await utils.calculate_etag(path)\n    assert utils._etag_cache[path] == etag\n    utils._etag_cache[path] = \"hash\"\n    assert \"hash\" == await utils.calculate_etag(path)\n    utils._etag_cache.clear()\n\n\n@pytest.mark.parametrize(\n    \"dict1,dict2,expected\",\n    [\n        # Basic update\n        ({\"a\": 1, \"b\": 2}, {\"b\": 3, \"c\": 4}, {\"a\": 1, \"b\": 3, \"c\": 4}),\n        # Nested dictionary update\n        (\n            {\"a\": 1, \"b\": {\"x\": 10, \"y\": 20}},\n            {\"b\": {\"y\": 30, \"z\": 40}},\n            {\"a\": 1, \"b\": {\"x\": 10, \"y\": 30, \"z\": 40}},\n        ),\n        # Deep nested update\n        (\n            {\"a\": {\"b\": {\"c\": 1}}},\n            {\"a\": {\"b\": {\"d\": 2}}},\n            {\"a\": {\"b\": {\"c\": 1, \"d\": 2}}},\n        ),\n        # Update with mixed types\n        (\n            {\"a\": 1, \"b\": {\"x\": 10}},\n            {\"b\": {\"y\": 20}, \"c\": [1, 2, 3]},\n            {\"a\": 1, \"b\": {\"x\": 10, \"y\": 20}, \"c\": [1, 2, 3]},\n        ),\n    ],\n)\ndef test_deep_dict_update(dict1, dict2, expected):\n    result = utils.deep_dict_update(dict1, dict2)\n    assert result == expected\n    # Check that the original dict1 was modified\n    assert dict1 == expected\n"
  },
  {
    "path": "tests/test_utils_check_callable.py",
    "content": "from datasette.utils.check_callable import check_callable\nimport pytest\n\n\nclass AsyncClass:\n    async def __call__(self):\n        pass\n\n\nclass NotAsyncClass:\n    def __call__(self):\n        pass\n\n\nclass ClassNoCall:\n    pass\n\n\nasync def async_func():\n    pass\n\n\ndef non_async_func():\n    pass\n\n\n@pytest.mark.parametrize(\n    \"obj,expected_is_callable,expected_is_async_callable\",\n    (\n        (async_func, True, True),\n        (non_async_func, True, False),\n        (AsyncClass(), True, True),\n        (NotAsyncClass(), True, False),\n        (ClassNoCall(), False, False),\n        (AsyncClass, True, False),\n        (NotAsyncClass, True, False),\n        (ClassNoCall, True, False),\n        (\"\", False, False),\n        (1, False, False),\n        (str, True, False),\n    ),\n)\ndef test_check_callable(obj, expected_is_callable, expected_is_async_callable):\n    status = check_callable(obj)\n    assert status.is_callable == expected_is_callable\n    assert status.is_async_callable == expected_is_async_callable\n"
  },
  {
    "path": "tests/test_utils_permissions.py",
    "content": "import pytest\nfrom datasette.app import Datasette\nfrom datasette.permissions import PermissionSQL\nfrom datasette.utils.permissions import resolve_permissions_from_catalog\nfrom typing import Callable, List\n\n\n@pytest.fixture\ndef db():\n    ds = Datasette()\n    import tempfile\n    from datasette.database import Database\n\n    path = tempfile.mktemp(suffix=\"demo.db\")\n    db = ds.add_database(Database(ds, path=path))\n    return db\n\n\nNO_RULES_SQL = (\n    \"SELECT NULL AS parent, NULL AS child, NULL AS allow, NULL AS reason WHERE 0\"\n)\n\n\ndef plugin_allow_all_for_user(user: str) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT NULL AS parent, NULL AS child, 1 AS allow,\n                   'global allow for ' || :allow_all_user || ' on ' || :allow_all_action AS reason\n            WHERE :actor_id = :allow_all_user\n            \"\"\",\n            {\"allow_all_user\": user, \"allow_all_action\": action},\n        )\n\n    return provider\n\n\ndef plugin_deny_specific_table(\n    user: str, parent: str, child: str\n) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :deny_specific_table_parent AS parent, :deny_specific_table_child AS child, 0 AS allow,\n                   'deny ' || :deny_specific_table_parent || '/' || :deny_specific_table_child || ' for ' || :deny_specific_table_user || ' on ' || :deny_specific_table_action AS reason\n            WHERE :actor_id = :deny_specific_table_user\n            \"\"\",\n            {\n                \"deny_specific_table_parent\": parent,\n                \"deny_specific_table_child\": child,\n                \"deny_specific_table_user\": user,\n                \"deny_specific_table_action\": action,\n            },\n        )\n\n    return provider\n\n\ndef plugin_org_policy_deny_parent(parent: str) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :org_policy_parent_deny_parent AS parent, NULL AS child, 0 AS allow,\n                   'org policy: parent ' || :org_policy_parent_deny_parent || ' denied on ' || :org_policy_parent_deny_action AS reason\n            \"\"\",\n            {\n                \"org_policy_parent_deny_parent\": parent,\n                \"org_policy_parent_deny_action\": action,\n            },\n        )\n\n    return provider\n\n\ndef plugin_allow_parent_for_user(\n    user: str, parent: str\n) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :allow_parent_parent AS parent, NULL AS child, 1 AS allow,\n                   'allow full parent for ' || :allow_parent_user || ' on ' || :allow_parent_action AS reason\n            WHERE :actor_id = :allow_parent_user\n            \"\"\",\n            {\n                \"allow_parent_parent\": parent,\n                \"allow_parent_user\": user,\n                \"allow_parent_action\": action,\n            },\n        )\n\n    return provider\n\n\ndef plugin_child_allow_for_user(\n    user: str, parent: str, child: str\n) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :allow_child_parent AS parent, :allow_child_child AS child, 1 AS allow,\n                   'allow child for ' || :allow_child_user || ' on ' || :allow_child_action AS reason\n            WHERE :actor_id = :allow_child_user\n            \"\"\",\n            {\n                \"allow_child_parent\": parent,\n                \"allow_child_child\": child,\n                \"allow_child_user\": user,\n                \"allow_child_action\": action,\n            },\n        )\n\n    return provider\n\n\ndef plugin_root_deny_for_all() -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :root_deny_action AS reason\n            \"\"\",\n            {\"root_deny_action\": action},\n        )\n\n    return provider\n\n\ndef plugin_conflicting_same_child_rules(\n    user: str, parent: str, child: str\n) -> List[Callable[[str], PermissionSQL]]:\n    def allow_provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :conflict_child_allow_parent AS parent, :conflict_child_allow_child AS child, 1 AS allow,\n                   'team grant at child for ' || :conflict_child_allow_user || ' on ' || :conflict_child_allow_action AS reason\n            WHERE :actor_id = :conflict_child_allow_user\n            \"\"\",\n            {\n                \"conflict_child_allow_parent\": parent,\n                \"conflict_child_allow_child\": child,\n                \"conflict_child_allow_user\": user,\n                \"conflict_child_allow_action\": action,\n            },\n        )\n\n    def deny_provider(action: str) -> PermissionSQL:\n        return PermissionSQL(\n            \"\"\"\n            SELECT :conflict_child_deny_parent AS parent, :conflict_child_deny_child AS child, 0 AS allow,\n                   'exception deny at child for ' || :conflict_child_deny_user || ' on ' || :conflict_child_deny_action AS reason\n            WHERE :actor_id = :conflict_child_deny_user\n            \"\"\",\n            {\n                \"conflict_child_deny_parent\": parent,\n                \"conflict_child_deny_child\": child,\n                \"conflict_child_deny_user\": user,\n                \"conflict_child_deny_action\": action,\n            },\n        )\n\n    return [allow_provider, deny_provider]\n\n\ndef plugin_allow_all_for_action(\n    user: str, allowed_action: str\n) -> Callable[[str], PermissionSQL]:\n    def provider(action: str) -> PermissionSQL:\n        if action != allowed_action:\n            return PermissionSQL(NO_RULES_SQL)\n        # Sanitize parameter names by replacing hyphens with underscores\n        param_prefix = action.replace(\"-\", \"_\")\n        return PermissionSQL(\n            f\"\"\"\n            SELECT NULL AS parent, NULL AS child, 1 AS allow,\n                   'global allow for ' || :{param_prefix}_user || ' on ' || :{param_prefix}_action AS reason\n            WHERE :actor_id = :{param_prefix}_user\n            \"\"\",\n            {f\"{param_prefix}_user\": user, f\"{param_prefix}_action\": action},\n        )\n\n    return provider\n\n\nVIEW_TABLE = \"view-table\"\n\n\n# ---------- Catalog DDL (from your schema) ----------\nCATALOG_DDL = \"\"\"\nCREATE TABLE IF NOT EXISTS catalog_databases (\n    database_name TEXT PRIMARY KEY,\n    path TEXT,\n    is_memory INTEGER,\n    schema_version INTEGER\n);\nCREATE TABLE IF NOT EXISTS catalog_tables (\n    database_name TEXT,\n    table_name TEXT,\n    rootpage INTEGER,\n    sql TEXT,\n    PRIMARY KEY (database_name, table_name),\n    FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)\n);\n\"\"\"\n\nPARENTS = [\"accounting\", \"hr\", \"analytics\"]\nSPECIALS = {\"accounting\": [\"sales\"], \"analytics\": [\"secret\"], \"hr\": []}\n\nTABLE_CANDIDATES_SQL = (\n    \"SELECT database_name AS parent, table_name AS child FROM catalog_tables\"\n)\nPARENT_CANDIDATES_SQL = (\n    \"SELECT database_name AS parent, NULL AS child FROM catalog_databases\"\n)\n\n\n# ---------- Helpers ----------\nasync def seed_catalog(db, per_parent: int = 10) -> None:\n    await db.execute_write_script(CATALOG_DDL)\n    # databases\n    db_rows = [(p, f\"/{p}.db\", 0, 1) for p in PARENTS]\n    await db.execute_write_many(\n        \"INSERT OR REPLACE INTO catalog_databases(database_name, path, is_memory, schema_version) VALUES (?,?,?,?)\",\n        db_rows,\n    )\n\n    # tables\n    def tables_for(parent: str, n: int):\n        base = [f\"table{i:02d}\" for i in range(1, n + 1)]\n        for s in SPECIALS.get(parent, []):\n            if s not in base:\n                base[0] = s\n        return base\n\n    table_rows = []\n    for p in PARENTS:\n        for t in tables_for(p, per_parent):\n            table_rows.append((p, t, 0, f\"CREATE TABLE {t} (id INTEGER PRIMARY KEY)\"))\n    await db.execute_write_many(\n        \"INSERT OR REPLACE INTO catalog_tables(database_name, table_name, rootpage, sql) VALUES (?,?,?,?)\",\n        table_rows,\n    )\n\n\ndef res_allowed(rows, parent=None):\n    return sorted(\n        r[\"resource\"]\n        for r in rows\n        if r[\"allow\"] == 1 and (parent is None or r[\"parent\"] == parent)\n    )\n\n\ndef res_denied(rows, parent=None):\n    return sorted(\n        r[\"resource\"]\n        for r in rows\n        if r[\"allow\"] == 0 and (parent is None or r[\"parent\"] == parent)\n    )\n\n\n# ---------- Tests ----------\n@pytest.mark.asyncio\nasync def test_alice_global_allow_with_specific_denies_catalog(db):\n    await seed_catalog(db)\n    plugins = [\n        plugin_allow_all_for_user(\"alice\"),\n        plugin_deny_specific_table(\"alice\", \"accounting\", \"sales\"),\n        plugin_org_policy_deny_parent(\"hr\"),\n    ]\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"alice\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    # Alice can see everything except accounting/sales and hr/*\n    assert \"/accounting/sales\" in res_denied(rows)\n    for r in rows:\n        if r[\"parent\"] == \"hr\":\n            assert r[\"allow\"] == 0\n        elif r[\"resource\"] == \"/accounting/sales\":\n            assert r[\"allow\"] == 0\n        else:\n            assert r[\"allow\"] == 1\n\n\n@pytest.mark.asyncio\nasync def test_carol_parent_allow_but_child_conflict_deny_wins_catalog(db):\n    await seed_catalog(db)\n    plugins = [\n        plugin_org_policy_deny_parent(\"hr\"),\n        plugin_allow_parent_for_user(\"carol\", \"analytics\"),\n        *plugin_conflicting_same_child_rules(\"carol\", \"analytics\", \"secret\"),\n    ]\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"carol\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    allowed_analytics = res_allowed(rows, parent=\"analytics\")\n    denied_analytics = res_denied(rows, parent=\"analytics\")\n\n    assert \"/analytics/secret\" in denied_analytics\n    # 10 analytics children total, 1 denied\n    assert len(allowed_analytics) == 9\n\n\n@pytest.mark.asyncio\nasync def test_specificity_child_allow_overrides_parent_deny_catalog(db):\n    await seed_catalog(db)\n    plugins = [\n        plugin_allow_all_for_user(\"alice\"),\n        plugin_org_policy_deny_parent(\"analytics\"),  # parent-level deny\n        plugin_child_allow_for_user(\n            \"alice\", \"analytics\", \"table02\"\n        ),  # child allow beats parent deny\n    ]\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"alice\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n\n    # table02 allowed, other analytics tables denied\n    assert any(r[\"resource\"] == \"/analytics/table02\" and r[\"allow\"] == 1 for r in rows)\n    assert all(\n        (r[\"parent\"] != \"analytics\" or r[\"child\"] == \"table02\" or r[\"allow\"] == 0)\n        for r in rows\n    )\n\n\n@pytest.mark.asyncio\nasync def test_root_deny_all_but_parent_allow_rescues_specific_parent_catalog(db):\n    await seed_catalog(db)\n    plugins = [\n        plugin_root_deny_for_all(),  # root deny\n        plugin_allow_parent_for_user(\n            \"bob\", \"accounting\"\n        ),  # parent allow (more specific)\n    ]\n    rows = await resolve_permissions_from_catalog(\n        db, {\"id\": \"bob\"}, plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True\n    )\n    for r in rows:\n        if r[\"parent\"] == \"accounting\":\n            assert r[\"allow\"] == 1\n        else:\n            assert r[\"allow\"] == 0\n\n\n@pytest.mark.asyncio\nasync def test_parent_scoped_candidates(db):\n    await seed_catalog(db)\n    plugins = [\n        plugin_org_policy_deny_parent(\"hr\"),\n        plugin_allow_parent_for_user(\"carol\", \"analytics\"),\n    ]\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"carol\"},\n        plugins,\n        VIEW_TABLE,\n        PARENT_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    d = {r[\"resource\"]: r[\"allow\"] for r in rows}\n    assert d[\"/analytics\"] == 1\n    assert d[\"/hr\"] == 0\n\n\n@pytest.mark.asyncio\nasync def test_implicit_deny_behavior(db):\n    await seed_catalog(db)\n    plugins = []  # no rules at all\n\n    # implicit_deny=True -> everything denied with reason 'implicit deny'\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"erin\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    assert all(r[\"allow\"] == 0 and r[\"reason\"] == \"implicit deny\" for r in rows)\n\n    # implicit_deny=False -> no winner => allow is None, reason is None\n    rows2 = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"erin\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=False,\n    )\n    assert all(r[\"allow\"] is None and r[\"reason\"] is None for r in rows2)\n\n\n@pytest.mark.asyncio\nasync def test_candidate_filters_via_params(db):\n    await seed_catalog(db)\n    # Add some metadata to test filtering\n    # Mark 'hr' as is_memory=1 and increment analytics schema_version\n    await db.execute_write(\n        \"UPDATE catalog_databases SET is_memory=1 WHERE database_name='hr'\"\n    )\n    await db.execute_write(\n        \"UPDATE catalog_databases SET schema_version=2 WHERE database_name='analytics'\"\n    )\n\n    # Candidate SQL that filters by db metadata via params\n    candidate_sql = \"\"\"\n    SELECT t.database_name AS parent, t.table_name AS child\n    FROM catalog_tables t\n    JOIN catalog_databases d ON d.database_name = t.database_name\n    WHERE (:exclude_memory = 1 AND d.is_memory = 1) IS NOT 1\n      AND (:min_schema_version IS NULL OR d.schema_version >= :min_schema_version)\n    \"\"\"\n\n    plugins = [\n        plugin_root_deny_for_all(),\n        plugin_allow_parent_for_user(\n            \"dev\", \"analytics\"\n        ),  # analytics rescued if included by candidates\n    ]\n\n    # Case 1: exclude memory dbs, require schema_version >= 2 -> only analytics appear, and thus are allowed\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"dev\"},\n        plugins,\n        VIEW_TABLE,\n        candidate_sql,\n        candidate_params={\"exclude_memory\": 1, \"min_schema_version\": 2},\n        implicit_deny=True,\n    )\n    assert rows and all(r[\"parent\"] == \"analytics\" for r in rows)\n    assert all(r[\"allow\"] == 1 for r in rows)\n\n    # Case 2: include memory dbs, min_schema_version = None -> accounting/hr/analytics appear,\n    # but root deny wins except where specifically allowed (none except analytics parent allow doesn’t apply to table depth if candidate includes children; still fine—policy is explicit).\n    rows2 = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"dev\"},\n        plugins,\n        VIEW_TABLE,\n        candidate_sql,\n        candidate_params={\"exclude_memory\": 0, \"min_schema_version\": None},\n        implicit_deny=True,\n    )\n    assert any(r[\"parent\"] == \"accounting\" for r in rows2)\n    assert any(r[\"parent\"] == \"hr\" for r in rows2)\n    # For table-scoped candidates, the parent-level allow does not override root deny unless you have child-level rules\n    assert all(r[\"allow\"] in (0, 1) for r in rows2)\n\n\n@pytest.mark.asyncio\nasync def test_action_specific_rules(db):\n    await seed_catalog(db)\n    plugins = [plugin_allow_all_for_action(\"dana\", VIEW_TABLE)]\n\n    view_rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"dana\"},\n        plugins,\n        VIEW_TABLE,\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    assert view_rows and all(r[\"allow\"] == 1 for r in view_rows)\n    assert all(r[\"action\"] == VIEW_TABLE for r in view_rows)\n\n    insert_rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"dana\"},\n        plugins,\n        \"insert-row\",\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n    assert insert_rows and all(r[\"allow\"] == 0 for r in insert_rows)\n    assert all(r[\"reason\"] == \"implicit deny\" for r in insert_rows)\n    assert all(r[\"action\"] == \"insert-row\" for r in insert_rows)\n\n\n@pytest.mark.asyncio\nasync def test_actor_actor_id_action_parameters_available(db):\n    \"\"\"Test that :actor (JSON), :actor_id, and :action are all available in SQL\"\"\"\n    await seed_catalog(db)\n\n    def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]:\n        def provider(action: str) -> PermissionSQL:\n            return PermissionSQL(\"\"\"\n                SELECT NULL AS parent, NULL AS child, 1 AS allow,\n                       'Actor ID: ' || COALESCE(:actor_id, 'null') ||\n                       ', Actor JSON: ' || COALESCE(:actor, 'null') ||\n                       ', Action: ' || :action AS reason\n                WHERE :actor_id = 'test_user' AND :action = 'view-table'\n                AND json_extract(:actor, '$.role') = 'admin'\n                \"\"\")\n\n        return provider\n\n    plugins = [plugin_using_all_parameters()]\n\n    # Test with full actor dict\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"test_user\", \"role\": \"admin\"},\n        plugins,\n        \"view-table\",\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=True,\n    )\n\n    # Should have allowed rows with reason containing all the info\n    allowed = [r for r in rows if r[\"allow\"] == 1]\n    assert len(allowed) > 0\n\n    # Check that the reason string contains evidence of all parameters\n    reason = allowed[0][\"reason\"]\n    assert \"test_user\" in reason\n    assert \"view-table\" in reason\n    # The :actor parameter should be the JSON string\n    assert \"Actor JSON:\" in reason\n\n\n@pytest.mark.asyncio\nasync def test_multiple_plugins_with_own_parameters(db):\n    \"\"\"\n    Test that multiple plugins can use their own parameter names without conflict.\n\n    This verifies that the parameter naming convention works: plugins prefix their\n    parameters (e.g., :plugin1_pattern, :plugin2_message) and both sets of parameters\n    are successfully bound in the SQL queries.\n    \"\"\"\n    await seed_catalog(db)\n\n    def plugin_one() -> Callable[[str], PermissionSQL]:\n        def provider(action: str) -> PermissionSQL:\n            if action != \"view-table\":\n                return PermissionSQL(\"plugin_one\", \"SELECT NULL WHERE 0\", {})\n            return PermissionSQL(\n                \"\"\"\n                SELECT database_name AS parent, table_name AS child,\n                       1 AS allow, 'Plugin one used param: ' || :plugin1_param AS reason\n                FROM catalog_tables\n                WHERE database_name = 'accounting'\n                \"\"\",\n                {\n                    \"plugin1_param\": \"value1\",\n                },\n            )\n\n        return provider\n\n    def plugin_two() -> Callable[[str], PermissionSQL]:\n        def provider(action: str) -> PermissionSQL:\n            if action != \"view-table\":\n                return PermissionSQL(\"plugin_two\", \"SELECT NULL WHERE 0\", {})\n            return PermissionSQL(\n                \"\"\"\n                SELECT database_name AS parent, table_name AS child,\n                       1 AS allow, 'Plugin two used param: ' || :plugin2_param AS reason\n                FROM catalog_tables\n                WHERE database_name = 'hr'\n                \"\"\",\n                {\n                    \"plugin2_param\": \"value2\",\n                },\n            )\n\n        return provider\n\n    plugins = [plugin_one(), plugin_two()]\n\n    rows = await resolve_permissions_from_catalog(\n        db,\n        {\"id\": \"test_user\"},\n        plugins,\n        \"view-table\",\n        TABLE_CANDIDATES_SQL,\n        implicit_deny=False,\n    )\n\n    # Both plugins should contribute results with their parameters successfully bound\n    plugin_one_rows = [\n        r for r in rows if r.get(\"reason\") and \"Plugin one\" in r[\"reason\"]\n    ]\n    plugin_two_rows = [\n        r for r in rows if r.get(\"reason\") and \"Plugin two\" in r[\"reason\"]\n    ]\n\n    assert len(plugin_one_rows) > 0, \"Plugin one should contribute rules\"\n    assert len(plugin_two_rows) > 0, \"Plugin two should contribute rules\"\n\n    # Verify each plugin's parameters were successfully bound in the SQL\n    assert any(\n        \"value1\" in r.get(\"reason\", \"\") for r in plugin_one_rows\n    ), \"Plugin one's :plugin1_param should be bound\"\n    assert any(\n        \"value2\" in r.get(\"reason\", \"\") for r in plugin_two_rows\n    ), \"Plugin two's :plugin2_param should be bound\"\n"
  },
  {
    "path": "tests/test_write_wrapper.py",
    "content": "\"\"\"\nTests for the write_wrapper plugin hook.\n\"\"\"\n\nfrom datasette.app import Datasette\nfrom datasette.hookspecs import hookimpl\nfrom datasette.plugins import pm\nimport pytest\nimport sqlite3\nimport time\n\n\n@pytest.fixture\ndef datasette(tmp_path):\n    db_path = str(tmp_path / \"test.db\")\n    ds = Datasette([db_path])\n    return ds\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"use_execute_write\",\n    (False, True),\n    ids=[\"execute_write_fn\", \"execute_write\"],\n)\nasync def test_write_wrapper_before_and_after(datasette, use_execute_write):\n    \"\"\"Test that code before and after yield both execute.\"\"\"\n    log = []\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                log.append(\"before\")\n                yield\n                log.append(\"after\")\n\n            return wrapper\n\n    pm.register(Plugin(), name=\"test_before_after\")\n    try:\n        db = datasette.get_database(\"test\")\n        if use_execute_write:\n            await db.execute_write(\n                \"create table if not exists t (id integer primary key)\"\n            )\n        else:\n            await db.execute_write_fn(\n                lambda conn: conn.execute(\n                    \"create table if not exists t (id integer primary key)\"\n                )\n            )\n        assert log == [\"before\", \"after\"]\n    finally:\n        pm.unregister(name=\"test_before_after\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_receives_result_via_yield(datasette):\n    \"\"\"Test that the result of fn(conn) is sent back through yield.\"\"\"\n    captured = {}\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                result = yield\n                captured[\"result\"] = result\n\n            return wrapper\n\n    pm.register(Plugin(), name=\"test_result\")\n    try:\n        db = datasette.get_database(\"test\")\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\n                \"create table if not exists t2 (id integer primary key)\"\n            )\n        )\n        assert \"result\" in captured\n        # Should be a sqlite3 Cursor\n        assert captured[\"result\"] is not None\n    finally:\n        pm.unregister(name=\"test_result\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_exception_thrown_into_generator(datasette):\n    \"\"\"Test that exceptions from fn(conn) are thrown into the generator.\"\"\"\n    caught = {}\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                try:\n                    yield\n                except Exception as e:\n                    caught[\"error\"] = e\n\n            return wrapper\n\n    pm.register(Plugin(), name=\"test_exception\")\n    try:\n        db = datasette.get_database(\"test\")\n        with pytest.raises(Exception, match=\"deliberate\"):\n            await db.execute_write_fn(\n                lambda conn: (_ for _ in ()).throw(Exception(\"deliberate\"))\n            )\n        assert \"error\" in caught\n        assert str(caught[\"error\"]) == \"deliberate\"\n    finally:\n        pm.unregister(name=\"test_exception\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_conn_is_usable(datasette):\n    \"\"\"Test that the conn passed to the wrapper can execute SQL.\"\"\"\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                conn.execute(\"create table if not exists hook_log (msg text)\")\n                conn.execute(\"insert into hook_log values ('before')\")\n                yield\n                conn.execute(\"insert into hook_log values ('after')\")\n\n            return wrapper\n\n    pm.register(Plugin(), name=\"test_conn\")\n    try:\n        db = datasette.get_database(\"test\")\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\n                \"create table if not exists t3 (id integer primary key)\"\n            )\n        )\n        result = await db.execute(\"select msg from hook_log order by rowid\")\n        messages = [row[0] for row in result.rows]\n        assert messages == [\"before\", \"after\"]\n    finally:\n        pm.unregister(name=\"test_conn\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_multiple_plugins_nest(datasette):\n    \"\"\"Test that multiple write_wrapper plugins nest correctly.\"\"\"\n    log = []\n\n    class PluginA:\n        __name__ = \"PluginA\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                log.append(\"A-before\")\n                yield\n                log.append(\"A-after\")\n\n            return wrapper\n\n    class PluginB:\n        __name__ = \"PluginB\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            def wrapper(conn):\n                log.append(\"B-before\")\n                yield\n                log.append(\"B-after\")\n\n            return wrapper\n\n    pm.register(PluginA(), name=\"PluginA\")\n    pm.register(PluginB(), name=\"PluginB\")\n    try:\n        db = datasette.get_database(\"test\")\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\n                \"create table if not exists t4 (id integer primary key)\"\n            )\n        )\n        assert set(log) == {\"A-before\", \"A-after\", \"B-before\", \"B-after\"}\n        # Verify proper nesting: each plugin's before/after should be\n        # symmetric around the write\n        a_before = log.index(\"A-before\")\n        a_after = log.index(\"A-after\")\n        b_before = log.index(\"B-before\")\n        b_after = log.index(\"B-after\")\n        if a_before < b_before:\n            assert a_after > b_after, \"A is outer so A-after should come after B-after\"\n        else:\n            assert b_after > a_after, \"B is outer so B-after should come after A-after\"\n    finally:\n        pm.unregister(name=\"PluginA\")\n        pm.unregister(name=\"PluginB\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_return_none_skips(datasette):\n    \"\"\"Test that returning None from write_wrapper means no wrapping.\"\"\"\n    log = []\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            log.append(\"hook-called\")\n            return None\n\n    pm.register(Plugin(), name=\"test_skip\")\n    try:\n        db = datasette.get_database(\"test\")\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\n                \"create table if not exists t5 (id integer primary key)\"\n            )\n        )\n        assert log == [\"hook-called\"]\n    finally:\n        pm.unregister(name=\"test_skip\")\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"request_value,transaction_value,expected_request,expected_transaction\",\n    (\n        (\"fake-request\", True, \"fake-request\", True),\n        (None, True, None, True),\n        (None, False, None, False),\n    ),\n    ids=[\"with-request\", \"request-none-by-default\", \"transaction-false\"],\n)\nasync def test_write_wrapper_hook_parameters(\n    datasette,\n    request_value,\n    transaction_value,\n    expected_request,\n    expected_transaction,\n):\n    \"\"\"Test that request and transaction parameters are passed through.\"\"\"\n    captured = {}\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            captured[\"request\"] = request\n            captured[\"database\"] = database\n            captured[\"transaction\"] = transaction\n\n    pm.register(Plugin(), name=\"test_params\")\n    try:\n        db = datasette.get_database(\"test\")\n        kwargs = {\"transaction\": transaction_value}\n        if request_value is not None:\n            kwargs[\"request\"] = request_value\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\n                \"create table if not exists t6 (id integer primary key)\"\n            ),\n            **kwargs,\n        )\n        assert captured[\"request\"] == expected_request\n        assert captured[\"database\"] == \"test\"\n        assert captured[\"transaction\"] == expected_transaction\n    finally:\n        pm.unregister(name=\"test_params\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_via_api(tmp_path):\n    \"\"\"Test that write_wrapper fires for API write operations.\"\"\"\n    log = []\n\n    db_path = str(tmp_path / \"test.db\")\n    ds = Datasette([db_path], pdb=False)\n    ds.root_enabled = True\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            if database != \"test\":\n                return None\n\n            def wrapper(conn):\n                log.append(\"before\")\n                yield\n                log.append(\"after\")\n\n            return wrapper\n\n    pm.register(Plugin(), name=\"test_api\")\n    try:\n        db = ds.get_database(\"test\")\n        await db.execute_write(\n            \"create table if not exists api_test (id integer primary key, name text)\"\n        )\n        log.clear()\n\n        token = \"dstok_{}\".format(\n            ds.sign(\n                {\"a\": \"root\", \"token\": \"dstok\", \"t\": int(time.time())},\n                namespace=\"token\",\n            )\n        )\n        response = await ds.client.post(\n            \"/test/api_test/-/insert\",\n            json={\"row\": {\"name\": \"test\"}, \"return\": True},\n            headers={\n                \"Authorization\": \"Bearer {}\".format(token),\n                \"Content-Type\": \"application/json\",\n            },\n        )\n        assert response.status_code == 201, response.json()\n        assert log == [\"before\", \"after\"]\n    finally:\n        pm.unregister(name=\"test_api\")\n\n\n@pytest.mark.asyncio\nasync def test_write_wrapper_change_group_pattern(datasette):\n    \"\"\"Test the motivating use case: activating a change group around a write.\"\"\"\n    db = datasette.get_database(\"test\")\n\n    await db.execute_write(\n        \"create table if not exists groups (id integer primary key, current integer)\"\n    )\n    await db.execute_write(\n        \"create table if not exists data (id integer primary key, value text)\"\n    )\n    await db.execute_write(\"insert into groups (id, current) values (1, null)\")\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            if request and getattr(request, \"group_id\", None):\n                group_id = request.group_id\n\n                def wrapper(conn):\n                    conn.execute(\n                        \"update groups set current = 1 where id = ?\", [group_id]\n                    )\n                    yield\n                    conn.execute(\"update groups set current = null where current = 1\")\n\n                return wrapper\n\n    pm.register(Plugin(), name=\"test_change_group\")\n    try:\n\n        class FakeRequest:\n            group_id = 1\n\n        await db.execute_write_fn(\n            lambda conn: conn.execute(\"insert into data (value) values ('test')\"),\n            request=FakeRequest(),\n        )\n\n        result = await db.execute(\"select current from groups where id = 1\")\n        assert result.rows[0][0] is None\n    finally:\n        pm.unregister(name=\"test_change_group\")\n\n\nWRITE_ACTIONS = (\n    sqlite3.SQLITE_INSERT,\n    sqlite3.SQLITE_UPDATE,\n    sqlite3.SQLITE_DELETE,\n)\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\n    \"actor,table,should_deny\",\n    (\n        (None, \"protected_table\", True),\n        ({\"id\": \"regular\"}, \"protected_table\", True),\n        ({\"id\": \"admin\"}, \"protected_table\", False),\n        (None, \"other_table\", False),\n        ({\"id\": \"regular\"}, \"other_table\", False),\n    ),\n    ids=[\n        \"no-actor-protected\",\n        \"regular-user-protected\",\n        \"admin-protected\",\n        \"no-actor-other\",\n        \"regular-user-other\",\n    ],\n)\nasync def test_write_wrapper_set_authorizer(datasette, actor, table, should_deny):\n    \"\"\"Test the docs example that uses set_authorizer to block writes to a protected table.\"\"\"\n    db = datasette.get_database(\"test\")\n    await db.execute_write(\n        \"create table if not exists protected_table (id integer primary key, value text)\"\n    )\n    await db.execute_write(\n        \"create table if not exists other_table (id integer primary key, value text)\"\n    )\n\n    class Plugin:\n        __name__ = \"Plugin\"\n\n        @staticmethod\n        @hookimpl\n        def write_wrapper(datasette, database, request, transaction):\n            actor = None\n            if request:\n                actor = request.actor\n            if actor and actor.get(\"id\") == \"admin\":\n                return None\n\n            def wrapper(conn):\n                def authorizer(action, arg1, arg2, db_name, trigger):\n                    if action in WRITE_ACTIONS and arg1 == \"protected_table\":\n                        return sqlite3.SQLITE_DENY\n                    return sqlite3.SQLITE_OK\n\n                conn.set_authorizer(authorizer)\n                try:\n                    yield\n                finally:\n                    conn.set_authorizer(lambda *args: sqlite3.SQLITE_OK)\n\n            return wrapper\n\n    class FakeRequest:\n        def __init__(self, actor):\n            self.actor = actor\n\n    pm.register(Plugin(), name=\"test_set_authorizer\")\n    try:\n        request = FakeRequest(actor)\n        if should_deny:\n            with pytest.raises(Exception):\n                await db.execute_write_fn(\n                    lambda conn: conn.execute(\n                        f\"insert into {table} (value) values ('test')\"\n                    ),\n                    request=request,\n                )\n        else:\n            await db.execute_write_fn(\n                lambda conn: conn.execute(\n                    f\"insert into {table} (value) values ('test')\"\n                ),\n                request=request,\n            )\n            result = await db.execute(\n                f\"select value from {table} order by rowid desc limit 1\"\n            )\n            assert result.rows[0][0] == \"test\"\n    finally:\n        pm.unregister(name=\"test_set_authorizer\")\n"
  },
  {
    "path": "tests/utils.py",
    "content": "from datasette.utils.sqlite import sqlite3\n\n\ndef last_event(datasette):\n    events = getattr(datasette, \"_tracked_events\", [])\n    return events[-1] if events else None\n\n\ndef assert_footer_links(soup):\n    footer_links = soup.find(\"footer\").find_all(\"a\")\n    assert 4 == len(footer_links)\n    datasette_link, license_link, source_link, about_link = footer_links\n    assert \"Datasette\" == datasette_link.text.strip()\n    assert \"tests/fixtures.py\" == source_link.text.strip()\n    assert \"Apache License 2.0\" == license_link.text.strip()\n    assert \"About Datasette\" == about_link.text.strip()\n    assert \"https://datasette.io/\" == datasette_link[\"href\"]\n    assert (\n        \"https://github.com/simonw/datasette/blob/main/tests/fixtures.py\"\n        == source_link[\"href\"]\n    )\n    assert (\n        \"https://github.com/simonw/datasette/blob/main/LICENSE\" == license_link[\"href\"]\n    )\n    assert \"https://github.com/simonw/datasette\" == about_link[\"href\"]\n\n\ndef inner_html(soup):\n    html = str(soup)\n    # This includes the parent tag - so remove that\n    inner_html = html.split(\">\", 1)[1].rsplit(\"<\", 1)[0]\n    return inner_html.strip()\n\n\ndef has_load_extension():\n    conn = sqlite3.connect(\":memory:\")\n    return hasattr(conn, \"enable_load_extension\")\n\n\ndef cookie_was_deleted(response, cookie):\n    return any(\n        h\n        for h in response.headers.get_list(\"set-cookie\")\n        if h.startswith(f'{cookie}=\"\";')\n    )\n"
  }
]