diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6ca0fac8..00000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -omit = datasette/_version.py, datasette/utils/shutil_backport.py diff --git a/.dockerignore b/.dockerignore index 5078bf47..2c34db66 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,13 @@ .DS_Store .cache .eggs +.git .gitignore .ipynb_checkpoints +.travis.yml build *.spec *.egg-info dist scratchpad venv -*.db -*.sqlite diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 84e574fd..00000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,4 +0,0 @@ -# Applying Black -35d6ee2790e41e96f243c1ff58be0c9c0519a8ce -368638555160fb9ac78f462d0f79b1394163fa30 -2b344f6a34d2adaa305996a1a580ece06397f6e4 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 744258eb..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -datasette/static/codemirror-* linguist-vendored diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index f0bcdbe0..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [simonw] diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 88bb03b1..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - time: "13:00" - groups: - python-packages: - patterns: - - "*" diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml deleted file mode 100644 index b0640ae8..00000000 --- a/.github/workflows/deploy-latest.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Deploy latest.datasette.io - -on: - workflow_dispatch: - push: - branches: - - main - # - 1.0-dev - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Check out datasette - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - cache: pip - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install . --group dev - python -m pip install sphinx-to-sqlite==0.1a1 - - name: Run tests - if: ${{ github.ref == 'refs/heads/main' }} - run: | - pytest -n auto -m "not serial" - pytest -m "serial" - - name: Build fixtures.db and other files needed to deploy the demo - run: |- - python tests/fixtures.py \ - fixtures.db \ - fixtures-config.json \ - fixtures-metadata.json \ - plugins \ - --extra-db-filename extra_database.db - - name: Build docs.db - if: ${{ github.ref == 'refs/heads/main' }} - run: |- - cd docs - DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build - sphinx-to-sqlite ../docs.db _build - cd .. - - name: Set up the alternate-route demo - run: | - echo ' - from datasette import hookimpl - - @hookimpl - def startup(datasette): - db = datasette.get_database("fixtures2") - db.route = "alternative-route" - ' > plugins/alternative_route.py - cp fixtures.db fixtures2.db - - name: And the counters writable stored query demo - run: | - cat > plugins/counters.py < metadata.json - # cat metadata.json - - id: auth - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - credentials_json: ${{ secrets.GCP_SA_KEY }} - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v3 - - name: Deploy to Cloud Run - env: - LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }} - run: |- - gcloud config set run/region us-central1 - gcloud config set project datasette-222320 - export SUFFIX="-${GITHUB_REF#refs/heads/}" - export SUFFIX=${SUFFIX#-main} - # Replace 1.0 with one-dot-zero in SUFFIX - export SUFFIX=${SUFFIX//1.0/one-dot-zero} - datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \ - -m fixtures-metadata.json \ - --plugins-dir=plugins \ - --branch=$GITHUB_SHA \ - --version-note=$GITHUB_SHA \ - --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \ - --install 'datasette-ephemeral-tables>=0.2.2' \ - --service "datasette-latest$SUFFIX" \ - --secret $LATEST_DATASETTE_SECRET - - name: Deploy to docs as well (only for main) - if: ${{ github.ref == 'refs/heads/main' }} - run: |- - # Deploy docs.db to a different service - datasette publish cloudrun docs.db \ - --branch=$GITHUB_SHA \ - --version-note=$GITHUB_SHA \ - --extra-options="--setting template_debug 1" \ - --service=datasette-docs-latest diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml deleted file mode 100644 index b8fb8aaa..00000000 --- a/.github/workflows/documentation-links.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Read the Docs Pull Request Preview -on: - pull_request: - types: - - opened - -permissions: - pull-requests: write - -jobs: - documentation-links: - runs-on: ubuntu-latest - steps: - - uses: readthedocs/actions/preview@v1 - with: - project-slug: "datasette" diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml deleted file mode 100644 index 735e14e9..00000000 --- a/.github/workflows/prettier.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Check JavaScript for conformance with Prettier - -on: [push] - -permissions: - contents: read - -jobs: - prettier: - runs-on: ubuntu-latest - steps: - - name: Check out repo - uses: actions/checkout@v6 - - uses: actions/cache@v5 - name: Configure npm caching - with: - path: ~/.npm - key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.OS }}-npm- - - name: Install dependencies - run: npm ci - - name: Run prettier - run: |- - npm run prettier -- --check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 87300593..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Publish Python Package - -on: - release: - types: [created] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - steps: - - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - cache: pip - cache-dependency-path: pyproject.toml - - name: Install dependencies - run: | - pip install . --group dev - - name: Run tests - run: | - pytest - - deploy: - runs-on: ubuntu-latest - needs: [test] - environment: release - permissions: - id-token: write - steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.13' - cache: pip - cache-dependency-path: pyproject.toml - - name: Install dependencies - run: | - pip install setuptools wheel build - - name: Build - run: | - python -m build - - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 - - deploy_static_docs: - runs-on: ubuntu-latest - needs: [deploy] - if: "!github.event.release.prerelease" - steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.10' - cache: pip - cache-dependency-path: pyproject.toml - - name: Install dependencies - run: | - python -m pip install . --group dev - python -m pip install sphinx-to-sqlite==0.1a1 - - name: Build docs.db - run: |- - cd docs - DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build - sphinx-to-sqlite ../docs.db _build - cd .. - - id: auth - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY }} - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v3 - - name: Deploy stable-docs.datasette.io to Cloud Run - run: |- - gcloud config set run/region us-central1 - gcloud config set project datasette-222320 - datasette publish cloudrun docs.db \ - --service=datasette-docs-stable - - deploy_docker: - runs-on: ubuntu-latest - needs: [deploy] - if: "!github.event.release.prerelease" - steps: - - uses: actions/checkout@v6 - - name: Build and push to Docker Hub - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASS: ${{ secrets.DOCKER_PASS }} - run: |- - sleep 60 # Give PyPI time to make the new release available - docker login -u $DOCKER_USER -p $DOCKER_PASS - export REPO=datasetteproject/datasette - docker build -f Dockerfile \ - -t $REPO:${GITHUB_REF#refs/tags/} \ - --build-arg VERSION=${GITHUB_REF#refs/tags/} . - docker tag $REPO:${GITHUB_REF#refs/tags/} $REPO:latest - docker push $REPO:${GITHUB_REF#refs/tags/} - docker push $REPO:latest diff --git a/.github/workflows/push_docker_tag.yml b/.github/workflows/push_docker_tag.yml deleted file mode 100644 index e622ef4c..00000000 --- a/.github/workflows/push_docker_tag.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Push specific Docker tag - -on: - workflow_dispatch: - inputs: - version_tag: - description: Tag to build and push - -permissions: - contents: read - -jobs: - deploy_docker: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Build and push to Docker Hub - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASS: ${{ secrets.DOCKER_PASS }} - VERSION_TAG: ${{ github.event.inputs.version_tag }} - run: |- - docker login -u $DOCKER_USER -p $DOCKER_PASS - export REPO=datasetteproject/datasette - docker build -f Dockerfile \ - -t $REPO:${VERSION_TAG} \ - --build-arg VERSION=${VERSION_TAG} . - docker push $REPO:${VERSION_TAG} diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml deleted file mode 100644 index 9a808194..00000000 --- a/.github/workflows/spellcheck.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Check spelling in documentation - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - spellcheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: '**/pyproject.toml' - - name: Install dependencies - run: | - pip install . --group dev - - name: Check spelling - run: | - codespell README.md --ignore-words docs/codespell-ignore-words.txt - codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt - codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt - codespell tests --ignore-words docs/codespell-ignore-words.txt diff --git a/.github/workflows/stable-docs.yml b/.github/workflows/stable-docs.yml deleted file mode 100644 index 59b5fbc0..00000000 --- a/.github/workflows/stable-docs.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Update Stable Docs - -on: - release: - types: [published] - push: - branches: - - main - -permissions: - contents: write - -jobs: - update_stable_docs: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 # We need all commits to find docs/ changes - - name: Set up Git user - run: | - git config user.name "Automated" - git config user.email "actions@users.noreply.github.com" - - name: Create stable branch if it does not yet exist - run: | - if ! git ls-remote --heads origin stable | grep -qE '\bstable\b'; then - # Make sure we have all tags locally - git fetch --tags --quiet - - # Latest tag that is just numbers and dots (optionally prefixed with 'v') - # e.g., 0.65.2 or v0.65.2 — excludes 1.0a20, 1.0-rc1, etc. - LATEST_RELEASE=$( - git tag -l --sort=-v:refname \ - | grep -E '^v?[0-9]+(\.[0-9]+){1,3}$' \ - | head -n1 - ) - - git checkout -b stable - - # If there are any stable releases, copy docs/ from the most recent - if [ -n "$LATEST_RELEASE" ]; then - rm -rf docs/ - git checkout "$LATEST_RELEASE" -- docs/ || true - fi - - git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes" - git push -u origin stable - fi - - name: Handle Release - if: github.event_name == 'release' && !github.event.release.prerelease - run: | - git fetch --all - git checkout stable - git reset --hard ${GITHUB_REF#refs/tags/} - git push origin stable --force - - name: Handle Commit to Main - if: contains(github.event.head_commit.message, '!stable-docs') - run: | - git fetch origin - git checkout -b stable origin/stable - # Get the list of modified files in docs/ from the current commit - FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/) - # Check if the list of files is non-empty - if [[ -n "$FILES" ]]; then - # Checkout those files to the stable branch to over-write with their contents - for FILE in $FILES; do - git checkout ${{ github.sha }} -- $FILE - done - git add docs/ - git commit -m "Doc changes from ${{ github.sha }}" - git push origin stable - else - echo "No changes to docs/ in this commit." - exit 0 - fi diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml deleted file mode 100644 index c514048e..00000000 --- a/.github/workflows/test-coverage.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Calculate test coverage - -on: - push: - branches: - - main - pull_request: - branches: - - main -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Check out datasette - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - cache: 'pip' - cache-dependency-path: '**/pyproject.toml' - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install . --group dev - python -m pip install pytest-cov - - name: Run tests - run: |- - ls -lah - cat .coveragerc - pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x - ls -lah - - name: Upload coverage report - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: coverage.xml diff --git a/.github/workflows/test-pyodide.yml b/.github/workflows/test-pyodide.yml deleted file mode 100644 index 5162c47a..00000000 --- a/.github/workflows/test-pyodide.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Test in Pyodide with shot-scraper - -on: - push: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up Python 3.10 - uses: actions/setup-python@v6 - with: - python-version: "3.10" - cache: 'pip' - cache-dependency-path: '**/pyproject.toml' - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright/ - key: ${{ runner.os }}-browsers - - name: Install Playwright dependencies - run: | - pip install shot-scraper build - shot-scraper install - - name: Run test - run: | - ./test-in-pyodide-with-shot-scraper.sh diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml deleted file mode 100644 index 23fce459..00000000 --- a/.github/workflows/test-sqlite-support.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Test SQLite versions - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - test: - runs-on: ${{ matrix.platform }} - continue-on-error: true - strategy: - matrix: - platform: [ubuntu-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - sqlite-version: [ - #"3", # latest version - "3.46", - #"3.45", - #"3.27", - #"3.26", - "3.25", - #"3.25.3", # 2018-09-25, window functions breaks test_upsert for some reason on 3.10, skip for now - #"3.24", # 2018-06-04, added UPSERT support - #"3.23.1" # 2018-04-10, before UPSERT - ] - steps: - - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - cache: pip - cache-dependency-path: pyproject.toml - - name: Set up SQLite ${{ matrix.sqlite-version }} - uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6 - with: - version: ${{ matrix.sqlite-version }} - cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1" - - run: python3 -c "import sqlite3; print(sqlite3.sqlite_version)" - - run: echo $LD_LIBRARY_PATH - - name: Build extension for --load-extension test - run: |- - (cd tests && gcc ext.c -fPIC -shared -o ext.so) - - name: Install dependencies - run: | - pip install . --group dev - pip freeze - - name: Run tests - run: | - pytest -n auto -m "not serial" - pytest -m "serial" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index a1b2e9d2..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Test - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - steps: - - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - cache: pip - cache-dependency-path: pyproject.toml - - name: Build extension for --load-extension test - run: |- - (cd tests && gcc ext.c -fPIC -shared -o ext.so) - - name: Install dependencies - run: | - pip install . --group dev - pip freeze - - name: Run tests - run: | - pytest -n auto -m "not serial" - pytest -m "serial" - # And the test that exceeds a localhost HTTPS server - tests/test_datasette_https_server.sh - - name: Black - run: | - black --version - black --check . - - name: Ruff - run: ruff check datasette tests - - name: Check if cog needs to be run - run: | - cog --check docs/*.rst - - name: Check if blacken-docs needs to be run - run: | - # This fails on syntax errors, or a diff was applied - blacken-docs -l 60 docs/*.rst - - name: Test DATASETTE_LOAD_PLUGINS - run: | - pip install datasette-init datasette-json-html - tests/test-datasette-load-plugins.sh diff --git a/.github/workflows/tmate-mac.yml b/.github/workflows/tmate-mac.yml deleted file mode 100644 index a033cd92..00000000 --- a/.github/workflows/tmate-mac.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: tmate session mac - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: macos-latest - steps: - - uses: actions/checkout@v6 - - name: Setup tmate session - uses: mxschmitt/action-tmate@v3 diff --git a/.github/workflows/tmate.yml b/.github/workflows/tmate.yml deleted file mode 100644 index 72af1eec..00000000 --- a/.github/workflows/tmate.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: tmate session - -on: - workflow_dispatch: - -permissions: - contents: read - models: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Setup tmate session - uses: mxschmitt/action-tmate@v3 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 12acd87e..cf8578ee 100644 --- a/.gitignore +++ b/.gitignore @@ -3,20 +3,9 @@ datasets.json scratchpad -.vscode - -uv.lock -data.db - -# test databases +# SQLite databases *.db - -# We don't use Pipfile, so ignore them -Pipfile -Pipfile.lock - -fixtures.db -*test.db +*.sqlite # Byte-compiled / optimized / DLL files __pycache__/ @@ -122,13 +111,3 @@ ENV/ # macOS files .DS_Store -node_modules -.*.swp - -# In case someone compiled tests/ext.c for test_load_extensions, don't -# include it in source control. -tests/*.dylib -tests/*.so -tests/*.dll - -.idea \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 0cece53b..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -multi_line_output=3 - diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 222861c3..00000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "tabWidth": 2, - "useTabs": false -} diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 8b3e54aa..00000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 - -sphinx: - configuration: docs/conf.py - -build: - os: ubuntu-24.04 - tools: - python: "3.13" - jobs: - install: - - pip install --upgrade pip - - pip install . --group dev - -formats: -- pdf -- epub diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..6cbbfac0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: python + +python: + - 3.5 + - 3.6 + +script: + - python setup.py test + +deploy: + provider: pypi + user: simonw + distributions: bdist_wheel + password: + secure: Rx6Wl+a2UL+Nr2QQsbtQtrfsZQoC4LmXpDtWyOxFs76aPyC2JmJOb7wYxkmG703LxzZNuUuU9+4WxuA5+/JbyLwT1oQFUBtRfpKIroFa1NKKqQss1RbR+feLufcLT5dJNSkQb+xiKmep5pZnVbRNbmC4z0SFO4/5LJfegSi0pXBbV4IclE7kXSMZS4bYFAb6n9YGfFvwj0o7/GYD6P9MaNwuMqtVsYWZK/S0Bt4wthNc29av8R/jI3++MC3bXs9KC18q5s3x761rWQe/P/u2J++wufqWw9TyrtSVkvQMsaweBuo+0iY4Uiv8m5rPI5xT5rvLC8jdbLnj1FY5xphohEl3/H5vvmW7zBhBFvCSFSirXBkB7fZyX6uoVfe81vNzH/GGNwEi/3E4SVuUk/v2Fm4LBIXfg23O0Gl8aUZnhvcvL5eImadCRCy+dOhLkX9QO7iS6Bu+jjzu5yfqqoIQC96uB9HDn8OZLZ/9W4PlISc1+ARyoCEqeh33mB5IQN41XEbSLbW2STvPS0HYt8mffDDgv/YXjdxUjceGqsUYmksk/6feq2oOdBgwYG1HhOA9dlG7xQsWPCXsaDIZ2olnXnz8AIsZN7jtJiJoUWUF+ZlwLL8zA/wNqLoyohgsKa0Ne4BEpa42XVmhkoWMbl5sJfQzmjS/pQNc9hkv0n1UKYY= + on: + tags: true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 14d4c567..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -`swillison+datasette-code-of-conduct@gmail.com`. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile index 9a8f06cf..04c08d2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,21 @@ -FROM python:3.11.0-slim-bullseye as build +FROM python:3.6-slim-stretch as build -# Version of Datasette to install, e.g. 0.55 -# docker build . -t datasette --build-arg VERSION=0.55 -ARG VERSION +# Setup build dependencies +RUN apt update +RUN apt install -y python3-dev gcc libsqlite3-mod-spatialite +# Add local code to the image instead of fetching from pypi. +ADD . /datasette -RUN apt-get update && \ - apt-get install -y --no-install-recommends libsqlite3-mod-spatialite && \ - apt clean && \ - rm -rf /var/lib/apt && \ - rm -rf /var/lib/dpkg/info/* +RUN pip install /datasette -RUN pip install https://github.com/simonw/datasette/archive/refs/tags/${VERSION}.zip && \ - find /usr/local/lib -name '__pycache__' | xargs rm -r && \ - rm -rf /root/.cache/pip +FROM python:3.6-slim-stretch + +# Copy python dependencies +COPY --from=build /usr/local/lib/python3.6/site-packages /usr/local/lib/python3.6/site-packages +# Copy executables +COPY --from=build /usr/local/bin /usr/local/bin +# Copy spatial extensions +COPY --from=build /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu EXPOSE 8001 CMD ["datasette"] diff --git a/Justfile b/Justfile deleted file mode 100644 index 657881be..00000000 --- a/Justfile +++ /dev/null @@ -1,60 +0,0 @@ -export DATASETTE_SECRET := "not_a_secret" - -# Run tests and linters -@default: test lint - -# Setup project -@init: - uv sync - -# Run pytest with supplied options -@test *options: init - uv run pytest -n auto {{options}} - -@codespell: - uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt - uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt - uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt - uv run codespell tests --ignore-words docs/codespell-ignore-words.txt - -# Run linters: black, ruff, cog -@lint: codespell - uv run black datasette tests --check - uv run ruff check datasette tests - uv run cog --check README.md docs/*.rst - -# Apply ruff fixes -@fix: - uv run ruff check --fix datasette tests - -# Rebuild docs with cog -@cog: - uv run cog -r README.md docs/*.rst - -# Serve live docs on localhost:8000 -@docs: cog blacken-docs - uv run make -C docs livehtml - -# Build docs as static HTML -@docs-build: cog blacken-docs - rm -rf docs/_build && cd docs && uv run make html - -# Apply Black -@black: - uv run black datasette tests - -# Apply blacken-docs -@blacken-docs: - uv run blacken-docs -l 60 docs/*.rst - -# Apply prettier -@prettier: - npm run fix - -# Format code with both black and prettier -@format: black prettier blacken-docs - -@serve *options: - uv run sqlite-utils create-database data.db - uv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore - uv run python -m datasette data.db --root --reload {{options}} diff --git a/MANIFEST.in b/MANIFEST.in index 8c5e3ee6..696c1146 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1 @@ recursive-include datasette/static * -recursive-include datasette/templates * -include versioneer.py -include datasette/_version.py -include LICENSE diff --git a/README.md b/README.md index 393e8e5c..579fe843 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,20 @@ -Datasette +# datasette -[![PyPI](https://img.shields.io/pypi/v/datasette.svg)](https://pypi.org/project/datasette/) -[![Changelog](https://img.shields.io/github/v/release/simonw/datasette?label=changelog)](https://docs.datasette.io/en/latest/changelog.html) -[![Python 3.x](https://img.shields.io/pypi/pyversions/datasette.svg?logo=python&logoColor=white)](https://pypi.org/project/datasette/) -[![Tests](https://github.com/simonw/datasette/workflows/Test/badge.svg)](https://github.com/simonw/datasette/actions?query=workflow%3ATest) -[![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette/blob/main/LICENSE) -[![docker: datasette](https://img.shields.io/badge/docker-datasette-blue)](https://hub.docker.com/r/datasetteproject/datasette) -[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord) +[![PyPI](https://img.shields.io/pypi/v/datasette.svg)](https://pypi.python.org/pypi/datasette) +[![Travis CI](https://travis-ci.org/simonw/datasette.svg?branch=master)](https://travis-ci.org/simonw/datasette) +[![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](http://datasette.readthedocs.io/en/latest/?badge=latest) -*An open source multi-tool for exploring and publishing data* +*An instant JSON API for your SQLite databases* -Datasette 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. +Datasette provides an instant, read-only JSON API for any SQLite database. It also provides tools for packaging the database up as a Docker container and deploying that container to hosting providers such as [Zeit Now](https://zeit.co/now). -Datasette 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. - -[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). - -* [datasette.io](https://datasette.io/) is the official project website -* Latest [Datasette News](https://datasette.io/news) -* Comprehensive documentation: https://docs.datasette.io/ -* Examples: https://datasette.io/examples -* Live demo of current `main` branch: https://latest.datasette.io/ -* Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord) - -Want 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. +Some examples: https://github.com/simonw/datasette/wiki/Datasettes ## Installation -If you are on a Mac, [Homebrew](https://brew.sh/) is the easiest way to install Datasette: + pip3 install datasette - brew install datasette - -You can also install it using `pip` or `pipx`: - - pip install datasette - -Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker. +Datasette requires Python 3.5 or higher. ## Basic usage @@ -48,12 +26,83 @@ This will start a web server on port 8001 - visit http://localhost:8001/ to acce Use Chrome on OS X? You can run datasette against your browser history like so: - datasette ~/Library/Application\ Support/Google/Chrome/Default/History --nolock + datasette ~/Library/Application\ Support/Google/Chrome/Default/History Now visiting http://localhost:8001/History/downloads will show you a web interface to browse your downloads data: ![Downloads table rendered by datasette](https://static.simonwillison.net/static/2017/datasette-downloads.png) +http://localhost:8001/History/downloads.json will return that data as JSON: + + { + "database": "History", + "columns": [ + "id", + "current_path", + "target_path", + "start_time", + "received_bytes", + "total_bytes", + ... + ], + "table_rows": 576, + "rows": [ + [ + 1, + "/Users/simonw/Downloads/DropboxInstaller.dmg", + "/Users/simonw/Downloads/DropboxInstaller.dmg", + 13097290269022132, + 626688, + 0, + ... + ] + ] + } + + +http://localhost:8001/History/downloads.jsono will return that data as JSON in a more convenient but less efficient format: + + { + ... + "rows": [ + { + "start_time": 13097290269022132, + "interrupt_reason": 0, + "hash": "", + "id": 1, + "site_url": "", + "referrer": "https://www.dropbox.com/downloading?src=index", + ... + } + ] + } + +## datasette serve options + + $ datasette serve --help + Usage: datasette serve [OPTIONS] [FILES]... + + Serve up specified SQLite database files with a web UI + + Options: + -h, --host TEXT host for server, defaults to 127.0.0.1 + -p, --port INTEGER port for server, defaults to 8001 + --debug Enable debug mode - useful for development + --reload Automatically reload if code change detected - + useful for development + --cors Enable CORS by serving Access-Control-Allow- + Origin: * + --page_size INTEGER Page size - default is 100 + --max_returned_rows INTEGER Max allowed rows to return at once - default is + 1000. Set to 0 to disable check entirely. + --sql_time_limit_ms INTEGER Max time allowed for SQL queries in ms + --load-extension TEXT Path to a SQLite extension to load + --inspect-file TEXT Path to JSON file created using "datasette + build" + -m, --metadata FILENAME Path to JSON file containing license/source + metadata + --help Show this message and exit. + ## metadata.json If 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: @@ -66,26 +115,96 @@ If you want to include licensing and source information in the generated dataset "source_url": "https://github.com/fivethirtyeight/data" } -Save this in `metadata.json` and run Datasette like so: - - datasette serve fivethirtyeight.db -m metadata.json - The 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. ## datasette publish -If 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: +If you have [Zeit Now](https://zeit.co/now) installed, datasette can deploy one or more SQLite databases to the internet with a single command: - datasette publish heroku database.db + datasette publish now database.db -Or: +This will create a docker image containing both the datasette application and the specified SQLite database files. It will then deploy that image to Zeit Now and give you a URL to access the API. - datasette publish cloudrun database.db + $ datasette publish --help + Usage: datasette publish [OPTIONS] PUBLISHER [FILES]... -This 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. + Publish specified SQLite database files to the internet along with a + datasette API. -See [Publishing data](https://docs.datasette.io/en/stable/publish.html) in the documentation for more details. + Only current option for PUBLISHER is 'now'. You must have Zeit Now + installed: https://zeit.co/now -## Datasette Lite + Example usage: datasette publish now my-database.db -[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). + Options: + -n, --name TEXT Application name to use when deploying to Now + -m, --metadata FILENAME Path to JSON file containing metadata to publish + --extra-options TEXT Extra options to pass to datasette serve + --force Pass --force option to now + --branch TEXT Install datasette from a GitHub branch e.g. master + --title TEXT Title for metadata + --license TEXT License label for metadata + --license_url TEXT License URL for metadata + --source TEXT Source label for metadata + --source_url TEXT Source URL for metadata + --help Show this message and exit. + +## datasette package + +If you have docker installed you can use `datasette package` to create a new Docker image in your local repository containing the datasette app and selected SQLite databases: + + $ datasette package --help + Usage: datasette package [OPTIONS] FILES... + + Package specified SQLite files into a new datasette Docker container + + Options: + -t, --tag TEXT Name for the resulting Docker container, can + optionally use name:tag format + -m, --metadata FILENAME Path to JSON file containing metadata to publish + --extra-options TEXT Extra options to pass to datasette serve + --branch TEXT Install datasette from a GitHub branch e.g. master + --title TEXT Title for metadata + --license TEXT License label for metadata + --license_url TEXT License URL for metadata + --source TEXT Source label for metadata + --source_url TEXT Source URL for metadata + --help Show this message and exit. + +Both publish and package accept an `extra_options` argument option, which will affect how the resulting application is executed. For example, say you want to increase the SQL time limit for a particular container: + + datasette package parlgov.db --extra-options="--sql_time_limit_ms=2500 --page_size=10" + +The resulting container will run the application with those options. + +Here's example output for the package command: + + $ datasette package parlgov.db --extra-options="--sql_time_limit_ms=2500 --page_size=10" + Sending build context to Docker daemon 4.459MB + Step 1/7 : FROM python:3 + ---> 79e1dc9af1c1 + Step 2/7 : COPY . /app + ---> Using cache + ---> cd4ec67de656 + Step 3/7 : WORKDIR /app + ---> Using cache + ---> 139699e91621 + Step 4/7 : RUN pip install datasette + ---> Using cache + ---> 340efa82bfd7 + Step 5/7 : RUN datasette build parlgov.db --inspect-file inspect-data.json + ---> Using cache + ---> 5fddbe990314 + Step 6/7 : EXPOSE 8001 + ---> Using cache + ---> 8e83844b0fed + Step 7/7 : CMD datasette serve parlgov.db --port 8001 --inspect-file inspect-data.json --sql_time_limit_ms=2500 --page_size=10 + ---> Using cache + ---> 1bd380ea8af3 + Successfully built 1bd380ea8af3 + +You can now run the resulting container like so: + + docker run -p 8081:8001 1bd380ea8af3 + +This exposes port 8001 inside the container as port 8081 on your host machine, so you can access the application at http://localhost:8081/ diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index bfdc9877..00000000 --- a/codecov.yml +++ /dev/null @@ -1,8 +0,0 @@ -coverage: - status: - project: - default: - informational: true - patch: - default: - informational: true diff --git a/datasette/__init__.py b/datasette/__init__.py index eb18e59e..668a8c82 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,9 +1 @@ -from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa -from datasette.events import Event # noqa -from datasette.tokens import TokenHandler, TokenRestrictions # noqa -from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa -from datasette.utils import actor_matches_allow # noqa -from datasette.views import Context # noqa -from .hookspecs import hookimpl # noqa -from .hookspecs import hookspec # noqa diff --git a/datasette/__main__.py b/datasette/__main__.py deleted file mode 100644 index 4adef844..00000000 --- a/datasette/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from datasette.cli import cli - -if __name__ == "__main__": - cli() diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py deleted file mode 100644 index 103c616d..00000000 --- a/datasette/_pytest_plugin.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Pytest plugin that automatically closes any Datasette instances constructed -during a pytest test — both in the test body and in function-scoped -fixtures. Instances constructed by session-, module-, class- or package- -scoped fixtures are left alone, because other tests in the session will -still want to use them. - -Registered as a pytest11 entry point in pyproject.toml so that downstream -projects using Datasette get the same FD-safety net for their own tests. - -Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the -equivalent ini file). -""" - -from __future__ import annotations - -import contextvars -import weakref - -import pytest - -_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( - "datasette_active_instances", default=None -) - -_original_init = None - - -def _install_tracking(): - # datasette.app is imported lazily here rather than at module level: - # as a pytest11 entry point this module is imported during pytest - # startup, before pytest-cov starts measuring, so a module-level - # import would drag in all of datasette and make every import-time - # line in the package invisible to coverage - global _original_init - if _original_init is not None: - return - from datasette.app import Datasette - - _original_init = Datasette.__init__ - - def _tracking_init(self, *args, **kwargs): - _original_init(self, *args, **kwargs) - instances = _active_instances.get() - if instances is not None: - instances.append(weakref.ref(self)) - - Datasette.__init__ = _tracking_init - - -def pytest_configure(config): - if _enabled(config): - _install_tracking() - - -def pytest_addoption(parser): - parser.addini( - "datasette_autoclose", - help=( - "Automatically close Datasette instances created inside test " - "bodies and function-scoped fixtures (default: true)." - ), - default="true", - ) - - -def _enabled(config) -> bool: - value = config.getini("datasette_autoclose") - if isinstance(value, bool): - return value - return str(value).strip().lower() not in ("false", "0", "no", "off") - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item, nextitem): - """Track Datasette instances across setup, call and teardown; close at end.""" - if not _enabled(item.config): - yield - return - refs: list[weakref.ref] = [] - token = _active_instances.set(refs) - try: - yield - finally: - _active_instances.reset(token) - for ref in reversed(refs): - ds = ref() - if ds is None: - continue - try: - ds.close() - except Exception as e: - item.warn( - pytest.PytestUnraisableExceptionWarning( - f"Error closing Datasette instance: {e!r}" - ) - ) - - -@pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup(fixturedef, request): - """Exempt instances created by non-function-scoped fixtures. - - Session-, module-, class- and package-scoped fixtures produce Datasette - instances that must survive beyond the current test — other tests in - the session will still use them. When such a fixture creates one or - more Datasette instances during its setup, we snapshot the tracking - list before the fixture runs and subtract off any instances that were - added during its setup, so they don't get closed at test teardown. - """ - refs = _active_instances.get() - if refs is None: - yield - return - before_ids = {id(ref) for ref in refs} - yield - if fixturedef.scope != "function": - new_refs = [ref for ref in refs if id(ref) not in before_ids] - for new_ref in new_refs: - try: - refs.remove(new_ref) - except ValueError: - pass diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py deleted file mode 100644 index 368213af..00000000 --- a/datasette/actor_auth_cookie.py +++ /dev/null @@ -1,23 +0,0 @@ -from datasette import hookimpl -from itsdangerous import BadSignature -from datasette.utils import baseconv -import time - - -@hookimpl -def actor_from_request(datasette, request): - if "ds_actor" not in request.cookies: - return None - try: - decoded = datasette.unsign(request.cookies["ds_actor"], "actor") - # If it has "e" and "a" keys process the "e" expiry - if not isinstance(decoded, dict) or "a" not in decoded: - return None - expires_at = decoded.get("e") - if expires_at: - timestamp = int(baseconv.base62.decode(expires_at)) - if time.time() > timestamp: - return None - return decoded["a"] - except BadSignature: - return None diff --git a/datasette/app.py b/datasette/app.py index 9979b6c5..a6cfc29d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,3135 +1,893 @@ -from __future__ import annotations - -import asyncio -import contextvars -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence - -if TYPE_CHECKING: - from datasette.permissions import Resource - from datasette.tokens import TokenRestrictions -import collections -import dataclasses -import datetime -import functools -import glob -import hashlib -import httpx -import importlib.metadata -import inspect -from itsdangerous import BadSignature -import json -import os +from sanic import Sanic +from sanic import response +from sanic.exceptions import NotFound +from sanic.views import HTTPMethodView +from sanic.request import RequestParameters +from sanic_jinja2 import SanicJinja2 +from jinja2 import FileSystemLoader import re -import secrets -import sys -import threading -import time -import types -import urllib.parse -from concurrent import futures +import sqlite3 from pathlib import Path - -from markupsafe import Markup, escape -from itsdangerous import URLSafeSerializer -from jinja2 import ( - ChoiceLoader, - Environment, - FileSystemLoader, - PrefixLoader, -) -from jinja2.environment import Template -from jinja2.exceptions import TemplateNotFound - -from .events import Event -from .column_types import SQLiteType -from . import stored_queries, write_sql -from .views import Context -from .views.database import ( - database_download, - DatabaseView, - TableCreateView, - QueryView, -) -from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView -from .views.stored_queries import ( - QueryCreateAnalyzeView, - QueryDeleteView, - QueryDefinitionView, - QueryEditView, - GlobalQueryListView, - QueryListView, - QueryParametersView, - QueryStoreView, - QueryUpdateView, -) -from .views.index import IndexView -from .views.special import ( - JsonDataView, - PatternPortfolioView, - AuthTokenView, - ApiExplorerView, - CreateTokenView, - LogoutView, - AllowDebugView, - PermissionsDebugView, - MessagesDebugView, - AllowedResourcesView, - PermissionRulesView, - PermissionCheckView, - JumpView, - InstanceSchemaView, - DatabaseSchemaView, - TableSchemaView, -) -from .views.table import ( - TableInsertView, - TableUpsertView, - TableSetColumnTypeView, - TableDropView, - table_view, -) -from .views.row import RowView, RowDeleteView, RowUpdateView -from .renderer import json_renderer -from .url_builder import Urls -from .database import Database, QueryInterrupted - +from concurrent import futures +import asyncio +import threading +import urllib.parse +import json +import jinja2 +import hashlib +import time from .utils import ( - PaginatedResources, - PrefixedUrlString, - SPATIALITE_FUNCTIONS, - StartupError, - async_call_with_supported_arguments, - await_me_maybe, - baseconv, - call_with_supported_arguments, - detect_json1, - display_actor, + Filters, + compound_pks_from_path, + CustomJSONEncoder, + detect_fts_sql, escape_css_string, - escape_sqlite, - find_spatialite, - format_bytes, - module_from_path, - move_plugins_and_allow, - move_table_config, - parse_metadata, - resolve_env_secrets, - resolve_routes, - tilde_decode, - tilde_encode, - to_css_class, - urlsafe_components, - redact_keys, - row_sql_params_pks, + escape_sqlite_table_name, + filters_should_redirect, + get_all_foreign_keys, + InvalidSql, + path_from_row_pks, + path_with_added_args, + path_with_ext, + sqlite_timelimit, + validate_sql_select, ) -from .utils.asgi import ( - AsgiLifespan, - Forbidden, - NotFound, - DatabaseNotFound, - TableNotFound, - RowNotFound, - Request, - Response, - AsgiRunOnFirstRequest, - asgi_static, - asgi_send, - asgi_send_file, - asgi_send_redirect, -) -from .csrf import CrossOriginProtectionMiddleware -from .utils.internal_db import init_internal_db, populate_schema_tables -from .utils.sqlite import ( - sqlite3, - using_pysqlite3, -) -from .tracer import AsgiTracer -from .plugins import pm, DEFAULT_PLUGINS, get_plugins from .version import __version__ -from .resources import DatabaseResource, TableResource - app_root = Path(__file__).parent.parent +HASH_BLOCK_SIZE = 1024 * 1024 -# Context variable to track when code is executing within a datasette.client request -_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False) +connections = threading.local() -class _DatasetteClientContext: - """Context manager to mark code as executing within a datasette.client request.""" +class BaseView(HTTPMethodView): + template = None - def __enter__(self): - self.token = _in_datasette_client.set(True) - return self + def __init__(self, datasette): + self.ds = datasette + self.files = datasette.files + self.jinja = datasette.jinja + self.executor = datasette.executor + self.page_size = datasette.page_size + self.max_returned_rows = datasette.max_returned_rows - def __exit__(self, exc_type, exc_val, exc_tb): - _in_datasette_client.reset(self.token) - return False + def options(self, request, *args, **kwargs): + r = response.text('ok') + if self.ds.cors: + r.headers['Access-Control-Allow-Origin'] = '*' + return r + + def redirect(self, request, path, forward_querystring=True): + if request.query_string and '?' not in path and forward_querystring: + path = '{}?{}'.format( + path, request.query_string + ) + r = response.redirect(path) + r.headers['Link'] = '<{}>; rel=preload'.format(path) + if self.ds.cors: + r.headers['Access-Control-Allow-Origin'] = '*' + return r + + async def pks_for_table(self, name, table): + rows = [ + row for row in await self.execute( + name, + 'PRAGMA table_info("{}")'.format(table) + ) + if row[-1] + ] + rows.sort(key=lambda row: row[-1]) + return [str(r[1]) for r in rows] + + def resolve_db_name(self, db_name, **kwargs): + databases = self.ds.inspect() + hash = None + name = None + if '-' in db_name: + # Might be name-and-hash, or might just be + # a name with a hyphen in it + name, hash = db_name.rsplit('-', 1) + if name not in databases: + # Try the whole name + name = db_name + hash = None + else: + name = db_name + # Verify the hash + try: + info = databases[name] + except KeyError: + raise NotFound('Database not found: {}'.format(name)) + expected = info['hash'][:7] + if expected != hash: + should_redirect = '/{}-{}'.format( + name, expected, + ) + if 'table' in kwargs: + should_redirect += '/' + kwargs['table'] + if 'pk_path' in kwargs: + should_redirect += '/' + kwargs['pk_path'] + if 'as_json' in kwargs: + should_redirect += kwargs['as_json'] + if 'as_db' in kwargs: + should_redirect += kwargs['as_db'] + return name, expected, should_redirect + return name, expected, None + + def prepare_connection(self, conn): + conn.row_factory = sqlite3.Row + conn.text_factory = lambda x: str(x, 'utf-8', 'replace') + for name, num_args, func in self.ds.sqlite_functions: + conn.create_function(name, num_args, func) + if self.ds.sqlite_extensions: + conn.enable_load_extension(True) + for extension in self.ds.sqlite_extensions: + conn.execute("SELECT load_extension('{}')".format(extension)) + + async def execute(self, db_name, sql, params=None, truncate=False, custom_time_limit=None): + """Executes sql against db_name in a thread""" + def sql_operation_in_thread(): + conn = getattr(connections, db_name, None) + if not conn: + info = self.ds.inspect()[db_name] + conn = sqlite3.connect( + 'file:{}?immutable=1'.format(info['file']), + uri=True, + check_same_thread=False, + ) + self.prepare_connection(conn) + setattr(connections, db_name, conn) + + time_limit_ms = self.ds.sql_time_limit_ms + if custom_time_limit and custom_time_limit < self.ds.sql_time_limit_ms: + time_limit_ms = custom_time_limit + + with sqlite_timelimit(conn, time_limit_ms): + try: + cursor = conn.cursor() + cursor.execute(sql, params or {}) + if self.max_returned_rows and truncate: + rows = cursor.fetchmany(self.max_returned_rows + 1) + truncated = len(rows) > self.max_returned_rows + rows = rows[:self.max_returned_rows] + else: + rows = cursor.fetchall() + truncated = False + except Exception: + print('ERROR: conn={}, sql = {}, params = {}'.format( + conn, repr(sql), params + )) + raise + if truncate: + return rows, truncated, cursor.description + else: + return rows + + return await asyncio.get_event_loop().run_in_executor( + self.executor, sql_operation_in_thread + ) + + async def get(self, request, db_name, **kwargs): + name, hash, should_redirect = self.resolve_db_name(db_name, **kwargs) + if should_redirect: + return self.redirect(request, should_redirect) + return await self.view_get(request, name, hash, **kwargs) + + async def view_get(self, request, name, hash, **kwargs): + try: + as_json = kwargs.pop('as_json') + except KeyError: + as_json = False + extra_template_data = {} + start = time.time() + template = self.template + status_code = 200 + try: + response_or_template_contexts = await self.data( + request, name, hash, **kwargs + ) + if isinstance(response_or_template_contexts, response.HTTPResponse): + return response_or_template_contexts + else: + data, extra_template_data = response_or_template_contexts + except (sqlite3.OperationalError, InvalidSql) as e: + data = { + 'ok': False, + 'error': str(e), + 'database': name, + 'database_hash': hash, + } + template = 'error.html' + status_code = 400 + end = time.time() + data['query_ms'] = (end - start) * 1000 + for key in ('source', 'source_url', 'license', 'license_url'): + value = self.ds.metadata.get(key) + if value: + data[key] = value + if as_json: + # Special case for .jsono extension + if as_json == '.jsono': + columns = data.get('columns') + rows = data.get('rows') + if rows and columns: + data['rows'] = [ + dict(zip(columns, row)) + for row in rows + ] + headers = {} + if self.ds.cors: + headers['Access-Control-Allow-Origin'] = '*' + r = response.HTTPResponse( + json.dumps( + data, cls=CustomJSONEncoder + ), + status=status_code, + content_type='application/json', + headers=headers, + ) + else: + extras = {} + if callable(extra_template_data): + extras = extra_template_data() + if asyncio.iscoroutine(extras): + extras = await extras + else: + extras = extra_template_data + context = { + **data, + **extras, + **{ + 'url_json': path_with_ext(request, '.json'), + 'url_jsono': path_with_ext(request, '.jsono'), + 'metadata': self.ds.metadata, + 'datasette_version': __version__, + } + } + r = self.jinja.render( + template, + request, + **context, + ) + r.status = status_code + # Set far-future cache expiry + if self.ds.cache_headers: + r.headers['Cache-Control'] = 'max-age={}'.format( + 365 * 24 * 60 * 60 + ) + return r -@dataclasses.dataclass -class PermissionCheck: - """Represents a logged permission check for debugging purposes.""" +class IndexView(HTTPMethodView): + def __init__(self, datasette): + self.ds = datasette + self.files = datasette.files + self.jinja = datasette.jinja + self.executor = datasette.executor - when: str - actor: Dict[str, Any] | None - action: str - parent: str | None - child: str | None - result: bool + async def get(self, request, as_json): + databases = [] + for key, info in sorted(self.ds.inspect().items()): + tables = [t for t in info['tables'].values() if not t['hidden']] + hidden_tables = [t for t in info['tables'].values() if t['hidden']] + database = { + 'name': key, + 'hash': info['hash'], + 'path': '{}-{}'.format(key, info['hash'][:7]), + 'tables_truncated': sorted( + tables, + key=lambda t: t['count'], + reverse=True + )[:5], + 'tables_count': len(tables), + 'tables_more': len(tables) > 5, + 'table_rows': sum(t['count'] for t in tables), + 'hidden_table_rows': sum(t['count'] for t in hidden_tables), + 'hidden_tables_count': len(hidden_tables), + 'views_count': len(info['views']), + } + databases.append(database) + if as_json: + return response.HTTPResponse( + json.dumps( + {db['name']: db for db in databases}, + cls=CustomJSONEncoder + ), + content_type='application/json', + headers={ + 'Access-Control-Allow-Origin': '*' + } + ) + else: + return self.jinja.render( + 'index.html', + request, + databases=databases, + metadata=self.ds.metadata, + datasette_version=__version__, + ) -# https://github.com/simonw/datasette/issues/283#issuecomment-781591015 -SQLITE_LIMIT_ATTACHED = 10 - -INTERNAL_DB_NAME = "__INTERNAL__" - -Setting = collections.namedtuple("Setting", ("name", "default", "help")) -SETTINGS = ( - Setting("default_page_size", 100, "Default page size for the table view"), - Setting( - "max_returned_rows", - 1000, - "Maximum rows that can be returned from a table or custom query", - ), - Setting( - "max_insert_rows", - 100, - "Maximum rows that can be inserted at a time using the bulk insert API", - ), - Setting( - "num_sql_threads", - 3, - "Number of threads in the thread pool for executing SQLite queries", - ), - Setting("sql_time_limit_ms", 1000, "Time limit for a SQL query in milliseconds"), - Setting( - "default_facet_size", 30, "Number of values to return for requested facets" - ), - Setting("facet_time_limit_ms", 200, "Time limit for calculating a requested facet"), - Setting( - "facet_suggest_time_limit_ms", - 50, - "Time limit for calculating a suggested facet", - ), - Setting( - "allow_facet", - True, - "Allow users to specify columns to facet using ?_facet= parameter", - ), - Setting( - "allow_download", - True, - "Allow users to download the original SQLite database files", - ), - Setting( - "allow_signed_tokens", - True, - "Allow users to create and use signed API tokens", - ), - Setting( - "default_allow_sql", - True, - "Allow anyone to run arbitrary SQL queries", - ), - Setting( - "max_signed_tokens_ttl", - 0, - "Maximum allowed expiry time for signed API tokens", - ), - Setting("suggest_facets", True, "Calculate and display suggested facets"), - Setting( - "default_cache_ttl", - 5, - "Default HTTP cache TTL (used in Cache-Control: max-age= header)", - ), - Setting("cache_size_kb", 0, "SQLite cache size in KB (0 == use SQLite default)"), - Setting( - "allow_csv_stream", - True, - "Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)", - ), - Setting( - "max_csv_mb", - 100, - "Maximum size allowed for CSV export in MB - set 0 to disable this limit", - ), - Setting( - "truncate_cells_html", - 2048, - "Truncate cells longer than this in HTML table view - set 0 to disable", - ), - Setting( - "force_https_urls", - False, - "Force URLs in API output to always use https:// protocol", - ), - Setting( - "template_debug", - False, - "Allow display of template debug information with ?_context=1", - ), - Setting( - "trace_debug", - False, - "Allow display of SQL trace debug information with ?_trace=1", - ), - Setting("base_url", "/", "Datasette URLs should use this base path"), -) -_HASH_URLS_REMOVED = "The hash_urls setting has been removed, try the datasette-hashed-urls plugin instead" -OBSOLETE_SETTINGS = { - "hash_urls": _HASH_URLS_REMOVED, - "default_cache_ttl_hashed": _HASH_URLS_REMOVED, -} -DEFAULT_SETTINGS = {option.name: option.default for option in SETTINGS} - -FAVICON_PATH = app_root / "datasette" / "static" / "favicon.png" - -DEFAULT_NOT_SET = object() +async def favicon(request): + return response.text('') -ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) +class DatabaseView(BaseView): + template = 'database.html' + re_named_parameter = re.compile(':([a-zA-Z0-9_]+)') + + async def data(self, request, name, hash): + if request.args.get('sql'): + return await self.custom_sql(request, name, hash) + info = self.ds.inspect()[name] + tables = list(info['tables'].values()) + tables.sort(key=lambda t: (t['hidden'], t['name'])) + return { + 'database': name, + 'tables': tables, + 'hidden_count': len([t for t in tables if t['hidden']]), + 'views': info['views'], + }, { + 'database_hash': hash, + 'show_hidden': request.args.get('_show_hidden'), + } + + async def custom_sql(self, request, name, hash): + params = request.raw_args + sql = params.pop('sql') + validate_sql_select(sql) + + # Extract any :named parameters + named_parameters = self.re_named_parameter.findall(sql) + named_parameter_values = { + named_parameter: params.get(named_parameter) or '' + for named_parameter in named_parameters + } + + # Set to blank string if missing from params + for named_parameter in named_parameters: + if named_parameter not in params: + params[named_parameter] = '' + + extra_args = {} + if params.get('_sql_time_limit_ms'): + extra_args['custom_time_limit'] = int(params['_sql_time_limit_ms']) + rows, truncated, description = await self.execute( + name, sql, params, truncate=True, **extra_args + ) + columns = [r[0] for r in description] + return { + 'database': name, + 'rows': rows, + 'truncated': truncated, + 'columns': columns, + 'query': { + 'sql': sql, + 'params': params, + } + }, { + 'database_hash': hash, + 'custom_sql': True, + 'named_parameter_values': named_parameter_values, + } -def _permission_cache_key(actor, action, parent, child): - # Key on the full serialized actor so actors differing in any field - # (e.g. token restrictions) never share cache entries - actor_key = ( - json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None - ) - return (actor_key, action, parent, child) +class DatabaseDownload(BaseView): + async def view_get(self, request, name, hash, **kwargs): + filepath = self.ds.inspect()[name]['file'] + return await response.file_stream( + filepath, headers={ + 'Content-Disposition': 'attachment; filename="{}"'.format(filepath) + } + ) -async def favicon(request, send): - await asgi_send_file( - send, - str(FAVICON_PATH), - content_type="image/png", - headers={"Cache-Control": "max-age=3600, immutable, public"}, - ) +class RowTableShared(BaseView): + async def make_display_rows(self, database, database_hash, table, rows, display_columns, pks, is_view, use_rowid): + # Get fancy with foreign keys + expanded = {} + tables = self.ds.inspect()[database]['tables'] + table_info = tables.get(table) or {} + if table_info and not is_view: + foreign_keys = table_info['foreign_keys']['outgoing'] + for fk in foreign_keys: + label_column = tables.get(fk['other_table'], {}).get('label_column') + if not label_column: + # We only link cells to other tables with label columns defined + continue + ids_to_lookup = set([row[fk['column']] for row in rows]) + sql = 'select "{other_column}", "{label_column}" from {other_table} where "{other_column}" in ({placeholders})'.format( + other_column=fk['other_column'], + label_column=label_column, + other_table=escape_sqlite_table_name(fk['other_table']), + placeholders=', '.join(['?'] * len(ids_to_lookup)), + ) + try: + results = await self.execute(database, sql, list(set(ids_to_lookup))) + except sqlite3.OperationalError: + # Probably hit the timelimit + pass + else: + for id, value in results: + expanded[(fk['column'], id)] = (fk['other_table'], value) + + to_return = [] + for row in rows: + cells = [] + # Unless we are a view, the first column is a link - either to the rowid + # or to the simple or compound primary key + if not is_view: + display_value = jinja2.Markup( + '{flat_pks}'.format( + database=database, + database_hash=database_hash, + table=urllib.parse.quote_plus(table), + flat_pks=path_from_row_pks(row, pks, use_rowid), + ) + ) + cells.append({ + 'column': 'rowid' if use_rowid else 'Link', + 'value': display_value, + }) + + for value, column in zip(row, display_columns): + if use_rowid and column == 'rowid': + # We already showed this in the linked first column + continue + elif (column, value) in expanded: + other_table, label = expanded[(column, value)] + display_value = jinja2.Markup( + # TODO: Escape id/label/etc so no XSS here + '{label} {id}'.format( + database=database, + database_hash=database_hash, + table=urllib.parse.quote_plus(other_table), + id=value, + label=label, + ) + ) + elif value is None: + display_value = jinja2.Markup(' ') + else: + display_value = str(value) + cells.append({ + 'column': column, + 'value': display_value, + }) + to_return.append(cells) + return to_return -ResolvedTable = collections.namedtuple("ResolvedTable", ("db", "table", "is_view")) -ResolvedRow = collections.namedtuple( - "ResolvedRow", ("db", "table", "sql", "params", "pks", "pk_values", "row") -) +class TableView(RowTableShared): + template = 'table.html' + + async def data(self, request, name, hash, table): + table = urllib.parse.unquote_plus(table) + pks = await self.pks_for_table(name, table) + is_view = bool(list(await self.execute(name, "SELECT count(*) from sqlite_master WHERE type = 'view' and name=:n", { + 'n': table, + }))[0][0]) + view_definition = None + table_definition = None + if is_view: + view_definition = list(await self.execute(name, 'select sql from sqlite_master where name = :n and type="view"', { + 'n': table, + }))[0][0] + else: + table_definition = list(await self.execute(name, 'select sql from sqlite_master where name = :n and type="table"', { + 'n': table, + }))[0][0] + use_rowid = not pks and not is_view + if use_rowid: + select = 'rowid, *' + order_by = 'rowid' + else: + select = '*' + order_by = ', '.join(pks) + + if is_view: + order_by = '' + + # We roll our own query_string decoder because by default Sanic + # drops anything with an empty value e.g. ?name__exact= + args = RequestParameters( + urllib.parse.parse_qs(request.query_string, keep_blank_values=True) + ) + + # Special args start with _ and do not contain a __ + # That's so if there is a column that starts with _ + # it can still be queried using ?_col__exact=blah + special_args = {} + special_args_lists = {} + other_args = {} + for key, value in args.items(): + if key.startswith('_') and '__' not in key: + special_args[key] = value[0] + special_args_lists[key] = value + else: + other_args[key] = value[0] + + # Handle ?_filter_column and redirect, if present + redirect_params = filters_should_redirect(special_args) + if redirect_params: + return self.redirect( + request, + path_with_added_args(request, redirect_params), + forward_querystring=False + ) + + filters = Filters(sorted(other_args.items())) + where_clauses, params = filters.build_where_clauses() + + # _search support: + fts_table = None + fts_sql = detect_fts_sql(table) + fts_rows = list(await self.execute(name, fts_sql)) + if fts_rows: + fts_table = fts_rows[0][0] + + search = special_args.get('_search') + search_description = None + if search and fts_table: + where_clauses.append( + 'rowid in (select rowid from [{fts_table}] where [{fts_table}] match :search)'.format( + fts_table=fts_table + ) + ) + search_description = 'search matches "{}"'.format(search) + params['search'] = search + + next = special_args.get('_next') + offset = '' + if next: + if is_view: + # _next is an offset + offset = ' offset {}'.format(int(next)) + elif use_rowid: + where_clauses.append( + 'rowid > :p{}'.format( + len(params), + ) + ) + params['p{}'.format(len(params))] = next + else: + pk_values = compound_pks_from_path(next) + if len(pk_values) == len(pks): + param_counter = len(params) + for pk, value in zip(pks, pk_values): + where_clauses.append( + '"{}" > :p{}'.format( + pk, param_counter, + ) + ) + params['p{}'.format(param_counter)] = value + param_counter += 1 + + where_clause = '' + if where_clauses: + where_clause = 'where {} '.format(' and '.join(where_clauses)) + + if order_by: + order_by = 'order by {} '.format(order_by) + + # _group_count=col1&_group_count=col2 + group_count = special_args_lists.get('_group_count') or [] + if group_count: + count_sql = None + sql = 'select {group_cols}, count(*) as "count" from {table_name} {where} group by {group_cols} order by "count" desc limit 100'.format( + group_cols=', '.join('"{}"'.format(group_count_col) for group_count_col in group_count), + table_name=escape_sqlite_table_name(table), + where=where_clause, + ) + is_view = True + else: + count_sql = 'select count(*) from {table_name} {where}'.format( + table_name=escape_sqlite_table_name(table), + where=where_clause, + ) + sql = 'select {select} from {table_name} {where}{order_by}limit {limit}{offset}'.format( + select=select, + table_name=escape_sqlite_table_name(table), + where=where_clause, + order_by=order_by, + limit=self.page_size + 1, + offset=offset, + ) + + extra_args = {} + if request.raw_args.get('_sql_time_limit_ms'): + extra_args['custom_time_limit'] = int(request.raw_args['_sql_time_limit_ms']) + + rows, truncated, description = await self.execute( + name, sql, params, truncate=True, **extra_args + ) + + columns = [r[0] for r in description] + rows = list(rows) + + display_columns = columns + if not use_rowid and not is_view: + display_columns = ['Link'] + display_columns + + info = self.ds.inspect() + table_rows = None + if not is_view: + table_rows = info[name]['tables'][table]['count'] + + # Pagination next link + next_value = None + next_url = None + if len(rows) > self.page_size: + if is_view: + next_value = int(next or 0) + self.page_size + else: + next_value = path_from_row_pks(rows[-2], pks, use_rowid) + next_url = urllib.parse.urljoin(request.url, path_with_added_args(request, { + '_next': next_value, + })) + + # Number of filtered rows in whole set: + filtered_table_rows = None + if not where_clauses and not is_view: + # Use the pre-calculated total + filtered_table_rows = table_rows + elif not truncated and len(rows) < self.page_size: + filtered_table_rows = len(rows) + else: + # Attempt a full count, if we can do it in < X ms + if count_sql: + try: + count_rows = list(await self.execute(name, count_sql, params)) + filtered_table_rows = count_rows[0][0] + except sqlite3.OperationalError: + # Almost certainly hit the timeout + pass + + # human_filter_description combines filters AND search, if provided + human_description = filters.human_description(extra=search_description) + + async def extra_template(): + return { + 'database_hash': hash, + 'human_filter_description': human_description, + 'supports_search': bool(fts_table), + 'search': search or '', + 'use_rowid': use_rowid, + 'filters': filters, + 'display_columns': display_columns, + 'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid), + } + + return { + 'database': name, + 'table': table, + 'is_view': is_view, + 'view_definition': view_definition, + 'table_definition': table_definition, + 'rows': rows[:self.page_size], + 'truncated': truncated, + 'table_rows': table_rows, + 'filtered_table_rows': filtered_table_rows, + 'columns': columns, + 'primary_keys': pks, + 'query': { + 'sql': sql, + 'params': params, + }, + 'next': next_value and str(next_value) or None, + 'next_url': next_url, + }, extra_template -def _to_string(value): - if isinstance(value, str): - return value - else: - return json.dumps(value, default=str) +class RowView(RowTableShared): + template = 'row.html' + + async def data(self, request, name, hash, table, pk_path): + table = urllib.parse.unquote_plus(table) + pk_values = compound_pks_from_path(pk_path) + pks = await self.pks_for_table(name, table) + use_rowid = not pks + select = '*' + if use_rowid: + select = 'rowid, *' + pks = ['rowid'] + wheres = [ + '"{}"=:p{}'.format(pk, i) + for i, pk in enumerate(pks) + ] + sql = 'select {} from "{}" where {}'.format( + select, table, ' AND '.join(wheres) + ) + params = {} + for i, pk_value in enumerate(pk_values): + params['p{}'.format(i)] = pk_value + # rows, truncated, description = await self.execute(name, sql, params, truncate=True) + rows, truncated, description = await self.execute(name, sql, params, truncate=True) + columns = [r[0] for r in description] + rows = list(rows) + if not rows: + raise NotFound('Record not found: {}'.format(pk_values)) + + async def template_data(): + return { + 'database_hash': hash, + 'foreign_key_tables': await self.foreign_key_tables(name, table, pk_values), + 'display_columns': columns, + 'display_rows': await self.make_display_rows(name, hash, table, rows, columns, pks, False, use_rowid), + } + + data = { + 'database': name, + 'table': table, + 'rows': rows, + 'columns': columns, + 'primary_keys': pks, + 'primary_key_values': pk_values, + } + + if 'foreign_key_tables' in (request.raw_args.get('_extras') or '').split(','): + data['foreign_key_tables'] = await self.foreign_key_tables(name, table, pk_values) + + return data, template_data + + async def foreign_key_tables(self, name, table, pk_values): + if len(pk_values) != 1: + return [] + table_info = self.ds.inspect()[name]['tables'].get(table) + if not table: + return [] + foreign_keys = table_info['foreign_keys']['incoming'] + sql = 'select ' + ', '.join([ + '(select count(*) from {table} where "{column}"=:id)'.format( + table=escape_sqlite_table_name(fk['other_table']), + column=fk['other_column'], + ) + for fk in foreign_keys + ]) + try: + rows = list(await self.execute(name, sql, {'id': pk_values[0]})) + except sqlite3.OperationalError: + # Almost certainly hit the timeout + return [] + foreign_table_counts = dict( + zip( + [(fk['other_table'], fk['other_column']) for fk in foreign_keys], + list(rows[0]), + ) + ) + foreign_key_tables = [] + for fk in foreign_keys: + count = foreign_table_counts.get((fk['other_table'], fk['other_column'])) or 0 + foreign_key_tables.append({**fk, **{'count': count}}) + return foreign_key_tables class Datasette: - # Message constants: - INFO = 1 - WARNING = 2 - ERROR = 3 - def __init__( - self, - files=None, - immutables=None, - cache_headers=True, - cors=False, - inspect_data=None, - config=None, - metadata=None, - sqlite_extensions=None, - template_dir=None, - plugins_dir=None, - static_mounts=None, - memory=False, - settings=None, - secret=None, - version_note=None, - config_dir=None, - pdb=False, - crossdb=False, - nolock=False, - internal=None, - default_deny=False, - ): - self._startup_invoked = False - self._closed = False - assert config_dir is None or isinstance( - config_dir, Path - ), "config_dir= should be a pathlib.Path" - self.config_dir = config_dir - self.pdb = pdb - self._secret = secret or secrets.token_hex(32) - if files is not None and isinstance(files, str): - raise ValueError("files= must be a list of paths, not a string") - self.files = tuple(files or []) + tuple(immutables or []) - if config_dir: - db_files = [] - for ext in ("db", "sqlite", "sqlite3"): - db_files.extend(config_dir.glob("*.{}".format(ext))) - self.files += tuple(str(f) for f in db_files) - if ( - config_dir - and (config_dir / "inspect-data.json").exists() - and not inspect_data - ): - inspect_data = json.loads((config_dir / "inspect-data.json").read_text()) - if not immutables: - immutable_filenames = [i["file"] for i in inspect_data.values()] - immutables = [ - f for f in self.files if Path(f).name in immutable_filenames - ] - self.inspect_data = inspect_data - self.immutables = set(immutables or []) - self.databases = collections.OrderedDict() - self.actions = {} # .invoke_startup() will populate this - self._column_types = {} # .invoke_startup() will populate this - try: - self._refresh_schemas_lock = asyncio.Lock() - except RuntimeError as rex: - # Workaround for intermittent test failure, see: - # https://github.com/simonw/datasette/issues/1802 - if "There is no current event loop in thread" in str(rex): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - self._refresh_schemas_lock = asyncio.Lock() - else: - raise - self.crossdb = crossdb - self.nolock = nolock - if memory or crossdb or not self.files: - self.add_database( - Database(self, is_mutable=False, is_memory=True), name="_memory" - ) - for file in self.files: - self.add_database( - Database(self, file, is_mutable=file not in self.immutables) - ) - - self.internal_db_created = False - if internal is None: - self._internal_database = Database(self, is_temp_disk=True) - else: - self._internal_database = Database(self, path=internal, mode="rwc") - self._internal_database.name = INTERNAL_DB_NAME - + self, files, num_threads=3, cache_headers=True, page_size=100, + max_returned_rows=1000, sql_time_limit_ms=1000, cors=False, + inspect_data=None, metadata=None, sqlite_extensions=None): + self.files = files + self.num_threads = num_threads + self.executor = futures.ThreadPoolExecutor( + max_workers=num_threads + ) self.cache_headers = cache_headers + self.page_size = page_size + self.max_returned_rows = max_returned_rows + self.sql_time_limit_ms = sql_time_limit_ms self.cors = cors - config_files = [] - metadata_files = [] - if config_dir: - metadata_files = [ - config_dir / filename - for filename in ("metadata.json", "metadata.yaml", "metadata.yml") - if (config_dir / filename).exists() - ] - config_files = [ - config_dir / filename - for filename in ("datasette.json", "datasette.yaml", "datasette.yml") - if (config_dir / filename).exists() - ] - if config_dir and metadata_files and not metadata: - with metadata_files[0].open() as fp: - metadata = parse_metadata(fp.read()) + self._inspect = inspect_data + self.metadata = metadata or {} + self.sqlite_functions = [] + self.sqlite_extensions = sqlite_extensions or [] - if config_dir and config_files and not config: - with config_files[0].open() as fp: - config = parse_metadata(fp.read()) + def inspect(self): + if not self._inspect: + self._inspect = {} + for filename in self.files: + path = Path(filename) + name = path.stem + if name in self._inspect: + raise Exception('Multiple files with same stem %s' % name) + # Calculate hash, efficiently + m = hashlib.sha256() + with path.open('rb') as fp: + while True: + data = fp.read(HASH_BLOCK_SIZE) + if not data: + break + m.update(data) + # List tables and their row counts + tables = {} + views = [] + with sqlite3.connect('file:{}?immutable=1'.format(path), uri=True) as conn: + conn.row_factory = sqlite3.Row + table_names = [ + r['name'] + for r in conn.execute('select * from sqlite_master where type="table"') + ] + views = [v[0] for v in conn.execute('select name from sqlite_master where type = "view"')] + for table in table_names: + count = conn.execute( + 'select count(*) from {}'.format(escape_sqlite_table_name(table)) + ).fetchone()[0] + label_column = None + # If table has two columns, one of which is ID, then label_column is the other one + column_names = [r[1] for r in conn.execute( + 'PRAGMA table_info({});'.format(escape_sqlite_table_name(table)) + ).fetchall()] + if column_names and len(column_names) == 2 and 'id' in column_names: + label_column = [c for c in column_names if c != 'id'][0] + tables[table] = { + 'name': table, + 'columns': column_names, + 'count': count, + 'label_column': label_column, + 'hidden': False, + } - # Move any "plugins" and "allow" settings from metadata to config - updates them in place - metadata = metadata or {} - config = config or {} - metadata, config = move_plugins_and_allow(metadata, config) - # Now migrate any known table configuration settings over as well - metadata, config = move_table_config(metadata, config) + foreign_keys = get_all_foreign_keys(conn) + for table, info in foreign_keys.items(): + tables[table]['foreign_keys'] = info - self._metadata_local = metadata or {} - self.sqlite_extensions = [] - for extension in sqlite_extensions or []: - # Resolve spatialite, if requested - if extension == "spatialite": - # Could raise SpatialiteNotFound - self.sqlite_extensions.append(find_spatialite()) - else: - self.sqlite_extensions.append(extension) - if config_dir and (config_dir / "templates").is_dir() and not template_dir: - template_dir = str((config_dir / "templates").resolve()) - self.template_dir = template_dir - if config_dir and (config_dir / "plugins").is_dir() and not plugins_dir: - plugins_dir = str((config_dir / "plugins").resolve()) - self.plugins_dir = plugins_dir - if config_dir and (config_dir / "static").is_dir() and not static_mounts: - static_mounts = [("static", str((config_dir / "static").resolve()))] - self.static_mounts = static_mounts or [] - if config_dir and (config_dir / "datasette.json").exists() and not config: - config = json.loads((config_dir / "datasette.json").read_text()) - - config = config or {} - config_settings = config.get("settings") or {} - - # Validate settings from config file - for key, value in config_settings.items(): - if key not in DEFAULT_SETTINGS: - raise StartupError(f"Invalid setting '{key}' in config file") - # Validate type matches expected type from DEFAULT_SETTINGS - if value is not None: # Allow None/null values - expected_type = type(DEFAULT_SETTINGS[key]) - actual_type = type(value) - if actual_type != expected_type: - raise StartupError( - f"Setting '{key}' in config file has incorrect type. " - f"Expected {expected_type.__name__}, got {actual_type.__name__}. " - f"Value: {value!r}. " - f"Hint: In YAML/JSON config files, remove quotes from boolean and integer values." - ) - - # Validate settings from constructor parameter - if settings: - for key, value in settings.items(): - if key not in DEFAULT_SETTINGS: - raise StartupError(f"Invalid setting '{key}' in settings parameter") - if value is not None: - expected_type = type(DEFAULT_SETTINGS[key]) - actual_type = type(value) - if actual_type != expected_type: - raise StartupError( - f"Setting '{key}' in settings parameter has incorrect type. " - f"Expected {expected_type.__name__}, got {actual_type.__name__}. " - f"Value: {value!r}" + # Mark tables 'hidden' if they relate to FTS virtual tables + fts_tables = [ + r['name'] + for r in conn.execute( + ''' + select name from sqlite_master + where rootpage = 0 + and sql like '%VIRTUAL TABLE%USING FTS%' + ''' ) + ] + for t in tables.keys(): + for fts_table in fts_tables: + if t == fts_table or t.startswith(fts_table): + tables[t]['hidden'] = True + continue - self.config = config - # CLI settings should overwrite datasette.json settings - self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {})) - self.renderers = {} # File extension -> (renderer, can_render) functions - self.version_note = version_note - if self.setting("num_sql_threads") == 0: - self.executor = None - else: - self.executor = futures.ThreadPoolExecutor( - max_workers=self.setting("num_sql_threads") - ) - self.max_returned_rows = self.setting("max_returned_rows") - self.sql_time_limit_ms = self.setting("sql_time_limit_ms") - self.page_size = self.setting("default_page_size") - # Execute plugins in constructor, to ensure they are available - # when the rest of `datasette inspect` executes - if self.plugins_dir: - for filepath in glob.glob(os.path.join(self.plugins_dir, "*.py")): - if not os.path.isfile(filepath): - continue - mod = module_from_path(filepath, name=os.path.basename(filepath)) - try: - pm.register(mod) - except ValueError: - # Plugin already registered - pass + self._inspect[name] = { + 'hash': m.hexdigest(), + 'file': str(path), + 'tables': tables, + 'views': views, - # Configure Jinja - default_templates = str(app_root / "datasette" / "templates") - template_paths = [] - if self.template_dir: - template_paths.append(self.template_dir) - plugin_template_paths = [ - plugin["templates_path"] - for plugin in get_plugins() - if plugin["templates_path"] - ] - template_paths.extend(plugin_template_paths) - template_paths.append(default_templates) - template_loader = ChoiceLoader( - [ - FileSystemLoader(template_paths), - # Support {% extends "default:table.html" %}: - PrefixLoader( - {"default": FileSystemLoader(default_templates)}, delimiter=":" - ), - ] - ) - environment = Environment( - loader=template_loader, + } + return self._inspect + + def app(self): + app = Sanic(__name__) + self.jinja = SanicJinja2( + app, + loader=FileSystemLoader([ + str(app_root / 'datasette' / 'templates') + ]), autoescape=True, - enable_async=True, - # undefined=StrictUndefined, ) - environment.filters["escape_css_string"] = escape_css_string - environment.filters["quote_plus"] = urllib.parse.quote_plus - self._jinja_env = environment - environment.filters["escape_sqlite"] = escape_sqlite - environment.filters["to_css_class"] = to_css_class - self._register_renderers() - self._permission_checks = collections.deque(maxlen=200) - self._root_token = secrets.token_hex(32) - self.root_enabled = False - self.default_deny = default_deny - self.client = DatasetteClient(self) - - async def apply_metadata_json(self): - # Apply any metadata entries from metadata.json to the internal tables - # step 1: top-level metadata - for key in self._metadata_local or {}: - if key == "databases": - continue - value = self._metadata_local[key] - await self.set_instance_metadata(key, _to_string(value)) - - # step 2: database-level metadata - for dbname, db in self._metadata_local.get("databases", {}).items(): - for key, value in db.items(): - if key in ("tables", "queries"): - continue - await self.set_database_metadata(dbname, key, _to_string(value)) - - # step 3: table-level metadata - for tablename, table in db.get("tables", {}).items(): - for key, value in table.items(): - if key == "columns": - continue - await self.set_resource_metadata( - dbname, tablename, key, _to_string(value) - ) - - # step 4: column-level metadata (only descriptions in metadata.json) - for columnname, column_description in table.get("columns", {}).items(): - await self.set_column_metadata( - dbname, tablename, columnname, "description", column_description - ) - - # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log - # a warning to user that they should delete their metadata.json file - - async def _save_queries_from_config(self): - await stored_queries.save_queries_from_config(self) - - def get_jinja_environment(self, request: Request = None) -> Environment: - environment = self._jinja_env - if request: - for environment in pm.hook.jinja2_environment_from_request( - datasette=self, request=request, env=environment - ): - pass - return environment - - def get_action(self, name_or_abbr: str): - """ - Returns an Action object for the given name or abbreviation. Returns None if not found. - """ - if name_or_abbr in self.actions: - return self.actions[name_or_abbr] - # Try abbreviation - for action in self.actions.values(): - if action.abbr == name_or_abbr: - return action - return None - - async def refresh_schemas(self): - # Throttle schema refreshes to at most once per second - if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0: - return - self._last_schema_refresh = time.monotonic() - if self._refresh_schemas_lock.locked(): - return - async with self._refresh_schemas_lock: - await self._refresh_schemas() - - async def _refresh_schemas(self): - internal_db = self.get_internal_database() - if not self.internal_db_created: - await init_internal_db(internal_db) - await self.apply_metadata_json() - self.internal_db_created = True - current_schema_versions = { - row["database_name"]: row["schema_version"] - for row in await internal_db.execute( - "select database_name, schema_version from catalog_databases" - ) - } - catalog_table_names = ( - "catalog_columns", - "catalog_foreign_keys", - "catalog_indexes", - "catalog_views", - "catalog_tables", - "catalog_databases", - ) - # Delete stale entries for databases that are no longer attached - catalog_database_names = set(current_schema_versions.keys()) - for table in catalog_table_names[:-1]: - catalog_database_names.update( - row["database_name"] - for row in await internal_db.execute( - "select distinct database_name from {}".format(table) - ) - if row["database_name"] is not None - ) - stale_databases = catalog_database_names - set(self.databases.keys()) - if stale_databases: - - def delete_stale_database_catalog(conn): - for stale_db_name in stale_databases: - for table in catalog_table_names: - conn.execute( - "DELETE FROM {} WHERE database_name = ?".format(table), - [stale_db_name], - ) - - await internal_db.execute_write_fn(delete_stale_database_catalog) - for database_name, db in self.databases.items(): - schema_version = (await db.execute("PRAGMA schema_version")).first()[0] - # Compare schema versions to see if we should skip it - if schema_version == current_schema_versions.get(database_name): - continue - placeholders = "(?, ?, ?, ?)" - values = [database_name, str(db.path), db.is_memory, schema_version] - if db.path is None: - placeholders = "(?, null, ?, ?)" - values = [database_name, db.is_memory, schema_version] - await internal_db.execute_write( - """ - INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version) - VALUES {} - """.format(placeholders), - values, - ) - await populate_schema_tables(internal_db, db) - - @property - def urls(self): - return Urls(self) - - @property - def pm(self): - """ - Return the global plugin manager instance. - - This provides access to the pluggy PluginManager that manages all - Datasette plugins and hooks. Use datasette.pm.hook.hook_name() to - call plugin hooks. - """ - return pm - - async def invoke_startup(self): - # This must be called for Datasette to be in a usable state - if self._startup_invoked: - return - # Register event classes - event_classes = [] - for hook in pm.hook.register_events(datasette=self): - extra_classes = await await_me_maybe(hook) - if extra_classes: - event_classes.extend(extra_classes) - self.event_classes = tuple(event_classes) - - # Register actions, but watch out for duplicate name/abbr - action_names = {} - action_abbrs = {} - for hook in pm.hook.register_actions(datasette=self): - if hook: - for action in hook: - if ( - action.name in action_names - and action != action_names[action.name] - ): - raise StartupError( - "Duplicate action name: {}".format(action.name) - ) - if ( - action.abbr - and action.abbr in action_abbrs - and action != action_abbrs[action.abbr] - ): - raise StartupError( - "Duplicate action abbr: {}".format(action.abbr) - ) - action_names[action.name] = action - if action.abbr: - action_abbrs[action.abbr] = action - self.actions[action.name] = action - - # Register column types (classes, not instances) - self._column_types = {} - for hook in pm.hook.register_column_types(datasette=self): - if hook: - for ct_cls in hook: - if ct_cls.name in self._column_types: - raise StartupError(f"Duplicate column type name: {ct_cls.name}") - self._column_types[ct_cls.name] = ct_cls - - for hook in pm.hook.prepare_jinja2_environment( - env=self._jinja_env, datasette=self - ): - await await_me_maybe(hook) - # Ensure internal tables and metadata are populated before startup hooks - await self._refresh_schemas() - await self._save_queries_from_config() - # Load column_types from config into internal DB - await self._apply_column_types_config() - for hook in pm.hook.startup(datasette=self): - await await_me_maybe(hook) - self._startup_invoked = True - - def sign(self, value, namespace="default"): - return URLSafeSerializer(self._secret, namespace).dumps(value) - - def unsign(self, signed, namespace="default"): - return URLSafeSerializer(self._secret, namespace).loads(signed) - - def in_client(self) -> bool: - """Check if the current code is executing within a datasette.client request. - - Returns: - bool: True if currently executing within a datasette.client request, False otherwise. - """ - return _in_datasette_client.get() - - def _token_handlers(self): - """Collect all registered token handlers from plugins.""" - from datasette.tokens import TokenHandler - - handlers = [] - for result in pm.hook.register_token_handler(datasette=self): - if isinstance(result, TokenHandler): - handlers.append(result) - elif isinstance(result, list): - handlers.extend(h for h in result if isinstance(h, TokenHandler)) - return handlers - - async def create_token( - self, - actor_id: str, - *, - expires_after: int | None = None, - restrictions: "TokenRestrictions | None" = None, - handler: str | None = None, - ) -> str: - """ - Create an API token for the given actor. - - Uses the first registered token handler by default, or a specific - handler if ``handler`` is provided (matched by handler name). - - Pass a :class:`TokenRestrictions` to limit which actions the token - can perform. - """ - handlers = self._token_handlers() - if not handlers: - raise RuntimeError("No token handlers are registered") - - if handler is not None: - matched = [h for h in handlers if h.name == handler] - if not matched: - available = [h.name for h in handlers] - raise ValueError( - f"Token handler {handler!r} not found. " - f"Available handlers: {available}" - ) - chosen = matched[0] - else: - chosen = handlers[0] - - return await chosen.create_token( - self, - actor_id, - expires_after=expires_after, - restrictions=restrictions, - ) - - async def verify_token(self, token: str) -> dict | None: - """ - Verify an API token by trying all registered token handlers. - - Returns an actor dict from the first handler that recognizes the - token, or None if no handler accepts it. - """ - for token_handler in self._token_handlers(): - result = await token_handler.verify_token(self, token) - if result is not None: - return result - return None - - def get_database(self, name=None, route=None): - if route is not None: - matches = [db for db in self.databases.values() if db.route == route] - if not matches: - raise KeyError - return matches[0] - if name is None: - name = [key for key in self.databases.keys()][0] - return self.databases[name] - - def add_database(self, db, name=None, route=None): - new_databases = self.databases.copy() - if name is None: - # Pick a unique name for this database - suggestion = db.suggest_name() - name = suggestion - else: - suggestion = name - i = 2 - while name in self.databases: - name = "{}_{}".format(suggestion, i) - i += 1 - db.name = name - db.route = route or name - new_databases[name] = db - # don't mutate! that causes race conditions with live import - self.databases = new_databases - return db - - def add_memory_database(self, memory_name, name=None, route=None): - return self.add_database( - Database(self, memory_name=memory_name), name=name, route=route - ) - - def remove_database(self, name): - self.get_database(name).close() - new_databases = self.databases.copy() - new_databases.pop(name) - self.databases = new_databases - - def close(self): - """Release all resources held by this Datasette instance. - - Closes every attached Database (including the internal database), - shuts down the executor, and unlinks the temporary file used for - the internal database if one was created. Idempotent and one-way. - """ - if self._closed: - return - self._closed = True - first_exception = None - dbs = list(self.databases.values()) + [self._internal_database] - for db in dbs: - try: - db.close() - except Exception as e: - if first_exception is None: - first_exception = e - if self.executor is not None: - try: - self.executor.shutdown(wait=True, cancel_futures=True) - except Exception as e: - if first_exception is None: - first_exception = e - if first_exception is not None: - raise first_exception - - def setting(self, key): - return self._settings.get(key, None) - - def settings_dict(self): - # Returns a fully resolved settings dictionary, useful for templates - return {option.name: self.setting(option.name) for option in SETTINGS} - - def _metadata_recursive_update(self, orig, updated): - if not isinstance(orig, dict) or not isinstance(updated, dict): - return orig - - for key, upd_value in updated.items(): - if isinstance(upd_value, dict) and isinstance(orig.get(key), dict): - orig[key] = self._metadata_recursive_update(orig[key], upd_value) - else: - orig[key] = upd_value - return orig - - async def get_instance_metadata(self): - rows = await self.get_internal_database().execute(""" - SELECT - key, - value - FROM metadata_instance - """) - return dict(rows) - - async def get_database_metadata(self, database_name: str): - rows = await self.get_internal_database().execute( - """ - SELECT - key, - value - FROM metadata_databases - WHERE database_name = ? - """, - [database_name], - ) - return dict(rows) - - async def get_resource_metadata(self, database_name: str, resource_name: str): - rows = await self.get_internal_database().execute( - """ - SELECT - key, - value - FROM metadata_resources - WHERE database_name = ? - AND resource_name = ? - """, - [database_name, resource_name], - ) - return dict(rows) - - async def get_column_metadata( - self, database_name: str, resource_name: str, column_name: str - ): - rows = await self.get_internal_database().execute( - """ - SELECT - key, - value - FROM metadata_columns - WHERE database_name = ? - AND resource_name = ? - AND column_name = ? - """, - [database_name, resource_name, column_name], - ) - return dict(rows) - - async def set_instance_metadata(self, key: str, value: str): - # TODO upsert only supported on SQLite 3.24.0 (2018-06-04) - await self.get_internal_database().execute_write( - """ - INSERT INTO metadata_instance(key, value) - VALUES(?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value; - """, - [key, value], - ) - - async def set_database_metadata(self, database_name: str, key: str, value: str): - # TODO upsert only supported on SQLite 3.24.0 (2018-06-04) - await self.get_internal_database().execute_write( - """ - INSERT INTO metadata_databases(database_name, key, value) - VALUES(?, ?, ?) - ON CONFLICT(database_name, key) DO UPDATE SET value = excluded.value; - """, - [database_name, key, value], - ) - - async def set_resource_metadata( - self, database_name: str, resource_name: str, key: str, value: str - ): - # TODO upsert only supported on SQLite 3.24.0 (2018-06-04) - await self.get_internal_database().execute_write( - """ - INSERT INTO metadata_resources(database_name, resource_name, key, value) - VALUES(?, ?, ?, ?) - ON CONFLICT(database_name, resource_name, key) DO UPDATE SET value = excluded.value; - """, - [database_name, resource_name, key, value], - ) - - async def set_column_metadata( - self, - database_name: str, - resource_name: str, - column_name: str, - key: str, - value: str, - ): - # TODO upsert only supported on SQLite 3.24.0 (2018-06-04) - await self.get_internal_database().execute_write( - """ - INSERT INTO metadata_columns(database_name, resource_name, column_name, key, value) - VALUES(?, ?, ?, ?, ?) - ON CONFLICT(database_name, resource_name, column_name, key) DO UPDATE SET value = excluded.value; - """, - [database_name, resource_name, column_name, key, value], - ) - - @staticmethod - def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: - return stored_queries.query_row_to_stored_query(row) - - @staticmethod - def _query_options_json(options): - return stored_queries.query_options_json(options) - - async def add_query( - self, - database: str, - name: str, - sql: str, - *, - title: str | None = None, - description: str | None = None, - description_html: str | None = None, - hide_sql: bool = False, - fragment: str | None = None, - parameters: Iterable[str] | None = None, - is_write: bool = False, - is_private: bool = False, - is_trusted: bool = False, - source: str = "plugin", - owner_id: str | None = None, - on_success_message: str | None = None, - on_success_message_sql: str | None = None, - on_success_redirect: str | None = None, - on_error_message: str | None = None, - on_error_redirect: str | None = None, - replace: bool = True, - ) -> None: - return await stored_queries.add_query( - self, - database, - name, - sql, - title=title, - description=description, - description_html=description_html, - hide_sql=hide_sql, - fragment=fragment, - parameters=parameters, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - on_success_message=on_success_message, - on_success_message_sql=on_success_message_sql, - on_success_redirect=on_success_redirect, - on_error_message=on_error_message, - on_error_redirect=on_error_redirect, - replace=replace, - ) - - async def update_query( - self, - database: str, - name: str, - *, - sql=stored_queries.UNCHANGED, - title=stored_queries.UNCHANGED, - description=stored_queries.UNCHANGED, - description_html=stored_queries.UNCHANGED, - hide_sql=stored_queries.UNCHANGED, - fragment=stored_queries.UNCHANGED, - parameters=stored_queries.UNCHANGED, - is_write=stored_queries.UNCHANGED, - is_private=stored_queries.UNCHANGED, - is_trusted=stored_queries.UNCHANGED, - source=stored_queries.UNCHANGED, - owner_id=stored_queries.UNCHANGED, - on_success_message=stored_queries.UNCHANGED, - on_success_message_sql=stored_queries.UNCHANGED, - on_success_redirect=stored_queries.UNCHANGED, - on_error_message=stored_queries.UNCHANGED, - on_error_redirect=stored_queries.UNCHANGED, - ) -> None: - return await stored_queries.update_query( - self, - database, - name, - sql=sql, - title=title, - description=description, - description_html=description_html, - hide_sql=hide_sql, - fragment=fragment, - parameters=parameters, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - on_success_message=on_success_message, - on_success_message_sql=on_success_message_sql, - on_success_redirect=on_success_redirect, - on_error_message=on_error_message, - on_error_redirect=on_error_redirect, - ) - - async def remove_query( - self, database: str, name: str, source: str | None = None - ) -> None: - return await stored_queries.remove_query(self, database, name, source=source) - - async def get_query( - self, database: str, name: str - ) -> stored_queries.StoredQuery | None: - return await stored_queries.get_query(self, database, name) - - async def count_queries( - self, - database: str | None = None, - *, - actor: dict[str, Any] | None = None, - q: str | None = None, - is_write: bool | None = None, - is_private: bool | None = None, - is_trusted: bool | None = None, - source: str | None = None, - owner_id: str | None = None, - ) -> int: - return await stored_queries.count_queries( - self, - database, - actor=actor, - q=q, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - ) - - async def list_queries( - self, - database: str | None = None, - *, - actor: dict[str, Any] | None = None, - limit: int = 50, - cursor: str | None = None, - q: str | None = None, - is_write: bool | None = None, - is_private: bool | None = None, - is_trusted: bool | None = None, - source: str | None = None, - owner_id: str | None = None, - include_private: bool = False, - ) -> stored_queries.StoredQueryPage: - return await stored_queries.list_queries( - self, - database, - actor=actor, - limit=limit, - cursor=cursor, - q=q, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - include_private=include_private, - ) - - async def ensure_query_write_permissions( - self, database, sql, *, actor=None, params=None, analysis=None - ): - # Raise Forbidden or QueryWriteRejected if SQL should not run - return await write_sql.ensure_query_write_permissions( - self, database, sql, actor=actor, params=params, analysis=analysis - ) - - # Column types API - - async def _get_resource_column_details(self, database: str, resource: str): - db = self.databases.get(database) - if db is None: - return {} - try: - return { - column.name: column - for column in await db.table_column_details(resource) - } - except sqlite3.OperationalError: - return {} - - @staticmethod - def _column_type_is_applicable(ct_cls, column_detail) -> bool: - sqlite_types = getattr(ct_cls, "sqlite_types", None) - if sqlite_types is None: - return True - if column_detail is None: - return False - actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type) - return actual_sqlite_type in sqlite_types - - async def _validate_column_type_assignment( - self, database: str, resource: str, column: str, ct_cls - ) -> None: - sqlite_types = getattr(ct_cls, "sqlite_types", None) - if sqlite_types is None: - return - - column_detail = ( - await self._get_resource_column_details(database, resource) - ).get(column) - if column_detail is None: - return - - actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type) - if actual_sqlite_type in sqlite_types: - return - - allowed = ", ".join(sqlite_type.value for sqlite_type in sqlite_types) - actual = ( - actual_sqlite_type.value - if actual_sqlite_type is not None - else "unrecognized {!r}".format(column_detail.type) - ) - raise ValueError( - "Column type {!r} is only applicable to SQLite types {} but {}.{}.{} " - "has SQLite type {}".format( - ct_cls.name, - allowed, - database, - resource, - column, - actual, - ) - ) - - async def _apply_column_types_config(self): - """Load column_types from datasette.json config into the internal DB.""" - import logging - - for db_name, db_conf in (self.config or {}).get("databases", {}).items(): - for table_name, table_conf in db_conf.get("tables", {}).items(): - for col_name, ct in table_conf.get("column_types", {}).items(): - if isinstance(ct, str): - col_type, config = ct, None - else: - col_type = ct["type"] - config = ct.get("config") - if col_type not in self._column_types: - logging.warning( - "column_types config references unknown type %r " - "for %s.%s.%s", - col_type, - db_name, - table_name, - col_name, - ) - try: - await self.set_column_type( - db_name, table_name, col_name, col_type, config - ) - except ValueError as ex: - logging.warning(str(ex)) - - async def get_column_type(self, database: str, resource: str, column: str): - """ - Return a ColumnType instance (with config baked in) for a specific - column, or None if no column type is assigned. - """ - row = await self.get_internal_database().execute( - "SELECT column_type, config FROM column_types " - "WHERE database_name = ? AND resource_name = ? AND column_name = ?", - [database, resource, column], - ) - rows = row.rows - if not rows: - return None - ct_name, config = rows[0] - ct_cls = self._column_types.get(ct_name) - if ct_cls is None: - return None - column_detail = ( - await self._get_resource_column_details(database, resource) - ).get(column) - if not self._column_type_is_applicable(ct_cls, column_detail): - return None - return ct_cls(config=json.loads(config) if config else None) - - async def get_column_types(self, database: str, resource: str) -> dict: - """ - Return {column_name: ColumnType instance (with config)} - for all columns with assigned types on the given resource. - """ - rows = await self.get_internal_database().execute( - "SELECT column_name, column_type, config FROM column_types " - "WHERE database_name = ? AND resource_name = ?", - [database, resource], - ) - column_details = await self._get_resource_column_details(database, resource) - result = {} - for row in rows.rows: - col_name, ct_name, config = row - ct_cls = self._column_types.get(ct_name) - if ct_cls is not None and self._column_type_is_applicable( - ct_cls, column_details.get(col_name) - ): - result[col_name] = ct_cls(config=json.loads(config) if config else None) - return result - - async def set_column_type( - self, - database: str, - resource: str, - column: str, - column_type: str, - config: dict = None, - ) -> None: - """Assign a column type. Overwrites any existing assignment.""" - ct_cls = self._column_types.get(column_type) - if ct_cls is not None: - await self._validate_column_type_assignment( - database, resource, column, ct_cls - ) - await self.get_internal_database().execute_write( - """INSERT OR REPLACE INTO column_types - (database_name, resource_name, column_name, column_type, config) - VALUES (?, ?, ?, ?, ?)""", - [ - database, - resource, - column, - column_type, - json.dumps(config) if config else None, - ], - ) - - async def remove_column_type( - self, database: str, resource: str, column: str - ) -> None: - """Remove a column type assignment.""" - await self.get_internal_database().execute_write( - "DELETE FROM column_types " - "WHERE database_name = ? AND resource_name = ? AND column_name = ?", - [database, resource, column], - ) - - def get_internal_database(self): - return self._internal_database - - def plugin_config(self, plugin_name, database=None, table=None, fallback=True): - """Return config for plugin, falling back from specified database/table""" - if database is None and table is None: - config = self._plugin_config_top(plugin_name) - else: - config = self._plugin_config_nested(plugin_name, database, table, fallback) - - return resolve_env_secrets(config, os.environ) - - def _plugin_config_top(self, plugin_name): - """Returns any top-level plugin configuration for the specified plugin.""" - return ((self.config or {}).get("plugins") or {}).get(plugin_name) - - def _plugin_config_nested(self, plugin_name, database, table=None, fallback=True): - """Returns any database or table-level plugin configuration for the specified plugin.""" - db_config = ((self.config or {}).get("databases") or {}).get(database) - - # if there's no db-level configuration, then return early, falling back to top-level if needed - if not db_config: - return self._plugin_config_top(plugin_name) if fallback else None - - db_plugin_config = (db_config.get("plugins") or {}).get(plugin_name) - - if table: - table_plugin_config = ( - ((db_config.get("tables") or {}).get(table) or {}).get("plugins") or {} - ).get(plugin_name) - - # fallback to db_config or top-level config, in that order, if needed - if table_plugin_config is None and fallback: - return db_plugin_config or self._plugin_config_top(plugin_name) - - return table_plugin_config - - # fallback to top-level if needed - if db_plugin_config is None and fallback: - self._plugin_config_top(plugin_name) - - return db_plugin_config - - def static_hash(self, filename): - if not hasattr(self, "_static_hashes"): - self._static_hashes = {} - path = os.path.join(str(app_root), "datasette/static", filename) - signature = (os.path.getmtime(path), os.path.getsize(path)) - cached = self._static_hashes.get(filename) - if cached and cached["signature"] == signature: - return cached["hash"] - with open(path) as fp: - static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6] - self._static_hashes[filename] = { - "signature": signature, - "hash": static_hash, - } - return static_hash - - def app_css_hash(self): - return self.static_hash("app.css") - - def _prepare_connection(self, conn, database): - conn.row_factory = sqlite3.Row - conn.text_factory = lambda x: str(x, "utf-8", "replace") - if self.sqlite_extensions and database != INTERNAL_DB_NAME: - conn.enable_load_extension(True) - for extension in self.sqlite_extensions: - # "extension" is either a string path to the extension - # or a 2-item tuple that specifies which entrypoint to load. - if isinstance(extension, tuple): - path, entrypoint = extension - conn.execute("SELECT load_extension(?, ?)", [path, entrypoint]) - else: - conn.execute("SELECT load_extension(?)", [extension]) - if self.setting("cache_size_kb"): - conn.execute(f"PRAGMA cache_size=-{self.setting('cache_size_kb')}") - # pylint: disable=no-member - if database != INTERNAL_DB_NAME: - pm.hook.prepare_connection(conn=conn, database=database, datasette=self) - # If self.crossdb and this is _memory, connect the first SQLITE_LIMIT_ATTACHED databases - if self.crossdb and database == "_memory": - count = 0 - for db_name, db in self.databases.items(): - if count >= SQLITE_LIMIT_ATTACHED or db.is_memory: - continue - sql = 'ATTACH DATABASE "file:{path}?{qs}" AS [{name}];'.format( - path=db.path, - qs="mode=ro" if db.is_mutable else "immutable=1", - name=db_name, - ) - conn.execute(sql) - count += 1 - - def add_message(self, request, message, type=INFO): - if not hasattr(request, "_messages"): - request._messages = [] - request._messages_should_clear = False - request._messages.append((message, type)) - - def _write_messages_to_response(self, request, response): - if getattr(request, "_messages", None): - # Set those messages - response.set_cookie("ds_messages", self.sign(request._messages, "messages")) - elif getattr(request, "_messages_should_clear", False): - response.set_cookie("ds_messages", "", expires=0, max_age=0) - - def _show_messages(self, request): - if getattr(request, "_messages", None): - request._messages_should_clear = True - messages = request._messages - request._messages = [] - return messages - else: - return [] - - async def _crumb_items(self, request, table=None, database=None): - crumbs = [] - actor = None - if request: - actor = request.actor - # Top-level link - if await self.allowed(action="view-instance", actor=actor): - crumbs.append({"href": self.urls.instance(), "label": "home"}) - # Database link - if database: - if await self.allowed( - action="view-database", - resource=DatabaseResource(database=database), - actor=actor, - ): - crumbs.append( - { - "href": self.urls.database(database), - "label": database, - } - ) - # Table link - if table: - assert database, "table= requires database=" - if await self.allowed( - action="view-table", - resource=TableResource(database=database, table=table), - actor=actor, - ): - crumbs.append( - { - "href": self.urls.table(database, table), - "label": table, - } - ) - return crumbs - - async def actors_from_ids( - self, actor_ids: Iterable[str | int] - ) -> Dict[int | str, Dict]: - result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids) - if result is None: - # Do the default thing - return {actor_id: {"id": actor_id} for actor_id in actor_ids} - result = await await_me_maybe(result) - return result - - async def track_event(self, event: Event): - assert isinstance(event, self.event_classes), "Invalid event type: {}".format( - type(event) - ) - for hook in pm.hook.track_event(datasette=self, event=event): - await await_me_maybe(hook) - - def resource_for_action(self, action: str, parent: str | None, child: str | None): - """ - Create a Resource instance for the given action with parent/child values. - - Looks up the action's resource_class and instantiates it with the - provided parent and child identifiers. - - Args: - action: The action name (e.g., "view-table", "view-query") - parent: The parent resource identifier (e.g., database name) - child: The child resource identifier (e.g., table/query name) - - Returns: - A Resource instance of the appropriate subclass - - Raises: - ValueError: If the action is unknown - """ - from datasette.permissions import Resource - - action_obj = self.actions.get(action) - if not action_obj: - raise ValueError(f"Unknown action: {action}") - - resource_class = action_obj.resource_class - instance = object.__new__(resource_class) - Resource.__init__(instance, parent=parent, child=child) - return instance - - async def check_visibility( - self, - actor: dict, - action: str, - resource: "Resource" | None = None, - ): - """ - Check if actor can see a resource and if it's private. - - Returns (visible, private) tuple: - - visible: bool - can the actor see it? - - private: bool - if visible, can anonymous users NOT see it? - """ - from datasette.permissions import Resource - - # Validate that resource is a Resource object or None - if resource is not None and not isinstance(resource, Resource): - raise TypeError("resource must be a Resource subclass instance or None.") - - # Check if actor can see it - if not await self.allowed(action=action, resource=resource, actor=actor): - return False, False - - # Check if anonymous user can see it (for "private" flag) - if not await self.allowed(action=action, resource=resource, actor=None): - # Actor can see it but anonymous cannot - it's private - return True, True - - # Both actor and anonymous can see it - it's public - return True, False - - async def allowed_resources_sql( - self, - *, - action: str, - actor: dict | None = None, - parent: str | None = None, - include_is_private: bool = False, - ) -> ResourcesSQL: - """ - Build SQL query to get all resources the actor can access for the given action. - - Args: - action: The action name (e.g., "view-table") - actor: The actor dict (or None for unauthenticated) - parent: Optional parent filter (e.g., database name) to limit results - include_is_private: If True, include is_private column showing if anonymous cannot access - - Returns a namedtuple of (query: str, params: dict) that can be executed against the internal database. - The query returns rows with (parent, child, reason) columns, plus is_private if requested. - - Example: - query, params = await datasette.allowed_resources_sql( - action="view-table", - actor=actor, - parent="mydb", - include_is_private=True - ) - result = await datasette.get_internal_database().execute(query, params) - """ - from datasette.utils.actions_sql import build_allowed_resources_sql - - action_obj = self.actions.get(action) - if not action_obj: - raise ValueError(f"Unknown action: {action}") - - sql, params = await build_allowed_resources_sql( - self, actor, action, parent=parent, include_is_private=include_is_private - ) - return ResourcesSQL(sql, params) - - async def allowed_resources( - self, - action: str, - actor: dict | None = None, - *, - parent: str | None = None, - include_is_private: bool = False, - include_reasons: bool = False, - limit: int = 100, - next: str | None = None, - ) -> PaginatedResources: - """ - Return paginated resources the actor can access for the given action. - - Uses SQL with keyset pagination to efficiently filter resources. - Returns PaginatedResources with list of Resource instances and pagination metadata. - - Args: - action: The action name (e.g., "view-table") - actor: The actor dict (or None for unauthenticated) - parent: Optional parent filter (e.g., database name) to limit results - include_is_private: If True, adds a .private attribute to each Resource - include_reasons: If True, adds a .reasons attribute with List[str] of permission reasons - limit: Maximum number of results to return (1-1000, default 100) - next: Keyset token from previous page for pagination - - Returns: - PaginatedResources with: - - resources: List of Resource objects for this page - - next: Token for next page (None if no more results) - - Example: - # Get first page of tables - page = await datasette.allowed_resources("view-table", actor, limit=50) - for table in page.resources: - print(f"{table.parent}/{table.child}") - - # Get next page - if page.next: - next_page = await datasette.allowed_resources( - "view-table", actor, limit=50, next=page.next - ) - - # With reasons for debugging - page = await datasette.allowed_resources( - "view-table", actor, include_reasons=True - ) - for table in page.resources: - print(f"{table.child}: {table.reasons}") - - # Iterate through all results with async generator - page = await datasette.allowed_resources("view-table", actor) - async for table in page.all(): - print(table.child) - """ - - action_obj = self.actions.get(action) - if not action_obj: - raise ValueError(f"Unknown action: {action}") - - # Validate and cap limit - limit = min(max(1, limit), 1000) - - # Get base SQL query - query, params = await self.allowed_resources_sql( - action=action, - actor=actor, - parent=parent, - include_is_private=include_is_private, - ) - - # Add keyset pagination WHERE clause if next token provided - if next: - try: - components = urlsafe_components(next) - if len(components) >= 2: - last_parent, last_child = components[0], components[1] - # Keyset condition: (parent > last) OR (parent = last AND child > last) - keyset_where = """ - (parent > :keyset_parent OR - (parent = :keyset_parent AND child > :keyset_child)) - """ - # Wrap original query and add keyset filter - query = f"SELECT * FROM ({query}) WHERE {keyset_where}" - params["keyset_parent"] = last_parent - params["keyset_child"] = last_child - except (ValueError, KeyError): - # Invalid token - ignore and start from beginning - pass - - # Add LIMIT (fetch limit+1 to detect if there are more results) - # Note: query from allowed_resources_sql() already includes ORDER BY parent, child - query = f"{query} LIMIT :limit" - params["limit"] = limit + 1 - - # Execute query - result = await self.get_internal_database().execute(query, params) - rows = list(result.rows) - - # Check if truncated (got more than limit rows) - truncated = len(rows) > limit - if truncated: - rows = rows[:limit] # Remove the extra row - - # Build Resource objects with optional attributes - resources = [] - for row in rows: - # row[0]=parent, row[1]=child, row[2]=reason, row[3]=is_private (if requested) - resource = self.resource_for_action(action, parent=row[0], child=row[1]) - - # Add reasons if requested - if include_reasons: - reason_json = row[2] - try: - reasons_array = ( - json.loads(reason_json) if isinstance(reason_json, str) else [] - ) - resource.reasons = [r for r in reasons_array if r is not None] - except (json.JSONDecodeError, TypeError): - resource.reasons = [reason_json] if reason_json else [] - - # Add private flag if requested - if include_is_private: - resource.private = bool(row[3]) - - resources.append(resource) - - # Generate next token if there are more results - next_token = None - if truncated and resources: - last_resource = resources[-1] - # Use tilde-encoding like table pagination - next_token = "{},{}".format( - tilde_encode(str(last_resource.parent)), - tilde_encode(str(last_resource.child)), - ) - - return PaginatedResources( - resources=resources, - next=next_token, - _datasette=self, - _action=action, - _actor=actor, - _parent=parent, - _include_is_private=include_is_private, - _include_reasons=include_reasons, - _limit=limit, - ) - - async def allowed( - self, - *, - action: str, - resource: "Resource" = None, - actor: dict | None = None, - ) -> bool: - """ - Check if actor can perform action on specific resource. - - Uses SQL to check permission for a single resource without fetching all resources. - This is efficient - it does NOT call allowed_resources() and check membership. - - For global actions, resource should be None (or omitted). - - Example: - from datasette.resources import TableResource - can_view = await datasette.allowed( - action="view-table", - resource=TableResource(database="analytics", table="users"), - actor=actor - ) - - # For global actions, resource can be omitted: - can_debug = await datasette.allowed(action="permissions-debug", actor=actor) - """ - results = await self.allowed_many( - actions=[action], resource=resource, actor=actor - ) - return results[action] - - async def allowed_many( - self, - *, - actions: Sequence[str], - resource: "Resource" = None, - actor: dict | None = None, - ) -> dict[str, bool]: - """ - Check several actions against one resource for one actor. - - Resolves every action (plus any also_requires dependencies) with a - single internal database query, instead of one or two queries per - action. Results are stored in the request-scoped permission cache, - so subsequent datasette.allowed() calls for the same checks within - the same request are served from the cache. - - Example: - from datasette.resources import TableResource - results = await datasette.allowed_many( - actions=["edit-schema", "drop-table", "insert-row"], - resource=TableResource(database="data", table="exercise"), - actor=actor, - ) - # {"edit-schema": True, "drop-table": True, "insert-row": False} - """ - from datasette.utils.actions_sql import check_permissions_for_actions - from datasette.permissions import ( - _permission_check_cache, - _skip_permission_checks, - ) - - # For global actions, resource is None - parent = resource.parent if resource else None - child = resource.child if resource else None - - # Expand also_requires dependencies (transitively) so that each - # dependency is resolved within the same batch - expanded = [] - - def add_action(name): - if name in expanded: - return - action_obj = self.actions.get(name) - if action_obj is None: - raise ValueError(f"Unknown action: {name}") - expanded.append(name) - if action_obj.also_requires: - add_action(action_obj.also_requires) - - requested = list(dict.fromkeys(actions)) - for name in requested: - add_action(name) - - # Consult the request-scoped cache, unless permission checks are - # being skipped (skip-mode verdicts must never be cached) - skip = _skip_permission_checks.get() - cache = None if skip else _permission_check_cache.get() - - final = {} - to_check = [] - for name in expanded: - if cache is not None: - key = _permission_cache_key(actor, name, parent, child) - if key in cache: - final[name] = cache[key] - continue - to_check.append(name) - - raw = {} - if to_check: - raw = await check_permissions_for_actions( - datasette=self, - actor=actor, - actions=to_check, - parent=parent, - child=child, - ) - - def resolve(name): - # final verdict = own rules AND verdict of also_requires chain - if name in final: - return final[name] - result = raw[name] - action_obj = self.actions.get(name) - if result and action_obj.also_requires: - result = resolve(action_obj.also_requires) - final[name] = result - return result - - for name in expanded: - resolve(name) - - # Cache the freshly computed checks - if cache is not None: - for name in to_check: - cache[_permission_cache_key(actor, name, parent, child)] = final[name] - - # Log every check (including cache hits) for the debug page, - # dependencies before the actions that required them - when = datetime.datetime.now(datetime.timezone.utc).isoformat() - for name in reversed(expanded): - self._permission_checks.append( - PermissionCheck( - when=when, - actor=actor, - action=name, - parent=parent, - child=child, - result=final[name], - ) - ) - - return {name: final[name] for name in requested} - - async def ensure_permission( - self, - *, - action: str, - resource: "Resource" = None, - actor: dict | None = None, - ): - """ - Check if actor can perform action on resource, raising Forbidden if not. - - This is a convenience wrapper around allowed() that raises Forbidden - instead of returning False. Use this when you want to enforce a permission - check and halt execution if it fails. - - Example: - from datasette.resources import TableResource - - # Will raise Forbidden if actor cannot view the table - await datasette.ensure_permission( - action="view-table", - resource=TableResource(database="analytics", table="users"), - actor=request.actor - ) - - # For instance-level actions, resource can be omitted: - await datasette.ensure_permission( - action="permissions-debug", - actor=request.actor - ) - """ - if not await self.allowed(action=action, resource=resource, actor=actor): - raise Forbidden(action) - - async def execute( - self, - db_name, - sql, - params=None, - truncate=False, - custom_time_limit=None, - page_size=None, - log_sql_errors=True, - ): - return await self.databases[db_name].execute( - sql, - params=params, - truncate=truncate, - custom_time_limit=custom_time_limit, - page_size=page_size, - log_sql_errors=log_sql_errors, - ) - - async def expand_foreign_keys(self, actor, database, table, column, values): - """Returns dict mapping (column, value) -> label""" - labeled_fks = {} - db = self.databases[database] - foreign_keys = await db.foreign_keys_for_table(table) - # Find the foreign_key for this column - try: - fk = [ - foreign_key - for foreign_key in foreign_keys - if foreign_key["column"] == column - ][0] - except IndexError: - return {} - # Ensure user has permission to view the referenced table - from datasette.resources import TableResource - - other_table = fk["other_table"] - other_column = fk["other_column"] - visible, _ = await self.check_visibility( - actor, - action="view-table", - resource=TableResource(database=database, table=other_table), - ) - if not visible: - return {} - label_column = await db.label_column_for_table(other_table) - if not label_column: - return {(fk["column"], value): str(value) for value in values} - labeled_fks = {} - sql = """ - select {other_column}, {label_column} - from {other_table} - where {other_column} in ({placeholders}) - """.format( - other_column=escape_sqlite(other_column), - label_column=escape_sqlite(label_column), - other_table=escape_sqlite(other_table), - placeholders=", ".join(["?"] * len(set(values))), - ) - try: - results = await self.execute(database, sql, list(set(values))) - except QueryInterrupted: - pass - else: - for id, value in results: - labeled_fks[(fk["column"], id)] = value - return labeled_fks - - def absolute_url(self, request, path): - url = urllib.parse.urljoin(request.url, path) - if url.startswith("http://") and self.setting("force_https_urls"): - url = "https://" + url[len("http://") :] - return url - - def _connected_databases(self): - return [ - { - "name": d.name, - "route": d.route, - "path": d.path, - "size": d.size, - "is_mutable": d.is_mutable, - "is_memory": d.is_memory, - "hash": d.hash, - } - for name, d in self.databases.items() - ] - - def _versions(self): - conn = sqlite3.connect(":memory:") - self._prepare_connection(conn, "_memory") - sqlite_version = conn.execute("select sqlite_version()").fetchone()[0] - sqlite_extensions = {"json1": detect_json1(conn)} - for extension, testsql, hasversion in ( - ("spatialite", "SELECT spatialite_version()", True), - ): - try: - result = conn.execute(testsql) - if hasversion: - sqlite_extensions[extension] = result.fetchone()[0] - else: - sqlite_extensions[extension] = None - except Exception: - pass - # More details on SpatiaLite - if "spatialite" in sqlite_extensions: - spatialite_details = {} - for fn in SPATIALITE_FUNCTIONS: - try: - result = conn.execute("select {}()".format(fn)) - spatialite_details[fn] = result.fetchone()[0] - except Exception as e: - spatialite_details[fn] = {"error": str(e)} - sqlite_extensions["spatialite"] = spatialite_details - - # Figure out supported FTS versions - fts_versions = [] - for fts in ("FTS5", "FTS4", "FTS3"): - try: - conn.execute( - "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts) - ) - fts_versions.append(fts) - except sqlite3.OperationalError: - continue - datasette_version = {"version": __version__} - if self.version_note: - datasette_version["note"] = self.version_note - - try: - # Optional import to avoid breaking Pyodide - # https://github.com/simonw/datasette/issues/1733#issuecomment-1115268245 - import uvicorn - - uvicorn_version = uvicorn.__version__ - except ImportError: - uvicorn_version = None - info = { - "python": { - "version": ".".join(map(str, sys.version_info[:3])), - "full": sys.version, - }, - "datasette": datasette_version, - "asgi": "3.0", - "uvicorn": uvicorn_version, - "sqlite": { - "version": sqlite_version, - "fts_versions": fts_versions, - "extensions": sqlite_extensions, - "compile_options": [ - r[0] for r in conn.execute("pragma compile_options;").fetchall() - ], - }, - } - if using_pysqlite3: - for package in ("pysqlite3", "pysqlite3-binary"): - try: - info["pysqlite3"] = importlib.metadata.version(package) - break - except importlib.metadata.PackageNotFoundError: - pass - conn.close() - return info - - def _plugins(self, request=None, all=False): - ps = list(get_plugins()) - should_show_all = False - if request is not None: - should_show_all = request.args.get("all") - else: - should_show_all = all - if not should_show_all: - ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS] - ps.sort(key=lambda p: p["name"]) - return [ - { - "name": p["name"], - "static": p["static_path"] is not None, - "templates": p["templates_path"] is not None, - "version": p.get("version"), - "hooks": list(sorted(set(p["hooks"]))), - } - for p in ps - ] - - def _threads(self): - if self.setting("num_sql_threads") == 0: - return {"num_threads": 0, "threads": []} - threads = list(threading.enumerate()) - d = { - "num_threads": len(threads), - "threads": [ - {"name": t.name, "ident": t.ident, "daemon": t.daemon} for t in threads - ], - } - tasks = asyncio.all_tasks() - d.update( - { - "num_tasks": len(tasks), - "tasks": [_cleaner_task_str(t) for t in tasks], - } - ) - return d - - def _actor(self, request): - return {"actor": request.actor} - - def _actions(self): - return [ - { - "name": action.name, - "abbr": action.abbr, - "description": action.description, - "takes_parent": action.takes_parent, - "takes_child": action.takes_child, - "resource_class": ( - action.resource_class.__name__ if action.resource_class else None - ), - "also_requires": action.also_requires, - } - for action in sorted(self.actions.values(), key=lambda a: a.name) - ] - - async def table_config(self, database: str, table: str) -> dict: - """Return dictionary of configuration for specified table""" - return ( - (self.config or {}) - .get("databases", {}) - .get(database, {}) - .get("tables", {}) - .get(table, {}) - ) - - def _register_renderers(self): - """Register output renderers which output data in custom formats.""" - # Built-in renderers - self.renderers["json"] = (json_renderer, lambda: True) - - # Hooks - hook_renderers = [] - # pylint: disable=no-member - for hook in pm.hook.register_output_renderer(datasette=self): - if type(hook) is list: - hook_renderers += hook - else: - hook_renderers.append(hook) - - for renderer in hook_renderers: - self.renderers[renderer["extension"]] = ( - # It used to be called "callback" - remove this in Datasette 1.0 - renderer.get("render") or renderer["callback"], - renderer.get("can_render") or (lambda: True), - ) - - async def render_template( - self, - templates: List[str] | str | Template, - context: Dict[str, Any] | Context | None = None, - request: Request | None = None, - view_name: str | None = None, - ): - if not self._startup_invoked: - raise Exception("render_template() called before await ds.invoke_startup()") - context = context or {} - if isinstance(templates, Template): - template = templates - else: - if isinstance(templates, str): - templates = [templates] - template = self.get_jinja_environment(request).select_template(templates) - if dataclasses.is_dataclass(context): - context = dataclasses.asdict(context) - body_scripts = [] - # pylint: disable=no-member - for extra_script in pm.hook.extra_body_script( - template=template.name, - database=context.get("database"), - table=context.get("table"), - columns=context.get("columns"), - view_name=view_name, - request=request, - datasette=self, - ): - extra_script = await await_me_maybe(extra_script) - if isinstance(extra_script, dict): - script = extra_script["script"] - module = bool(extra_script.get("module")) - else: - script = extra_script - module = False - body_scripts.append({"script": Markup(script), "module": module}) - - extra_template_vars = {} - # pylint: disable=no-member - for extra_vars in pm.hook.extra_template_vars( - template=template.name, - database=context.get("database"), - table=context.get("table"), - columns=context.get("columns"), - view_name=view_name, - request=request, - datasette=self, - ): - extra_vars = await await_me_maybe(extra_vars) - assert isinstance(extra_vars, dict), "extra_vars is of type {}".format( - type(extra_vars) - ) - extra_template_vars.update(extra_vars) - - async def menu_links(): - links = [] - for hook in pm.hook.menu_links( - datasette=self, - actor=request.actor if request else None, - request=request or None, - ): - extra_links = await await_me_maybe(hook) - if extra_links: - links.extend(extra_links) - return links - - template_context = { - **context, - **{ - "request": request, - "crumb_items": self._crumb_items, - "urls": self.urls, - "actor": request.actor if request else None, - "menu_links": menu_links, - "display_actor": display_actor, - "show_logout": request is not None - and "ds_actor" in request.cookies - and request.actor, - "app_css_hash": self.app_css_hash(), - "zip": zip, - "body_scripts": body_scripts, - "format_bytes": format_bytes, - "show_messages": lambda: self._show_messages(request), - "extra_css_urls": await self._asset_urls( - "extra_css_urls", template, context, request, view_name - ), - "extra_js_urls": await self._asset_urls( - "extra_js_urls", template, context, request, view_name - ), - "base_url": self.setting("base_url"), - "csrftoken": ( - request.scope["csrftoken"] - if request and "csrftoken" in request.scope - else lambda: "" - ), - "datasette_version": __version__, - }, - **extra_template_vars, - } - if request and request.args.get("_context") and self.setting("template_debug"): - return "
{}
".format( - escape(json.dumps(template_context, default=repr, indent=4)) - ) - - return await template.render_async(template_context) - - def set_actor_cookie( - self, response: Response, actor: dict, expire_after: int | None = None - ): - data = {"a": actor} - if expire_after: - expires_at = int(time.time()) + (24 * 60 * 60) - data["e"] = baseconv.base62.encode(expires_at) - response.set_cookie("ds_actor", self.sign(data, "actor")) - - def delete_actor_cookie(self, response: Response): - response.set_cookie("ds_actor", "", expires=0, max_age=0) - - async def _asset_urls(self, key, template, context, request, view_name): - # Flatten list-of-lists from plugins: - seen_urls = set() - collected = [] - for hook in getattr(pm.hook, key)( - template=template.name, - database=context.get("database"), - table=context.get("table"), - columns=context.get("columns"), - view_name=view_name, - request=request, - datasette=self, - ): - hook = await await_me_maybe(hook) - collected.extend(hook) - collected.extend((self.config or {}).get(key) or []) - output = [] - for url_or_dict in collected: - if isinstance(url_or_dict, dict): - url = url_or_dict["url"] - sri = url_or_dict.get("sri") - module = bool(url_or_dict.get("module")) - else: - url = url_or_dict - sri = None - module = False - if url in seen_urls: - continue - seen_urls.add(url) - if url.startswith("/"): - # Take base_url into account: - url = self.urls.path(url) - script = {"url": url} - if sri: - script["sri"] = sri - if module: - script["module"] = True - output.append(script) - return output - - def _config(self): - return redact_keys( - self.config, ("secret", "key", "password", "token", "hash", "dsn") - ) - - def _routes(self): - routes = [] - - for routes_to_add in pm.hook.register_routes(datasette=self): - for regex, view_fn in routes_to_add: - routes.append((regex, wrap_view(view_fn, self))) - - def add_route(view, regex): - routes.append((regex, view)) - - add_route(IndexView.as_view(self), r"/(\.(?Pjsono?))?$") - add_route(IndexView.as_view(self), r"/-/(\.(?Pjsono?))?$") - add_route(permanent_redirect("/-/"), r"/-$") + self.jinja.add_env('escape_css_string', escape_css_string, 'filters') + self.jinja.add_env('quote_plus', lambda u: urllib.parse.quote_plus(u), 'filters') + self.jinja.add_env('escape_table_name', escape_sqlite_table_name, 'filters') + app.add_route(IndexView.as_view(self), '/') # TODO: /favicon.ico and /-/static/ deserve far-future cache expires - add_route(favicon, "/favicon.ico") - - add_route( - asgi_static(app_root / "datasette" / "static"), r"/-/static/(?P.*)$" + app.add_route(favicon, '/favicon.ico') + app.static('/-/static/', str(app_root / 'datasette' / 'static')) + app.add_route( + DatabaseView.as_view(self), + '/' ) - for path, dirname in self.static_mounts: - add_route(asgi_static(dirname), r"/" + path + "/(?P.*)$") - - # Mount any plugin static/ directories - for plugin in get_plugins(): - if plugin["static_path"]: - add_route( - asgi_static(plugin["static_path"]), - f"/-/static-plugins/{plugin['name']}/(?P.*)$", - ) - # Support underscores in name in addition to hyphens, see https://github.com/simonw/datasette/issues/611 - add_route( - asgi_static(plugin["static_path"]), - "/-/static-plugins/{}/(?P.*)$".format( - plugin["name"].replace("-", "_") - ), - ) - add_route( - permanent_redirect( - "/_memory", forward_query_string=True, forward_rest=True - ), - r"/:memory:(?P.*)$", + app.add_route( + DatabaseDownload.as_view(self), + '/' ) - add_route( - JsonDataView.as_view(self, "versions.json", self._versions), - r"/-/versions(\.(?Pjson))?$", + app.add_route( + TableView.as_view(self), + '//' ) - add_route( - JsonDataView.as_view( - self, "plugins.json", self._plugins, needs_request=True - ), - r"/-/plugins(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view(self, "settings.json", lambda: self._settings), - r"/-/settings(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view(self, "config.json", lambda: self._config()), - r"/-/config(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view(self, "threads.json", self._threads), - r"/-/threads(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view(self, "databases.json", self._connected_databases), - r"/-/databases(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view( - self, "actor.json", self._actor, needs_request=True, permission=None - ), - r"/-/actor(\.(?Pjson))?$", - ) - add_route( - JsonDataView.as_view( - self, - "actions.json", - self._actions, - template="debug_actions.html", - permission="permissions-debug", - ), - r"/-/actions(\.(?Pjson))?$", - ) - add_route( - AuthTokenView.as_view(self), - r"/-/auth-token$", - ) - add_route( - CreateTokenView.as_view(self), - r"/-/create-token$", - ) - add_route( - ApiExplorerView.as_view(self), - r"/-/api$", - ) - add_route( - JumpView.as_view(self), - r"/-/jump(\.(?Pjson))?$", - ) - add_route( - GlobalQueryListView.as_view(self), - r"/-/queries(\.(?Pjson))?$", - ) - add_route( - InstanceSchemaView.as_view(self), - r"/-/schema(\.(?Pjson|md))?$", - ) - add_route( - LogoutView.as_view(self), - r"/-/logout$", - ) - add_route( - PermissionsDebugView.as_view(self), - r"/-/permissions$", - ) - add_route( - AllowedResourcesView.as_view(self), - r"/-/allowed(\.(?Pjson))?$", - ) - add_route( - PermissionRulesView.as_view(self), - r"/-/rules(\.(?Pjson))?$", - ) - add_route( - PermissionCheckView.as_view(self), - r"/-/check(\.(?Pjson))?$", - ) - add_route( - MessagesDebugView.as_view(self), - r"/-/messages$", - ) - add_route( - AllowDebugView.as_view(self), - r"/-/allow-debug$", - ) - add_route( - wrap_view(PatternPortfolioView, self), - r"/-/patterns$", - ) - add_route( - wrap_view(database_download, self), - r"/(?P[^\/\.]+)\.db$", - ) - add_route( - wrap_view(DatabaseView, self), - r"/(?P[^\/\.]+)(\.(?P\w+))?$", - ) - add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") - add_route( - QueryListView.as_view(self), - r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", - ) - add_route( - QueryCreateAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/analyze$", - ) - add_route( - QueryStoreView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/store$", - ) - add_route( - ExecuteWriteAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write/analyze$", - ) - add_route( - ExecuteWriteView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write$", - ) - add_route( - DatabaseSchemaView.as_view(self), - r"/(?P[^\/\.]+)/-/schema(\.(?Pjson|md))?$", - ) - add_route( - QueryParametersView.as_view(self), - r"/(?P[^\/\.]+)/-/query/parameters$", - ) - add_route( - wrap_view(QueryView, self), - r"/(?P[^\/\.]+)/-/query(\.(?P\w+))?$", - ) - add_route( - QueryDefinitionView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/definition$", - ) - add_route( - QueryEditView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/edit$", - ) - add_route( - QueryUpdateView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/update$", - ) - add_route( - QueryDeleteView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/delete$", - ) - add_route( - wrap_view(table_view, self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)(\.(?P\w+))?$", - ) - add_route( + app.add_route( RowView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^/]+?)/(?P[^/]+?)(\.(?P\w+))?$", + '///' ) - add_route( - TableInsertView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/insert$", - ) - add_route( - TableUpsertView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/upsert$", - ) - add_route( - TableSetColumnTypeView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", - ) - add_route( - TableDropView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", - ) - add_route( - TableSchemaView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/schema(\.(?Pjson|md))?$", - ) - add_route( - RowDeleteView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^/]+?)/(?P[^/]+?)/-/delete$", - ) - add_route( - RowUpdateView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^/]+?)/(?P[^/]+?)/-/update$", - ) - return [ - # Compile any strings to regular expressions - ((re.compile(pattern) if isinstance(pattern, str) else pattern), view) - for pattern, view in routes - ] - - async def resolve_database(self, request): - database_route = tilde_decode(request.url_vars["database"]) - try: - return self.get_database(route=database_route) - except KeyError: - raise DatabaseNotFound(database_route) - - async def resolve_table(self, request): - db = await self.resolve_database(request) - table_name = tilde_decode(request.url_vars["table"]) - # Table must exist - is_view = False - table_exists = await db.table_exists(table_name) - if not table_exists: - is_view = await db.view_exists(table_name) - if not (table_exists or is_view): - raise TableNotFound(db.name, table_name) - return ResolvedTable(db, table_name, is_view) - - async def resolve_row(self, request): - db, table_name, _ = await self.resolve_table(request) - pk_values = urlsafe_components(request.url_vars["pks"]) - sql, params, pks = await row_sql_params_pks(db, table_name, pk_values) - results = await db.execute(sql, params, truncate=True) - row = results.first() - if row is None: - raise RowNotFound(db.name, table_name, pk_values) - return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first()) - - def app(self): - """Returns an ASGI app function that serves the whole of Datasette""" - routes = self._routes() - - async def setup_db(): - # First time server starts up, calculate table counts for immutable databases - for database in self.databases.values(): - if not database.is_mutable: - await database.table_counts(limit=60 * 60 * 1000) - - async def _close_on_shutdown(): - self.close() - - asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self) - if self.setting("trace_debug"): - asgi = AsgiTracer(asgi) - asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown]) - asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup]) - for wrapper in pm.hook.asgi_wrapper(datasette=self): - asgi = wrapper(asgi) - return asgi - - -class DatasetteRouter: - def __init__(self, datasette, routes): - self.ds = datasette - self.routes = routes or [] - - async def __call__(self, scope, receive, send): - # Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves - path = scope["path"] - raw_path = scope.get("raw_path") - if raw_path: - path = raw_path.decode("ascii") - path = path.partition("?")[0] - # Give each request a fresh permission check cache, so repeated - # datasette.allowed() checks within the request are memoized but - # results never persist beyond it - from datasette.permissions import _permission_check_cache - - cache_token = _permission_check_cache.set({}) - try: - return await self.route_path(scope, receive, send, path) - finally: - _permission_check_cache.reset(cache_token) - - async def route_path(self, scope, receive, send, path): - # Strip off base_url if present before routing - base_url = self.ds.setting("base_url") - if base_url != "/" and path.startswith(base_url): - path = "/" + path[len(base_url) :] - scope = dict(scope, route_path=path) - request = Request(scope, receive) - # Populate request_messages if ds_messages cookie is present - try: - request._messages = self.ds.unsign( - request.cookies.get("ds_messages", ""), "messages" - ) - except BadSignature: - pass - - scope_modifications = {} - # Apply force_https_urls, if set - if ( - self.ds.setting("force_https_urls") - and scope["type"] == "http" - and scope.get("scheme") != "https" - ): - scope_modifications["scheme"] = "https" - # Handle authentication - default_actor = scope.get("actor") or None - actor = None - results = pm.hook.actor_from_request(datasette=self.ds, request=request) - for result in results: - result = await await_me_maybe(result) - if result and actor is None: - actor = result - # Don't break — we must await all coroutines to avoid - # "coroutine was never awaited" warnings - scope_modifications["actor"] = actor or default_actor - scope = dict(scope, **scope_modifications) - - match, view = resolve_routes(self.routes, path) - - if match is None: - return await self.handle_404(request, send) - - new_scope = dict(scope, url_route={"kwargs": match.groupdict()}) - request.scope = new_scope - try: - response = await view(request, send) - if response: - self.ds._write_messages_to_response(request, response) - await response.asgi_send(send) - return - except NotFound as exception: - return await self.handle_404(request, send, exception) - except Forbidden as exception: - # Try the forbidden() plugin hook - for custom_response in pm.hook.forbidden( - datasette=self.ds, request=request, message=exception.args[0] - ): - custom_response = await await_me_maybe(custom_response) - assert ( - custom_response - ), "Default forbidden() hook should have been called" - return await custom_response.asgi_send(send) - except Exception as exception: - return await self.handle_exception(request, send, exception) - - async def handle_404(self, request, send, exception=None): - # If path contains % encoding, redirect to tilde encoding - if "%" in request.path: - # Try the same path but with "%" replaced by "~" - # and "~" replaced with "~7E" - # and "." replaced with "~2E" - new_path = ( - request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E") - ) - if request.query_string: - new_path += "?{}".format(request.query_string) - await asgi_send_redirect(send, new_path) - return - # If URL has a trailing slash, redirect to URL without it - path = request.scope.get( - "raw_path", request.scope["path"].encode("utf8") - ).partition(b"?")[0] - context = {} - if path.endswith(b"/"): - path = path.rstrip(b"/") - if request.scope["query_string"]: - path += b"?" + request.scope["query_string"] - await asgi_send_redirect(send, path.decode("latin1")) - else: - # Is there a pages/* template matching this path? - route_path = request.scope.get("route_path", request.scope["path"]) - # Jinja requires template names to use "/" even on Windows - template_name = "pages" + route_path + ".html" - # Build a list of pages/blah/{name}.html matching expressions - environment = self.ds.get_jinja_environment(request) - pattern_templates = [ - filepath - for filepath in environment.list_templates() - if "{" in filepath and filepath.startswith("pages/") - ] - page_routes = [ - (route_pattern_from_filepath(filepath[len("pages/") :]), filepath) - for filepath in pattern_templates - ] - try: - template = environment.select_template([template_name]) - except TemplateNotFound: - template = None - if template is None: - # Try for a pages/blah/{name}.html template match - for regex, wildcard_template in page_routes: - match = regex.match(route_path) - if match is not None: - context.update(match.groupdict()) - template = wildcard_template - break - - if template: - headers = {} - status = [200] - - def custom_header(name, value): - headers[name] = value - return "" - - def custom_status(code): - status[0] = code - return "" - - def custom_redirect(location, code=302): - status[0] = code - headers["Location"] = location - return "" - - def raise_404(message=""): - raise NotFoundExplicit(message) - - context.update( - { - "custom_header": custom_header, - "custom_status": custom_status, - "custom_redirect": custom_redirect, - "raise_404": raise_404, - } - ) - try: - body = await self.ds.render_template( - template, - context, - request=request, - view_name="page", - ) - except NotFoundExplicit as e: - await self.handle_exception(request, send, e) - return - # Pull content-type out into separate parameter - content_type = "text/html; charset=utf-8" - matches = [k for k in headers if k.lower() == "content-type"] - if matches: - content_type = headers[matches[0]] - await asgi_send( - send, - body, - status=status[0], - headers=headers, - content_type=content_type, - ) - else: - await self.handle_exception(request, send, exception or NotFound("404")) - - async def handle_exception(self, request, send, exception): - responses = [] - for hook in pm.hook.handle_exception( - datasette=self.ds, - request=request, - exception=exception, - ): - response = await await_me_maybe(hook) - if response is not None: - responses.append(response) - - assert responses, "Default exception handler should have returned something" - # Even if there are multiple responses use just the first one - response = responses[0] - await response.asgi_send(send) - - -_cleaner_task_str_re = re.compile(r"\S*site-packages/") - - -def _cleaner_task_str(task): - s = str(task) - # This has something like the following in it: - # running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361> - # Clean up everything up to and including site-packages - return _cleaner_task_str_re.sub("", s) - - -def wrap_view(view_fn_or_class, datasette): - is_function = isinstance(view_fn_or_class, types.FunctionType) - if is_function: - return wrap_view_function(view_fn_or_class, datasette) - else: - if not isinstance(view_fn_or_class, type): - raise ValueError("view_fn_or_class must be a function or a class") - return wrap_view_class(view_fn_or_class, datasette) - - -def wrap_view_class(view_class, datasette): - async def async_view_for_class(request, send): - instance = view_class() - if inspect.iscoroutinefunction(instance.__call__): - return await async_call_with_supported_arguments( - instance.__call__, - scope=request.scope, - receive=request.receive, - send=send, - request=request, - datasette=datasette, - ) - else: - return call_with_supported_arguments( - instance.__call__, - scope=request.scope, - receive=request.receive, - send=send, - request=request, - datasette=datasette, - ) - - async_view_for_class.view_class = view_class - return async_view_for_class - - -def wrap_view_function(view_fn, datasette): - @functools.wraps(view_fn) - async def async_view_fn(request, send): - if inspect.iscoroutinefunction(view_fn): - response = await async_call_with_supported_arguments( - view_fn, - scope=request.scope, - receive=request.receive, - send=send, - request=request, - datasette=datasette, - ) - else: - response = call_with_supported_arguments( - view_fn, - scope=request.scope, - receive=request.receive, - send=send, - request=request, - datasette=datasette, - ) - if response is not None: - return response - - return async_view_fn - - -def permanent_redirect(path, forward_query_string=False, forward_rest=False): - def view(request, send): - redirect_path = ( - path - + (request.url_vars["rest"] if forward_rest else "") - + ( - ("?" + request.query_string) - if forward_query_string and request.query_string - else "" - ) - ) - route_path = request.scope.get("route_path") - if route_path and request.path.endswith(route_path): - redirect_path = request.path[: -len(route_path)] + redirect_path - return Response.redirect(redirect_path, status=301) - - return wrap_view(view, datasette=None) - - -_curly_re = re.compile(r"({.*?})") - - -def route_pattern_from_filepath(filepath): - # Drop the ".html" suffix - if filepath.endswith(".html"): - filepath = filepath[: -len(".html")] - re_bits = ["/"] - for bit in _curly_re.split(filepath): - if _curly_re.match(bit): - re_bits.append(f"(?P<{bit[1:-1]}>[^/]*)") - else: - re_bits.append(re.escape(bit)) - return re.compile("^" + "".join(re_bits) + "$") - - -class NotFoundExplicit(NotFound): - pass - - -class DatasetteClient: - """Internal HTTP client for making requests to a Datasette instance. - - Used for testing and for internal operations that need to make HTTP requests - to the Datasette app without going through an actual HTTP server. - """ - - def __init__(self, ds): - self.ds = ds - - @property - def app(self): - return self.ds.app() - - def actor_cookie(self, actor): - # Utility method, mainly for tests - return self.ds.sign({"a": actor}, "actor") - - def _fix(self, path, avoid_path_rewrites=False): - if not isinstance(path, PrefixedUrlString) and not avoid_path_rewrites: - path = self.ds.urls.path(path) - if path.startswith("/"): - path = f"http://localhost{path}" - return path - - def _apply_actor(self, kwargs): - """If ``actor=`` was supplied, convert it into a signed ds_actor cookie.""" - actor = kwargs.pop("actor", None) - if actor is None: - return - cookies = dict(kwargs.get("cookies") or {}) - if "ds_actor" in cookies: - raise TypeError("Cannot pass both actor= and a ds_actor cookie") - cookies["ds_actor"] = self.actor_cookie(actor) - kwargs["cookies"] = cookies - - async def _request(self, method, path, skip_permission_checks=False, **kwargs): - from datasette.permissions import SkipPermissions - - self._apply_actor(kwargs) - with _DatasetteClientContext(): - if skip_permission_checks: - with SkipPermissions(): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - cookies=kwargs.pop("cookies", None), - ) as client: - return await getattr(client, method)(self._fix(path), **kwargs) - else: - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - cookies=kwargs.pop("cookies", None), - ) as client: - return await getattr(client, method)(self._fix(path), **kwargs) - - async def get(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "get", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def options(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "options", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def head(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "head", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def post(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "post", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def put(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "put", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def patch(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "patch", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def delete(self, path, skip_permission_checks=False, **kwargs): - return await self._request( - "delete", path, skip_permission_checks=skip_permission_checks, **kwargs - ) - - async def request(self, method, path, skip_permission_checks=False, **kwargs): - """Make an HTTP request with the specified method. - - Args: - method: HTTP method (e.g., "GET", "POST", "PUT") - path: The path to request - skip_permission_checks: If True, bypass all permission checks for this request - **kwargs: Additional arguments to pass to httpx - - Returns: - httpx.Response: The response from the request - """ - from datasette.permissions import SkipPermissions - - avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None) - self._apply_actor(kwargs) - with _DatasetteClientContext(): - if skip_permission_checks: - with SkipPermissions(): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - cookies=kwargs.pop("cookies", None), - ) as client: - return await client.request( - method, self._fix(path, avoid_path_rewrites), **kwargs - ) - else: - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - cookies=kwargs.pop("cookies", None), - ) as client: - return await client.request( - method, self._fix(path, avoid_path_rewrites), **kwargs - ) + return app diff --git a/datasette/blob_renderer.py b/datasette/blob_renderer.py deleted file mode 100644 index 4d8c6bea..00000000 --- a/datasette/blob_renderer.py +++ /dev/null @@ -1,61 +0,0 @@ -from datasette import hookimpl -from datasette.utils.asgi import Response, BadRequest -from datasette.utils import to_css_class -import hashlib - -_BLOB_COLUMN = "_blob_column" -_BLOB_HASH = "_blob_hash" - - -async def render_blob(datasette, database, rows, columns, request, table, view_name): - if _BLOB_COLUMN not in request.args: - raise BadRequest(f"?{_BLOB_COLUMN}= is required") - blob_column = request.args[_BLOB_COLUMN] - if blob_column not in columns: - raise BadRequest(f"{blob_column} is not a valid column") - - # If ?_blob_hash= provided, use that to select the row - otherwise use first row - blob_hash = None - if _BLOB_HASH in request.args: - blob_hash = request.args[_BLOB_HASH] - for row in rows: - value = row[blob_column] - if hashlib.sha256(value).hexdigest() == blob_hash: - break - else: - # Loop did not break - raise BadRequest( - "Link has expired - the requested binary content has changed or could not be found." - ) - else: - row = rows[0] - - value = row[blob_column] - filename_bits = [] - if table: - filename_bits.append(to_css_class(table)) - if "pks" in request.url_vars: - filename_bits.append(request.url_vars["pks"]) - filename_bits.append(to_css_class(blob_column)) - if blob_hash: - filename_bits.append(blob_hash[:6]) - filename = "-".join(filename_bits) + ".blob" - headers = { - "X-Content-Type-Options": "nosniff", - "Content-Disposition": f'attachment; filename="{filename}"', - } - return Response( - body=value or b"", - status=200, - headers=headers, - content_type="application/binary", - ) - - -@hookimpl -def register_output_renderer(): - return { - "extension": "blob", - "render": render_blob, - "can_render": lambda: False, - } diff --git a/datasette/cli.py b/datasette/cli.py index 90a33e80..51834c05 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -1,901 +1,183 @@ -import asyncio -import uvicorn import click -from click import formatting -from click.types import CompositeParamType from click_default_group import DefaultGroup -import functools import json -import os -import pathlib -from runpy import run_module import shutil -from subprocess import call +from subprocess import call, check_output import sys -import textwrap -import webbrowser -from .app import ( - Datasette, - DEFAULT_SETTINGS, - SETTINGS, - SQLITE_LIMIT_ATTACHED, - pm, -) -from .inspect import inspect_tables +from .app import Datasette from .utils import ( - LoadExtension, - StartupError, - check_connection, - deep_dict_update, - find_spatialite, - parse_metadata, - ConnectionProblem, - SpatialiteConnectionProblem, - initial_path_for_datasette, - pairs_to_nested_config, - temporary_docker_directory, - value_as_boolean, - SpatialiteNotFound, - StaticMount, - ValueAsBooleanError, + temporary_docker_directory, temporary_heroku_directory ) -from .utils.sqlite import sqlite3 -from .utils.testing import TestClient -from .version import __version__ -def run_sync(coro_func): - """Run an async callable to completion on a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(loop) - return loop.run_until_complete(coro_func()) - finally: - asyncio.set_event_loop(None) - loop.close() - - -# Use Rich for tracebacks if it is installed -try: - from rich.traceback import install - - install(show_locals=True) -except ImportError: - pass - - -class Setting(CompositeParamType): - name = "setting" - arity = 2 - - def convert(self, config, param, ctx): - name, value = config - if name in DEFAULT_SETTINGS: - # For backwards compatibility with how this worked prior to - # Datasette 1.0, we turn bare setting names into setting.name - # Type checking for those older settings - default = DEFAULT_SETTINGS[name] - name = "settings.{}".format(name) - if isinstance(default, bool): - try: - return name, "true" if value_as_boolean(value) else "false" - except ValueAsBooleanError: - self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx) - elif isinstance(default, int): - if not value.isdigit(): - self.fail(f'"{name}" should be an integer', param, ctx) - return name, value - elif isinstance(default, str): - return name, value - else: - # Should never happen: - self.fail("Invalid option") - return name, value - - -def sqlite_extensions(fn): - fn = click.option( - "sqlite_extensions", - "--load-extension", - type=LoadExtension(), - envvar="DATASETTE_LOAD_EXTENSION", - multiple=True, - help="Path to a SQLite extension to load, and optional entrypoint", - )(fn) - - # Wrap it in a custom error handler - @functools.wraps(fn) - def wrapped(*args, **kwargs): - try: - return fn(*args, **kwargs) - except AttributeError as e: - if "enable_load_extension" in str(e): - raise click.ClickException(textwrap.dedent(""" - Your Python installation does not have the ability to load SQLite extensions. - - More information: https://datasette.io/help/extensions - """).strip()) - raise - - return wrapped - - -@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True) -@click.version_option(version=__version__) +@click.group(cls=DefaultGroup, default='serve', default_if_no_args=True) +@click.version_option() def cli(): """ - Datasette is an open source multi-tool for exploring and publishing data - - \b - About Datasette: https://datasette.io/ - Full documentation: https://docs.datasette.io/ + Datasette! """ @cli.command() -@click.argument("files", type=click.Path(exists=True), nargs=-1) -@click.option("--inspect-file", default="-") -@sqlite_extensions -def inspect(files, inspect_file, sqlite_extensions): - """ - Generate JSON summary of provided database files - - This can then be passed to "datasette --inspect-file" to speed up count - operations against immutable database files. - """ - inspect_data = run_sync(lambda: inspect_(files, sqlite_extensions)) - if inspect_file == "-": - sys.stdout.write(json.dumps(inspect_data, indent=2)) - else: - with open(inspect_file, "w") as fp: - fp.write(json.dumps(inspect_data, indent=2)) - - -async def inspect_(files, sqlite_extensions): - app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) - data = {} - for name, database in app.databases.items(): - tables = await database.execute_fn(lambda conn: inspect_tables(conn, {})) - data[name] = { - "hash": database.hash, - "size": database.size, - "file": database.path, - "tables": { - table_name: {"count": table["count"]} - for table_name, table in tables.items() - }, - } - return data - - -@cli.group() -def publish(): - """Publish specified SQLite database files to the internet along with a Datasette-powered interface and API""" - pass - - -# Register publish plugins -pm.hook.publish_subcommand(publish=publish) +@click.argument('files', type=click.Path(exists=True), nargs=-1) +@click.option('--inspect-file', default='inspect-data.json') +def build(files, inspect_file): + app = Datasette(files) + open(inspect_file, 'w').write(json.dumps(app.inspect(), indent=2)) @cli.command() -@click.option("--all", help="Include built-in default plugins", is_flag=True) +@click.argument('publisher', type=click.Choice(['now', 'heroku'])) +@click.argument('files', type=click.Path(exists=True), nargs=-1) @click.option( - "--requirements", help="Output requirements.txt of installed plugins", is_flag=True + '-n', '--name', default='datasette', + help='Application name to use when deploying to Now (ignored for Heroku)' ) @click.option( - "--plugins-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom plugins", + '-m', '--metadata', type=click.File(mode='r'), + help='Path to JSON file containing metadata to publish' ) -def plugins(all, requirements, plugins_dir): - """List currently installed plugins""" - app = Datasette([], plugins_dir=plugins_dir) - if requirements: - for plugin in app._plugins(): - if plugin["version"]: - click.echo("{}=={}".format(plugin["name"], plugin["version"])) - else: - click.echo(json.dumps(app._plugins(all=all), indent=4)) +@click.option('--extra-options', help='Extra options to pass to datasette serve') +@click.option('--force', is_flag=True, help='Pass --force option to now') +@click.option('--branch', help='Install datasette from a GitHub branch e.g. master') +@click.option('--title', help='Title for metadata') +@click.option('--license', help='License label for metadata') +@click.option('--license_url', help='License URL for metadata') +@click.option('--source', help='Source label for metadata') +@click.option('--source_url', help='Source URL for metadata') +def publish(publisher, files, name, metadata, extra_options, force, branch, **extra_metadata): + """ + Publish specified SQLite database files to the internet along with a datasette API. + + Options for PUBLISHER: + * 'now' - You must have Zeit Now installed: https://zeit.co/now + * 'heroku' - You must have Heroku installed: https://cli.heroku.com/ + + Example usage: datasette publish now my-database.db + """ + def _fail_if_publish_binary_not_installed(binary, publish_target, install_link): + """Exit (with error message) if ``binary` isn't installed""" + if not shutil.which(binary): + click.secho( + "Publishing to {publish_target} requires {binary} to be installed and configured".format( + publish_target=publish_target, + binary=binary, + ), + bg='red', + fg='white', + bold=True, + err=True + ) + click.echo("Follow the instructions at {install_link}".format( + install_link=install_link, + ), err=True) + sys.exit(1) + + if publisher == 'now': + _fail_if_publish_binary_not_installed('now', 'Zeit Now', 'https://zeit.co/now') + with temporary_docker_directory(files, name, metadata, extra_options, branch, extra_metadata): + if force: + call(['now', '--force']) + else: + call('now') + + elif publisher == 'heroku': + _fail_if_publish_binary_not_installed('heroku', 'Heroku', 'https://cli.heroku.com') + + # Check for heroku-builds plugin + plugins = [line.split()[0] for line in check_output(['heroku', 'plugins']).splitlines()] + if b'heroku-builds' not in plugins: + click.echo('Publishing to Heroku requires the heroku-builds plugin to be installed.') + click.confirm('Install it? (this will run `heroku plugins:install heroku-builds`)', abort=True) + call(["heroku", "plugins:install", "heroku-builds"]) + + with temporary_heroku_directory(files, name, metadata, extra_options, branch, extra_metadata): + create_output = check_output( + ['heroku', 'apps:create', '--json'] + ).decode('utf8') + app_name = json.loads(create_output)["name"] + call(["heroku", "builds:create", "-a", app_name]) @cli.command() -@click.argument("files", type=click.Path(exists=True), nargs=-1, required=True) +@click.argument('files', type=click.Path(exists=True), nargs=-1, required=True) @click.option( - "-t", - "--tag", - help="Name for the resulting Docker container, can optionally use name:tag format", + '-t', '--tag', + help='Name for the resulting Docker container, can optionally use name:tag format' ) @click.option( - "-m", - "--metadata", - type=click.File(mode="r"), - help="Path to JSON/YAML file containing metadata to publish", + '-m', '--metadata', type=click.File(mode='r'), + help='Path to JSON file containing metadata to publish' ) -@click.option("--extra-options", help="Extra options to pass to datasette serve") -@click.option("--branch", help="Install datasette from a GitHub branch e.g. main") -@click.option( - "--template-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom templates", -) -@click.option( - "--plugins-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom plugins", -) -@click.option( - "--static", - type=StaticMount(), - help="Serve static files from this directory at /MOUNT/...", - multiple=True, -) -@click.option( - "--install", help="Additional packages (e.g. plugins) to install", multiple=True -) -@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension") -@click.option("--version-note", help="Additional note to show on /-/versions") -@click.option( - "--secret", - help="Secret used for signing secure values, such as signed cookies", - envvar="DATASETTE_PUBLISH_SECRET", - default=lambda: os.urandom(32).hex(), -) -@click.option( - "-p", - "--port", - default=8001, - type=click.IntRange(1, 65535), - help="Port to run the server on, defaults to 8001", -) -@click.option("--title", help="Title for metadata") -@click.option("--license", help="License label for metadata") -@click.option("--license_url", help="License URL for metadata") -@click.option("--source", help="Source label for metadata") -@click.option("--source_url", help="Source URL for metadata") -@click.option("--about", help="About label for metadata") -@click.option("--about_url", help="About URL for metadata") -def package( - files, - tag, - metadata, - extra_options, - branch, - template_dir, - plugins_dir, - static, - install, - spatialite, - version_note, - secret, - port, - **extra_metadata, -): - """Package SQLite files into a Datasette Docker container""" - if not shutil.which("docker"): +@click.option('--extra-options', help='Extra options to pass to datasette serve') +@click.option('--branch', help='Install datasette from a GitHub branch e.g. master') +@click.option('--title', help='Title for metadata') +@click.option('--license', help='License label for metadata') +@click.option('--license_url', help='License URL for metadata') +@click.option('--source', help='Source label for metadata') +@click.option('--source_url', help='Source URL for metadata') +def package(files, tag, metadata, extra_options, branch, **extra_metadata): + "Package specified SQLite files into a new datasette Docker container" + if not shutil.which('docker'): click.secho( ' The package command requires "docker" to be installed and configured ', - bg="red", - fg="white", + bg='red', + fg='white', bold=True, err=True, ) sys.exit(1) - with temporary_docker_directory( - files, - "datasette", - metadata=metadata, - extra_options=extra_options, - branch=branch, - template_dir=template_dir, - plugins_dir=plugins_dir, - static=static, - install=install, - spatialite=spatialite, - version_note=version_note, - secret=secret, - extra_metadata=extra_metadata, - port=port, - ): - args = ["docker", "build"] + with temporary_docker_directory(files, 'datasette', metadata, extra_options, branch, extra_metadata): + args = ['docker', 'build'] if tag: - args.append("-t") + args.append('-t') args.append(tag) - args.append(".") + args.append('.') call(args) @cli.command() -@click.argument("packages", nargs=-1) +@click.argument('files', type=click.Path(exists=True), nargs=-1) +@click.option('-h', '--host', default='127.0.0.1', help='host for server, defaults to 127.0.0.1') +@click.option('-p', '--port', default=8001, help='port for server, defaults to 8001') +@click.option('--debug', is_flag=True, help='Enable debug mode - useful for development') +@click.option('--reload', is_flag=True, help='Automatically reload if code change detected - useful for development') +@click.option('--cors', is_flag=True, help='Enable CORS by serving Access-Control-Allow-Origin: *') +@click.option('--page_size', default=100, help='Page size - default is 100') +@click.option('--max_returned_rows', default=1000, help='Max allowed rows to return at once - default is 1000. Set to 0 to disable check entirely.') +@click.option('--sql_time_limit_ms', default=1000, help='Max time allowed for SQL queries in ms') @click.option( - "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" + 'sqlite_extensions', '--load-extension', envvar='SQLITE_EXTENSIONS', multiple=True, + type=click.Path(exists=True, resolve_path=True), help='Path to a SQLite extension to load' ) -@click.option( - "-r", - "--requirement", - type=click.Path(exists=True), - help="Install from requirements file", -) -@click.option( - "-e", - "--editable", - help="Install a project in editable mode from this path", -) -def install(packages, upgrade, requirement, editable): - """Install plugins and packages from PyPI into the same environment as Datasette""" - if not packages and not requirement and not editable: - raise click.UsageError("Please specify at least one package to install") - args = ["pip", "install"] - if upgrade: - args += ["--upgrade"] - if editable: - args += ["--editable", editable] - if requirement: - args += ["-r", requirement] - args += list(packages) - sys.argv = args - run_module("pip", run_name="__main__") - - -@cli.command() -@click.argument("packages", nargs=-1, required=True) -@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation") -def uninstall(packages, yes): - """Uninstall plugins and Python packages from the Datasette environment""" - sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else []) - run_module("pip", run_name="__main__") - - -@cli.command() -@click.argument("files", type=click.Path(), nargs=-1) -@click.option( - "-i", - "--immutable", - type=click.Path(exists=True), - help="Database files to open in immutable mode", - multiple=True, -) -@click.option( - "-h", - "--host", - default="127.0.0.1", - help=( - "Host for server. Defaults to 127.0.0.1 which means only connections " - "from the local machine will be allowed. Use 0.0.0.0 to listen to " - "all IPs and allow access from other machines." - ), -) -@click.option( - "-p", - "--port", - default=8001, - type=click.IntRange(0, 65535), - help="Port for server, defaults to 8001. Use -p 0 to automatically assign an available port.", -) -@click.option( - "--uds", - help="Bind to a Unix domain socket", -) -@click.option( - "--reload", - is_flag=True, - help="Automatically reload if code or metadata change detected - useful for development", -) -@click.option( - "--cors", is_flag=True, help="Enable CORS by serving Access-Control-Allow-Origin: *" -) -@sqlite_extensions -@click.option( - "--inspect-file", help='Path to JSON file created using "datasette inspect"' -) -@click.option( - "-m", - "--metadata", - type=click.File(mode="r"), - help="Path to JSON/YAML file containing license/source metadata", -) -@click.option( - "--template-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom templates", -) -@click.option( - "--plugins-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom plugins", -) -@click.option( - "--static", - type=StaticMount(), - help="Serve static files from this directory at /MOUNT/...", - multiple=True, -) -@click.option("--memory", is_flag=True, help="Make /_memory database available") -@click.option( - "-c", - "--config", - type=click.File(mode="r"), - help="Path to JSON/YAML Datasette configuration file", -) -@click.option( - "-s", - "--setting", - "settings", - type=Setting(), - help="nested.key, value setting to use in Datasette configuration", - multiple=True, -) -@click.option( - "--secret", - help="Secret used for signing secure values, such as signed cookies", - envvar="DATASETTE_SECRET", -) -@click.option( - "--root", - help="Output URL that sets a cookie authenticating the root user", - is_flag=True, -) -@click.option( - "--default-deny", - help="Deny all permissions by default", - is_flag=True, -) -@click.option( - "--get", - help="Run an HTTP GET request against this path, print results and exit", -) -@click.option( - "--headers", - is_flag=True, - help="Include HTTP headers in --get output", -) -@click.option( - "--token", - help="API token to send with --get requests", -) -@click.option( - "--actor", - help="Actor to use for --get requests (JSON string)", -) -@click.option("--version-note", help="Additional note to show on /-/versions") -@click.option("--help-settings", is_flag=True, help="Show available settings") -@click.option("--pdb", is_flag=True, help="Launch debugger on any errors") -@click.option( - "-o", - "--open", - "open_browser", - is_flag=True, - help="Open Datasette in your web browser", -) -@click.option( - "--create", - is_flag=True, - help="Create database files if they do not exist", -) -@click.option( - "--crossdb", - is_flag=True, - help="Enable cross-database joins using the /_memory database", -) -@click.option( - "--nolock", - is_flag=True, - help="Ignore locking, open locked files in read-only mode", -) -@click.option( - "--ssl-keyfile", - help="SSL key file", - envvar="DATASETTE_SSL_KEYFILE", -) -@click.option( - "--ssl-certfile", - help="SSL certificate file", - envvar="DATASETTE_SSL_CERTFILE", -) -@click.option( - "--internal", - type=click.Path(), - help="Path to a persistent Datasette internal SQLite database", -) -def serve( - files, - immutable, - host, - port, - uds, - reload, - cors, - sqlite_extensions, - inspect_file, - metadata, - template_dir, - plugins_dir, - static, - memory, - config, - settings, - secret, - root, - default_deny, - get, - headers, - token, - actor, - version_note, - help_settings, - pdb, - open_browser, - create, - crossdb, - nolock, - ssl_keyfile, - ssl_certfile, - internal, - return_instance=False, -): +@click.option('--inspect-file', help='Path to JSON file created using "datasette build"') +@click.option('-m', '--metadata', type=click.File(mode='r'), help='Path to JSON file containing license/source metadata') +def serve(files, host, port, debug, reload, cors, page_size, max_returned_rows, sql_time_limit_ms, sqlite_extensions, inspect_file, metadata): """Serve up specified SQLite database files with a web UI""" - if help_settings: - formatter = formatting.HelpFormatter() - with formatter.section("Settings"): - formatter.write_dl( - [ - (option.name, f"{option.help} (default={option.default})") - for option in SETTINGS - ] - ) - click.echo(formatter.getvalue()) - sys.exit(0) if reload: import hupper - - reloader = hupper.start_reloader("datasette.cli.cli") - if immutable: - reloader.watch_files(immutable) - if config: - reloader.watch_files([config.name]) - if metadata: - reloader.watch_files([metadata.name]) + hupper.start_reloader('datasette.cli.serve') inspect_data = None if inspect_file: - with open(inspect_file) as fp: - inspect_data = json.load(fp) + inspect_data = json.load(open(inspect_file)) metadata_data = None if metadata: - metadata_data = parse_metadata(metadata.read()) + metadata_data = json.loads(metadata.read()) - config_data = None - if config: - config_data = parse_metadata(config.read()) - - config_data = config_data or {} - - # Merge in settings from -s/--setting - if settings: - settings_updates = pairs_to_nested_config(settings) - # Merge recursively, to avoid over-writing nested values - # https://github.com/simonw/datasette/issues/2389 - deep_dict_update(config_data, settings_updates) - - kwargs = dict( - immutables=immutable, - cache_headers=not reload, + click.echo('Serve! files={} on port {}'.format(files, port)) + ds = Datasette( + files, + cache_headers=not debug and not reload, cors=cors, + page_size=page_size, + max_returned_rows=max_returned_rows, + sql_time_limit_ms=sql_time_limit_ms, inspect_data=inspect_data, - config=config_data, metadata=metadata_data, sqlite_extensions=sqlite_extensions, - template_dir=template_dir, - plugins_dir=plugins_dir, - static_mounts=static, - settings=None, # These are passed in config= now - memory=memory, - secret=secret, - version_note=version_note, - pdb=pdb, - crossdb=crossdb, - nolock=nolock, - internal=internal, - default_deny=default_deny, ) - - # Separate directories from files - directories = [f for f in files if os.path.isdir(f)] - file_paths = [f for f in files if not os.path.isdir(f)] - - # Handle config_dir - only one directory allowed - if len(directories) > 1: - raise click.ClickException( - "Cannot pass multiple directories. Pass a single directory as config_dir." - ) - elif len(directories) == 1: - kwargs["config_dir"] = pathlib.Path(directories[0]) - - # Verify list of files, create if needed (and --create) - for file in file_paths: - if not pathlib.Path(file).exists(): - if create: - conn = sqlite3.connect(file) - conn.execute("vacuum") - conn.close() - else: - raise click.ClickException( - "Invalid value for '[FILES]...': Path '{}' does not exist.".format( - file - ) - ) - - # Check for duplicate files by resolving all paths to their absolute forms - # Collect all database files that will be loaded (explicit files + config_dir files) - all_db_files = [] - - # Add explicit files - for file in file_paths: - all_db_files.append((file, pathlib.Path(file).resolve())) - - # Add config_dir databases if config_dir is set - if "config_dir" in kwargs: - config_dir = kwargs["config_dir"] - for ext in ("db", "sqlite", "sqlite3"): - for db_file in config_dir.glob(f"*.{ext}"): - all_db_files.append((str(db_file), db_file.resolve())) - - # Check for duplicates - seen = {} - for original_path, resolved_path in all_db_files: - if resolved_path in seen: - raise click.ClickException( - f"Duplicate database file: '{original_path}' and '{seen[resolved_path]}' " - f"both refer to {resolved_path}" - ) - seen[resolved_path] = original_path - - files = file_paths - - try: - ds = Datasette(files, **kwargs) - except SpatialiteNotFound: - raise click.ClickException("Could not find SpatiaLite extension") - except StartupError as e: - raise click.ClickException(e.args[0]) - - if return_instance: - # Private utility mechanism for writing unit tests - return ds - - # Run async soundness checks before startup hooks, since invoke_startup - # now populates internal tables which requires querying each database - run_sync(lambda: check_databases(ds)) - - # Run the "startup" plugin hooks - try: - run_sync(ds.invoke_startup) - except StartupError as e: - raise click.ClickException(e.args[0]) - - if headers and not get: - raise click.ClickException("--headers can only be used with --get") - - if token and not get: - raise click.ClickException("--token can only be used with --get") - - if get: - client = TestClient(ds) - request_headers = {} - if token: - request_headers["Authorization"] = "Bearer {}".format(token) - cookies = {} - if actor: - cookies["ds_actor"] = client.actor_cookie(json.loads(actor)) - response = client.get(get, headers=request_headers, cookies=cookies) - - if headers: - # Output HTTP status code, headers, two newlines, then the response body - click.echo(f"HTTP/1.1 {response.status}") - for key, value in response.headers.items(): - click.echo(f"{key}: {value}") - if response.text: - click.echo() - click.echo(response.text) - else: - click.echo(response.text) - - exit_code = 0 if response.status == 200 else 1 - sys.exit(exit_code) - return - - # Start the server - url = None - if root: - ds.root_enabled = True - url = "http://{}:{}{}?token={}".format( - host, port, ds.urls.path("-/auth-token"), ds._root_token - ) - click.echo(url) - if open_browser: - if url is None: - # Figure out most convenient URL - to table, database or homepage - path = run_sync(lambda: initial_path_for_datasette(ds)) - url = f"http://{host}:{port}{path}" - webbrowser.open(url) - uvicorn_kwargs = dict( - host=host, port=port, log_level="info", lifespan="on", workers=1 - ) - if uds: - uvicorn_kwargs["uds"] = uds - if ssl_keyfile: - uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile - if ssl_certfile: - uvicorn_kwargs["ssl_certfile"] = ssl_certfile - uvicorn.run(ds.app(), **uvicorn_kwargs) - - -@cli.command() -@click.argument("id") -@click.option( - "--secret", - help="Secret used for signing the API tokens", - envvar="DATASETTE_SECRET", - required=True, -) -@click.option( - "-e", - "--expires-after", - help="Token should expire after this many seconds", - type=int, -) -@click.option( - "alls", - "-a", - "--all", - type=str, - metavar="ACTION", - multiple=True, - help="Restrict token to this action", -) -@click.option( - "databases", - "-d", - "--database", - type=(str, str), - metavar="DB ACTION", - multiple=True, - help="Restrict token to this action on this database", -) -@click.option( - "resources", - "-r", - "--resource", - type=(str, str, str), - metavar="DB RESOURCE ACTION", - multiple=True, - help="Restrict token to this action on this database resource (a table, SQL view or named query)", -) -@click.option( - "--debug", - help="Show decoded token", - is_flag=True, -) -@click.option( - "--plugins-dir", - type=click.Path(exists=True, file_okay=False, dir_okay=True), - help="Path to directory containing custom plugins", -) -def create_token( - id, secret, expires_after, alls, databases, resources, debug, plugins_dir -): - """ - Create a signed API token for the specified actor ID - - Example: - - datasette create-token root --secret mysecret - - To allow only "view-database-download" for all databases: - - \b - datasette create-token root --secret mysecret \\ - --all view-database-download - - To allow "create-table" against a specific database: - - \b - datasette create-token root --secret mysecret \\ - --database mydb create-table - - To allow "insert-row" against a specific table: - - \b - datasette create-token root --secret myscret \\ - --resource mydb mytable insert-row - - Restricted actions can be specified multiple times using - multiple --all, --database, and --resource options. - - Add --debug to see a decoded version of the token. - """ - ds = Datasette(secret=secret, plugins_dir=plugins_dir) - - # Run ds.invoke_startup() in an event loop - try: - run_sync(ds.invoke_startup) - except StartupError as e: - raise click.ClickException(e.args[0]) - - # Warn about any unknown actions - actions = [] - actions.extend(alls) - actions.extend([p[1] for p in databases]) - actions.extend([p[2] for p in resources]) - for action in actions: - if not ds.actions.get(action): - click.secho( - f" Unknown permission: {action} ", - fg="red", - err=True, - ) - - from datasette.tokens import TokenRestrictions - - restrictions = TokenRestrictions() - for action in alls: - restrictions.allow_all(action) - for database, action in databases: - restrictions.allow_database(database, action) - for database, resource, action in resources: - restrictions.allow_resource(database, resource, action) - - token = run_sync( - lambda: ds.create_token( - id, - expires_after=expires_after, - restrictions=restrictions, - handler="signed", - ) - ) - click.echo(token) - if debug: - encoded = token[len("dstok_") :] - click.echo("\nDecoded:\n") - click.echo(json.dumps(ds.unsign(encoded, namespace="token"), indent=2)) - - -pm.hook.register_commands(cli=cli) - - -async def check_databases(ds): - # Run check_connection against every connected database - # to confirm they are all usable - for database in list(ds.databases.values()): - try: - await database.execute_fn(check_connection) - except SpatialiteConnectionProblem: - suggestion = "" - try: - find_spatialite() - suggestion = "\n\nTry adding the --load-extension=spatialite option." - except SpatialiteNotFound: - pass - raise click.UsageError( - "It looks like you're trying to load a SpatiaLite" - + " database without first loading the SpatiaLite module." - + suggestion - + "\n\nRead more: https://docs.datasette.io/en/stable/spatialite.html" - ) - except ConnectionProblem as e: - raise click.UsageError( - f"Connection to {database.path} failed check: {str(e.args[0])}" - ) - # If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning - if ( - ds.crossdb - and len([db for db in ds.databases.values() if not db.is_memory]) - > SQLITE_LIMIT_ATTACHED - ): - msg = ( - "Warning: --crossdb only works with the first {} attached databases".format( - SQLITE_LIMIT_ATTACHED - ) - ) - click.echo(click.style(msg, bold=True, fg="yellow"), err=True) + # Force initial hashing/table counting + ds.inspect() + ds.app().run(host=host, port=port, debug=debug) diff --git a/datasette/column_types.py b/datasette/column_types.py deleted file mode 100644 index 7320e1d6..00000000 --- a/datasette/column_types.py +++ /dev/null @@ -1,83 +0,0 @@ -from enum import Enum - - -class SQLiteType(Enum): - TEXT = "TEXT" - INTEGER = "INTEGER" - REAL = "REAL" - BLOB = "BLOB" - NULL = "NULL" - - @classmethod - def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None": - if declared_type is None: - return cls.NULL - - normalized = declared_type.strip().upper() - if not normalized: - return cls.NULL - - if normalized == cls.NULL.value: - return cls.NULL - if "INT" in normalized: - return cls.INTEGER - if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")): - return cls.TEXT - if "BLOB" in normalized: - return cls.BLOB - if any( - token in normalized - for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub - ): - return cls.REAL - - return None - - -class ColumnType: - """ - Base class for column types. - - Subclasses must define ``name`` and ``description`` as class attributes: - - - ``name``: Unique identifier string. Lowercase, no spaces. - Examples: "markdown", "file", "email", "url", "point", "image". - - ``description``: Human-readable label for admin UI dropdowns. - Examples: "Markdown text", "File reference", "Email address". - - ``sqlite_types``: Optional tuple of SQLiteType values restricting - which SQLite column types this ColumnType can be assigned to. - - Instantiate with an optional ``config`` dict to bind per-column - configuration:: - - ct = MyColumnType(config={"key": "value"}) - ct.config # {"key": "value"} - """ - - name: str - description: str - sqlite_types: tuple[SQLiteType, ...] | None = None - - def __init__(self, config=None): - self.config = config - - async def render_cell(self, value, column, table, database, datasette, request): - """ - Return an HTML string to render this cell value, or None to - fall through to the default render_cell plugin hook chain. - """ - return None - - async def validate(self, value, datasette): - """ - Validate a value before it is written. Return None if valid, - or a string error message if invalid. - """ - return None - - async def transform_value(self, value, datasette): - """ - Transform a value before it appears in JSON API output. - Return the transformed value. Default: return unchanged. - """ - return value diff --git a/datasette/csrf.py b/datasette/csrf.py deleted file mode 100644 index df239aee..00000000 --- a/datasette/csrf.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Header-based CSRF (Cross-Origin) protection. - -Datasette uses the Sec-Fetch-Site + Origin header approach described in -Filippo Valsorda's article (https://words.filippo.io/csrf/) and implemented -in Go 1.25's http.CrossOriginProtection. This replaces the previous -token-based asgi-csrf mechanism. -""" - -from __future__ import annotations - -import secrets -import urllib.parse - -from .utils.asgi import asgi_send - -SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) - -DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443} - - -def _normalize_headers(raw_headers): - """Lowercase header names; for duplicates, last value wins.""" - result = {} - for name, value in raw_headers: - if isinstance(name, str): - name = name.encode("latin-1") - if isinstance(value, str): - value = value.encode("latin-1") - result[name.lower()] = value - return result - - -def _origin_tuple(value): - """ - Parse an origin-like string into ``(scheme, host, port)`` with default - ports filled in. Raises ``ValueError`` for malformed input. - """ - parsed = urllib.parse.urlsplit(value) - scheme = (parsed.scheme or "").lower() - host = (parsed.hostname or "").lower() - if not scheme or not host: - raise ValueError("missing scheme or host in {!r}".format(value)) - port = parsed.port # may raise ValueError on bad ports - if port is None: - port = DEFAULT_PORTS.get(scheme) - if port is None: - raise ValueError("unknown default port for scheme {!r}".format(scheme)) - return scheme, host, port - - -def _install_legacy_csrftoken(scope): - """ - Populate ``scope["csrftoken"]`` with a callable returning a per-request - random token. Provided for plugin compatibility only - core no longer - uses this value for CSRF enforcement. - """ - - def csrftoken(): - if "_datasette_legacy_csrftoken" not in scope: - scope["_datasette_legacy_csrftoken"] = secrets.token_urlsafe(32) - return scope["_datasette_legacy_csrftoken"] - - scope["csrftoken"] = csrftoken - - -class CrossOriginProtectionMiddleware: - """ - Modern CSRF protection using the Sec-Fetch-Site and Origin headers. - - Based on Filippo Valsorda's algorithm, as implemented in Go 1.25's - http.CrossOriginProtection. See https://words.filippo.io/csrf/ - - Unsafe-method requests are allowed through only if they look same-origin. - Non-browser clients (curl, etc.) send neither Sec-Fetch-Site nor Origin - and are passed through unchanged - CSRF is a browser-only attack. - """ - - SAFE_METHODS = SAFE_METHODS - - def __init__(self, app, datasette): - self.app = app - self.datasette = datasette - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - - _install_legacy_csrftoken(scope) - - if scope.get("method", "GET") in self.SAFE_METHODS: - await self.app(scope, receive, send) - return - - headers = _normalize_headers(scope.get("headers") or []) - - authorization = headers.get(b"authorization", b"").decode("latin-1") - cookie_header = headers.get(b"cookie") - # Bearer-token requests are not ambient browser credentials, so they - # are not CSRF-vulnerable. Narrowly exempt them from the header check - # before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt; - # schemes like Basic or Digest can be browser-managed and ambient. - # If the request also carries a Cookie header, ambient cookie auth - # could be in play, so do NOT treat it as exempt. - if authorization and not cookie_header: - parts = authorization.split(None, 1) - if parts and parts[0].lower() == "bearer": - await self.app(scope, receive, send) - return - - origin_bytes = headers.get(b"origin") - sec_fetch_site_bytes = headers.get(b"sec-fetch-site") - host_bytes = headers.get(b"host", b"") - origin = origin_bytes.decode("latin-1") if origin_bytes else None - sec_fetch_site = ( - sec_fetch_site_bytes.decode("latin-1") if sec_fetch_site_bytes else None - ) - host = host_bytes.decode("latin-1") - - # Primary defense: Sec-Fetch-Site (set by browsers, unforgeable from JS) - if sec_fetch_site is not None: - if sec_fetch_site in ("same-origin", "none"): - await self.app(scope, receive, send) - return - await self._forbid( - send, - "Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format( - sec_fetch_site - ), - ) - return - - # No Sec-Fetch-Site and no Origin -> non-browser client (curl, API, etc.) - if origin is None: - await self.app(scope, receive, send) - return - - # Fallback for older browsers: Origin must match the request's own - # scheme + host + port. Compare full origin tuples, not host alone. - request_scheme = self._request_scheme(scope) - try: - origin_tuple = _origin_tuple(origin) - expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host)) - except ValueError: - await self._forbid( - send, - "Malformed Origin {!r} or Host {!r}".format(origin, host), - ) - return - - if origin_tuple == expected_tuple: - await self.app(scope, receive, send) - return - - await self._forbid( - send, - "Origin {!r} does not match Host {!r}".format(origin, host), - ) - - def _request_scheme(self, scope): - if self.datasette is not None: - try: - if self.datasette.setting("force_https_urls"): - return "https" - except Exception: - pass - return scope.get("scheme") or "http" - - async def _forbid(self, send, reason): - await asgi_send( - send, - content=await self.datasette.render_template( - "csrf_error.html", {"reason": reason} - ), - status=403, - content_type="text/html; charset=utf-8", - ) diff --git a/datasette/database.py b/datasette/database.py deleted file mode 100644 index e7fe1ed9..00000000 --- a/datasette/database.py +++ /dev/null @@ -1,972 +0,0 @@ -import asyncio -import atexit -from collections import namedtuple -import inspect -import os -from pathlib import Path -import queue -import sqlite_utils -import sys -import tempfile -import threading -import uuid - -from .tracer import trace -from .utils import ( - call_with_supported_arguments, - detect_fts, - detect_primary_keys, - detect_spatialite, - get_all_foreign_keys, - get_outbound_foreign_keys, - md5_not_usedforsecurity, - sqlite_timelimit, - sqlite3, - table_columns, - table_column_details, -) -from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables -from .utils.sqlite import sqlite_hidden_table_names -from .inspect import inspect_hash - -connections = threading.local() - -EXECUTE_WRITE_RETURNING_LIMIT = 10 - -AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) - - -class DatasetteClosedError(RuntimeError): - """Raised when using a Datasette or Database instance after close().""" - - -_SHUTDOWN = object() - - -class Database: - # For table counts stop at this many rows: - count_limit = 10000 - _thread_local_id_counter = 1 - - def __init__( - self, - ds, - path=None, - is_mutable=True, - is_memory=False, - memory_name=None, - mode=None, - is_temp_disk=False, - ): - self.name = None - self._thread_local_id = f"x{self._thread_local_id_counter}" - Database._thread_local_id_counter += 1 - self.route = None - self.ds = ds - self.path = path - self.is_mutable = is_mutable - self.is_memory = is_memory - self.memory_name = memory_name - self.is_temp_disk = is_temp_disk - if memory_name is not None: - self.is_memory = True - if is_temp_disk: - fd, temp_path = tempfile.mkstemp(suffix=".db", prefix="datasette_temp_") - os.close(fd) - self.path = temp_path - self.is_mutable = True - self.mode = "rwc" - self._wal_enabled = False - atexit.register(self._cleanup_temp_file) - else: - self._wal_enabled = False - self.cached_hash = None - self.cached_size = None - self._cached_table_counts = None - self._write_thread = None - self._write_queue = None - self._closed = False - self._pending_execute_futures = set() - self._pending_execute_futures_lock = threading.Lock() - # These are used when in non-threaded mode: - self._read_connection = None - self._write_connection = None - # This is used to track all file connections so they can be closed - self._all_file_connections = [] - if not is_temp_disk: - self.mode = mode - - def _check_not_closed(self): - if self._closed: - raise DatasetteClosedError( - "Database {!r} has been closed".format(self.name) - ) - - def _remove_pending_execute_future(self, future): - with self._pending_execute_futures_lock: - self._pending_execute_futures.discard(future) - - @property - def cached_table_counts(self): - if self._cached_table_counts is not None: - return self._cached_table_counts - # Maybe use self.ds.inspect_data to populate cached_table_counts - if self.ds.inspect_data and self.ds.inspect_data.get(self.name): - self._cached_table_counts = { - key: value["count"] - for key, value in self.ds.inspect_data[self.name]["tables"].items() - } - return self._cached_table_counts - - @property - def color(self): - if self.hash: - return self.hash[:6] - return md5_not_usedforsecurity(self.name)[:6] - - def suggest_name(self): - if self.is_temp_disk: - return "_temp_disk" - if self.path: - return Path(self.path).stem - elif self.memory_name: - return self.memory_name - else: - return "db" - - def connect(self, write=False): - extra_kwargs = {} - if write: - extra_kwargs["isolation_level"] = "IMMEDIATE" - if self.memory_name: - uri = "file:{}?mode=memory&cache=shared".format(self.memory_name) - conn = sqlite3.connect( - uri, uri=True, check_same_thread=False, **extra_kwargs - ) - if not write: - conn.execute("PRAGMA query_only=1") - return conn - if self.is_memory: - return sqlite3.connect(":memory:", uri=True) - - # mode=ro or immutable=1? - if self.is_mutable: - qs = "?mode=ro" - if self.ds.nolock: - qs += "&nolock=1" - else: - qs = "?immutable=1" - assert not (write and not self.is_mutable) - if write: - qs = "" - if self.mode is not None: - qs = f"?mode={self.mode}" - conn = sqlite3.connect( - f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs - ) - self._all_file_connections.append(conn) - if self.is_temp_disk and not self._wal_enabled: - conn.execute("PRAGMA journal_mode=WAL") - self._wal_enabled = True - return conn - - def close(self): - """Release all resources held by this database. - - Idempotent. After close() further calls to execute()/execute_fn()/ - execute_write()/execute_write_fn() raise DatasetteClosedError. - """ - if self._closed: - return - with self._pending_execute_futures_lock: - if self._closed: - return - self._closed = True - pending_execute_futures = tuple(self._pending_execute_futures) - # Shut down the write thread, if any, via a sentinel. The thread - # drains any writes already queued before the sentinel and then - # closes its own write connection and returns. - write_thread = self._write_thread - if write_thread is not None and self._write_queue is not None: - self._write_queue.put(_SHUTDOWN) - write_thread.join(timeout=10) - if write_thread.is_alive(): - sys.stderr.write( - "Datasette: write thread for {!r} did not exit within 10s\n".format( - self.name - ) - ) - sys.stderr.flush() - for future in pending_execute_futures: - try: - future.result() - except Exception: - pass - # Close anything still tracked in _all_file_connections - for connection in self._all_file_connections: - try: - connection.close() - except Exception: - pass - self._all_file_connections = [] - # Drop per-thread cached read connections we can reach - try: - delattr(connections, self._thread_local_id) - except AttributeError: - pass - # Close non-threaded-mode cached connections if still open - if self._read_connection is not None: - try: - self._read_connection.close() - except Exception: - pass - self._read_connection = None - if self._write_connection is not None: - try: - self._write_connection.close() - except Exception: - pass - self._write_connection = None - if self.is_temp_disk: - self._cleanup_temp_file() - - def _cleanup_temp_file(self): - if self.is_temp_disk and self.path: - for suffix in ("", "-wal", "-shm"): - try: - os.unlink(self.path + suffix) - except OSError: - pass - - async def execute_write( - self, - sql, - params=None, - block=True, - request=None, - return_all=False, - returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, - ): - self._check_not_closed() - if returning_limit < 0: - raise ValueError("returning_limit must be >= 0") - - def _inner(conn): - cursor = conn.execute(sql, params or []) - return ExecuteWriteResult.from_cursor( - cursor, return_all=return_all, returning_limit=returning_limit - ) - - with trace("sql", database=self.name, sql=sql.strip(), params=params): - results = await self.execute_write_fn(_inner, block=block, request=request) - return results - - async def execute_write_script(self, sql, block=True, request=None): - self._check_not_closed() - - def _inner(conn): - return conn.executescript(sql) - - with trace("sql", database=self.name, sql=sql.strip(), executescript=True): - results = await self.execute_write_fn( - _inner, block=block, transaction=False, request=request - ) - return results - - async def execute_write_many(self, sql, params_seq, block=True, request=None): - self._check_not_closed() - - def _inner(conn): - count = 0 - - def count_params(params): - nonlocal count - for param in params: - count += 1 - yield param - - return conn.executemany(sql, count_params(params_seq)), count - - with trace( - "sql", database=self.name, sql=sql.strip(), executemany=True - ) as kwargs: - results, count = await self.execute_write_fn( - _inner, block=block, request=request - ) - kwargs["count"] = count - return results - - async def execute_isolated_fn(self, fn): - self._check_not_closed() - # Open a new connection just for the duration of this function, - # blocking the write queue to avoid any writes occurring during it - write = self.is_mutable - - def _run(): - isolated_connection = self.connect(write=write) - try: - return fn(isolated_connection) - finally: - isolated_connection.close() - try: - self._all_file_connections.remove(isolated_connection) - except ValueError: - # Was probably a memory connection - pass - - if self.ds.executor is None: - # non-threaded mode - return _run() - if not write: - # Immutable database - no writes can ever occur, so there is no - # write queue to block; run against a fresh read-only connection - return await asyncio.get_running_loop().run_in_executor( - self.ds.executor, _run - ) - # Threaded mode - send to write thread - return await self._send_to_write_thread(fn, isolated_connection=True) - - async def analyze_sql(self, sql, params=None) -> SQLAnalysis: - self._check_not_closed() - - return await self.execute_isolated_fn( - lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name) - ) - - async def execute_write_fn(self, fn, block=True, transaction=True, request=None): - self._check_not_closed() - pending_events = [] - - def track_event(event): - pending_events.append(event) - - fn = self._wrap_fn_with_hooks(fn, request, transaction, track_event) - if self.ds.executor is None: - # non-threaded mode - if self._write_connection is None: - self._write_connection = self.connect(write=True) - self.ds._prepare_connection(self._write_connection, self.name) - if transaction: - with self._write_connection: - result = fn(self._write_connection) - else: - result = fn(self._write_connection) - else: - result = await self._send_to_write_thread( - fn, block=block, transaction=transaction - ) - if block: - for event in pending_events: - await self.ds.track_event(event) - else: - # For non-blocking writes, spawn a background task to - # dispatch events after the write thread completes - task_id, reply_future = result - - async def _dispatch_events_after_write(): - try: - await reply_future - except Exception: - # if the write failed, don't emit success events - return - for event in pending_events: - await self.ds.track_event(event) - - asyncio.ensure_future(_dispatch_events_after_write()) - result = task_id - return result - - def _wrap_fn_with_hooks(self, fn, request, transaction, track_event): - from .plugins import pm - - # Wrap fn so it receives track_event if its signature supports it. - # Historically fn was called positionally, so any single-parameter - # name (conn, connection, db, ...) worked. Preserve that by only - # switching to keyword dependency injection when the callback - # explicitly opts in by declaring a `track_event` parameter. - original_fn = fn - - if "track_event" in inspect.signature(original_fn).parameters: - - def fn_with_track_event(conn): - return call_with_supported_arguments( - original_fn, conn=conn, track_event=track_event - ) - - fn = fn_with_track_event - - wrappers = pm.hook.write_wrapper( - datasette=self.ds, - database=self.name, - request=request, - transaction=transaction, - ) - wrappers = [w for w in wrappers if w is not None] - if not wrappers: - return fn - # Build the wrapped fn by nesting context manager generators. - # The first wrapper returned by pluggy is outermost. - for wrapper_factory in reversed(wrappers): - fn = _apply_write_wrapper(fn, wrapper_factory, track_event) - return fn - - async def _send_to_write_thread( - self, fn, block=True, isolated_connection=False, transaction=True - ): - if self._write_queue is None: - self._write_queue = queue.Queue() - if self._write_thread is None: - self._write_thread = threading.Thread( - target=self._execute_writes, daemon=True - ) - self._write_thread.name = "_execute_writes for database {}".format( - self.name - ) - self._write_thread.start() - task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") - loop = asyncio.get_running_loop() - reply_future = loop.create_future() - self._write_queue.put( - WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction) - ) - if block: - return await reply_future - else: - return task_id, reply_future - - def _execute_writes(self): - # Infinite looping thread that protects the single write connection - # to this database - conn_exception = None - conn = None - try: - conn = self.connect(write=True) - self.ds._prepare_connection(conn, self.name) - except Exception as e: - conn_exception = e - while True: - task = self._write_queue.get() - if task is _SHUTDOWN: - if conn is not None: - try: - conn.close() - except Exception: - pass - return - exception = None - result = None - if conn_exception is not None: - exception = conn_exception - elif task.isolated_connection: - try: - isolated_connection = self.connect(write=True) - try: - result = task.fn(isolated_connection) - finally: - isolated_connection.close() - try: - self._all_file_connections.remove(isolated_connection) - except ValueError: - # Was probably a memory connection - pass - except Exception as e: - sys.stderr.write("{}\n".format(e)) - sys.stderr.flush() - exception = e - else: - try: - if task.transaction: - with conn: - result = task.fn(conn) - else: - result = task.fn(conn) - except Exception as e: - sys.stderr.write("{}\n".format(e)) - sys.stderr.flush() - exception = e - _deliver_write_result(task, result, exception) - - async def execute_fn(self, fn): - self._check_not_closed() - if self.ds.executor is None: - # non-threaded mode - if self._read_connection is None: - self._read_connection = self.connect() - self.ds._prepare_connection(self._read_connection, self.name) - return fn(self._read_connection) - - # threaded mode - def in_thread(): - conn = getattr(connections, self._thread_local_id, None) - if not conn: - conn = self.connect() - self.ds._prepare_connection(conn, self.name) - setattr(connections, self._thread_local_id, conn) - return fn(conn) - - with self._pending_execute_futures_lock: - self._check_not_closed() - future = self.ds.executor.submit(in_thread) - self._pending_execute_futures.add(future) - future.add_done_callback(self._remove_pending_execute_future) - return await asyncio.wrap_future(future) - - async def execute( - self, - sql, - params=None, - truncate=False, - custom_time_limit=None, - page_size=None, - log_sql_errors=True, - ): - """Executes sql against db_name in a thread""" - self._check_not_closed() - page_size = page_size or self.ds.page_size - - def sql_operation_in_thread(conn): - time_limit_ms = self.ds.sql_time_limit_ms - if custom_time_limit and custom_time_limit < time_limit_ms: - time_limit_ms = custom_time_limit - - with sqlite_timelimit(conn, time_limit_ms): - try: - cursor = conn.cursor() - cursor.execute(sql, params if params is not None else {}) - max_returned_rows = self.ds.max_returned_rows - if max_returned_rows == page_size: - max_returned_rows += 1 - if max_returned_rows and truncate: - rows = cursor.fetchmany(max_returned_rows + 1) - truncated = len(rows) > max_returned_rows - rows = rows[:max_returned_rows] - else: - rows = cursor.fetchall() - truncated = False - except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: - if e.args == ("interrupted",): - raise QueryInterrupted(e, sql, params) - if log_sql_errors: - sys.stderr.write( - "ERROR: conn={}, sql = {}, params = {}: {}\n".format( - conn, repr(sql), params, e - ) - ) - sys.stderr.flush() - raise - - if truncate: - return Results(rows, truncated, cursor.description) - - else: - return Results(rows, False, cursor.description) - - with trace("sql", database=self.name, sql=sql.strip(), params=params): - results = await self.execute_fn(sql_operation_in_thread) - return results - - @property - def hash(self): - if self.cached_hash is not None: - return self.cached_hash - elif self.is_mutable or self.is_memory or self.is_temp_disk: - return None - elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): - self.cached_hash = self.ds.inspect_data[self.name]["hash"] - return self.cached_hash - else: - p = Path(self.path) - self.cached_hash = inspect_hash(p) - return self.cached_hash - - @property - def size(self): - if self.cached_size is not None: - return self.cached_size - elif self.is_memory: - return 0 - elif self.is_mutable: - return Path(self.path).stat().st_size - elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): - self.cached_size = self.ds.inspect_data[self.name]["size"] - return self.cached_size - else: - self.cached_size = Path(self.path).stat().st_size - return self.cached_size - - async def table_counts(self, limit=10): - if not self.is_mutable and self.cached_table_counts is not None: - return self.cached_table_counts - # Try to get counts for each table, $limit timeout for each count - counts = {} - for table in await self.table_names(): - try: - table_count = ( - await self.execute( - f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})", - custom_time_limit=limit, - ) - ).rows[0][0] - counts[table] = table_count - # In some cases I saw "SQL Logic Error" here in addition to - # QueryInterrupted - so we catch that too: - except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError): - counts[table] = None - if not self.is_mutable: - self._cached_table_counts = counts - return counts - - @property - def mtime_ns(self): - if self.is_memory: - return None - return Path(self.path).stat().st_mtime_ns - - async def attached_databases(self): - # This used to be: - # select seq, name, file from pragma_database_list() where seq > 0 - # But SQLite prior to 3.16.0 doesn't support pragma functions - results = await self.execute("PRAGMA database_list;") - # {'seq': 0, 'name': 'main', 'file': ''} - return [ - AttachedDatabase(*row) - for row in results.rows - # Filter out the SQLite internal "temp" database, refs #2557 - if row["seq"] > 0 and row["name"] != "temp" - ] - - async def table_exists(self, table): - results = await self.execute( - "select 1 from sqlite_master where type='table' and name=?", params=(table,) - ) - return bool(results.rows) - - async def view_exists(self, table): - results = await self.execute( - "select 1 from sqlite_master where type='view' and name=?", params=(table,) - ) - return bool(results.rows) - - async def table_names(self): - results = await self.execute( - "select name from sqlite_master where type='table' order by name" - ) - return [r[0] for r in results.rows] - - async def table_columns(self, table): - return await self.execute_fn(lambda conn: table_columns(conn, table)) - - async def table_column_details(self, table): - return await self.execute_fn(lambda conn: table_column_details(conn, table)) - - async def primary_keys(self, table): - return await self.execute_fn(lambda conn: detect_primary_keys(conn, table)) - - async def fts_table(self, table): - return await self.execute_fn(lambda conn: detect_fts(conn, table)) - - async def label_column_for_table(self, table): - explicit_label_column = (await self.ds.table_config(self.name, table)).get( - "label_column" - ) - if explicit_label_column: - return explicit_label_column - - def column_details(conn): - # Returns {column_name: (type, is_unique)} - db = sqlite_utils.Database(conn) - columns = db[table].columns_dict - indexes = db[table].indexes - details = {} - for name in columns: - is_unique = any( - index - for index in indexes - if index.columns == [name] and index.unique - ) - details[name] = (columns[name], is_unique) - return details - - column_details = await self.execute_fn(column_details) - # Is there just one unique column that's text? - unique_text_columns = [ - name - for name, (type_, is_unique) in column_details.items() - if is_unique and type_ is str - ] - if len(unique_text_columns) == 1: - return unique_text_columns[0] - - column_names = list(column_details.keys()) - # Is there a name or title column? - name_or_title = [c for c in column_names if c.lower() in ("name", "title")] - if name_or_title: - return name_or_title[0] - # If a table has two columns, one of which is ID, then label_column is the other one - if ( - column_names - and len(column_names) == 2 - and ("id" in column_names or "pk" in column_names) - and not set(column_names) == {"id", "pk"} - ): - return [c for c in column_names if c not in ("id", "pk")][0] - # Couldn't find a label: - return None - - async def foreign_keys_for_table(self, table): - return await self.execute_fn( - lambda conn: get_outbound_foreign_keys(conn, table) - ) - - async def hidden_table_names(self): - hidden_tables = [] - # Add any tables marked as hidden in config - db_config = self.ds.config.get("databases", {}).get(self.name, {}) - if "tables" in db_config: - hidden_tables += [ - t for t in db_config["tables"] if db_config["tables"][t].get("hidden") - ] - - hidden_tables += await self.execute_fn(sqlite_hidden_table_names) - - has_spatialite = await self.execute_fn(detect_spatialite) - if has_spatialite: - # Also hide Spatialite internal tables - hidden_tables += [ - "ElementaryGeometries", - "SpatialIndex", - "geometry_columns", - "spatial_ref_sys", - "spatialite_history", - "sql_statements_log", - "sqlite_sequence", - "views_geometry_columns", - "virts_geometry_columns", - "data_licenses", - "KNN", - "KNN2", - ] + [ - r[0] for r in (await self.execute(""" - select name from sqlite_master - where name like "idx_%" - and type = "table" - """)).rows - ] - - return hidden_tables - - async def view_names(self): - results = await self.execute("select name from sqlite_master where type='view'") - return [r[0] for r in results.rows] - - async def get_all_foreign_keys(self): - return await self.execute_fn(get_all_foreign_keys) - - async def get_table_definition(self, table, type_="table"): - table_definition_rows = list( - await self.execute( - "select sql from sqlite_master where name = :n and type=:t", - {"n": table, "t": type_}, - ) - ) - if not table_definition_rows: - return None - bits = [table_definition_rows[0][0] + ";"] - # Add on any indexes - index_rows = list( - await self.execute( - "select sql from sqlite_master where tbl_name = :n and type='index' and sql is not null", - {"n": table}, - ) - ) - for index_row in index_rows: - bits.append(index_row[0] + ";") - return "\n".join(bits) - - async def get_view_definition(self, view): - return await self.get_table_definition(view, "view") - - def __repr__(self): - tags = [] - if self.is_mutable: - tags.append("mutable") - if self.is_memory: - tags.append("memory") - if self.is_temp_disk: - tags.append("temp_disk") - if self.hash: - tags.append(f"hash={self.hash}") - if self.size is not None: - tags.append(f"size={self.size}") - tags_str = "" - if tags: - tags_str = f" ({', '.join(tags)})" - return f"" - - -def _apply_write_wrapper(fn, wrapper_factory, track_event): - """Apply a single write_wrapper context manager around fn. - - ``wrapper_factory`` is a callable that takes ``(conn)`` and optionally - ``track_event``, and returns a generator that yields exactly once. - Code before the yield runs before ``fn(conn)``, code after the yield - runs after. The result of ``fn(conn)`` is sent into the generator - via ``.send()``, and any exception raised by ``fn(conn)`` is thrown - via ``.throw()``. - """ - - def wrapped(conn): - gen = call_with_supported_arguments( - wrapper_factory, conn=conn, track_event=track_event - ) - # Advance to the yield point (run "before" code) - try: - next(gen) - except StopIteration: - # Generator didn't yield — just run fn unchanged - return fn(conn) - - # Execute the actual write - try: - result = fn(conn) - except Exception as e: - # Throw exception into generator so it can handle it - try: - gen.throw(e) - except StopIteration: - pass - # Re-raise the original exception - raise - else: - # Send the result back through the yield - try: - gen.send(result) - except StopIteration: - pass - return result - - return wrapped - - -class WriteTask: - __slots__ = ( - "fn", - "task_id", - "loop", - "reply_future", - "isolated_connection", - "transaction", - ) - - def __init__( - self, fn, task_id, loop, reply_future, isolated_connection, transaction - ): - self.fn = fn - self.task_id = task_id - self.loop = loop - self.reply_future = reply_future - self.isolated_connection = isolated_connection - self.transaction = transaction - - -def _deliver_write_result(task, result, exception): - # Called from the write thread. Delivers the result back to the - # awaiting coroutine on its event loop via call_soon_threadsafe. - def _set(): - if task.reply_future.done(): - # Awaiter was cancelled; nothing to do. - return - if exception is not None: - task.reply_future.set_exception(exception) - else: - task.reply_future.set_result(result) - - try: - task.loop.call_soon_threadsafe(_set) - except RuntimeError: - # Event loop has been closed; the awaiter is gone. - pass - - -class QueryInterrupted(Exception): - def __init__(self, e, sql, params): - self.e = e - self.sql = sql - self.params = params - - def __str__(self): - return "QueryInterrupted: {}".format(self.e) - - -class MultipleValues(Exception): - pass - - -class ExecuteWriteResult: - def __init__(self, rowcount, lastrowid, description, rows, truncated): - self.rowcount = rowcount - self.lastrowid = lastrowid - self.description = description - self.truncated = truncated - self._rows = rows - - @classmethod - def from_cursor( - cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT - ): - rows = [] - truncated = False - description = cursor.description - lastrowid = cursor.lastrowid - try: - if description is not None: - if return_all: - rows = cursor.fetchall() - else: - rows = cursor.fetchmany(returning_limit + 1) - if len(rows) > returning_limit: - rows = rows[:returning_limit] - truncated = True - rowcount = cursor.rowcount - finally: - cursor.close() - if description is not None and not return_all and truncated: - rowcount = -1 - return cls(rowcount, lastrowid, description, rows, truncated) - - def fetchall(self): - rows = self._rows - self._rows = [] - return rows - - -class Results: - def __init__(self, rows, truncated, description): - self.rows = rows - self.truncated = truncated - self.description = description - - @property - def columns(self): - return [d[0] for d in self.description] - - def first(self): - if self.rows: - return self.rows[0] - else: - return None - - def single_value(self): - if self.rows and 1 == len(self.rows) and 1 == len(self.rows[0]): - return self.rows[0][0] - else: - raise MultipleValues - - def dicts(self): - return [dict(row) for row in self.rows] - - def __iter__(self): - return iter(self.rows) - - def __len__(self): - return len(self.rows) diff --git a/datasette/default_actions.py b/datasette/default_actions.py deleted file mode 100644 index 2f78570b..00000000 --- a/datasette/default_actions.py +++ /dev/null @@ -1,133 +0,0 @@ -from datasette import hookimpl -from datasette.permissions import Action -from datasette.resources import ( - DatabaseResource, - TableResource, - QueryResource, -) - - -@hookimpl -def register_actions(): - """Register the core Datasette actions.""" - return ( - # Global actions (no resource_class) - Action( - name="view-instance", - abbr="vi", - description="View Datasette instance", - ), - Action( - name="permissions-debug", - abbr="pd", - description="Access permission debug tool", - ), - Action( - name="debug-menu", - abbr="dm", - description="View debug menu items", - ), - # Database-level actions (parent-level) - Action( - name="view-database", - abbr="vd", - description="View database", - resource_class=DatabaseResource, - ), - Action( - name="view-database-download", - abbr="vdd", - description="Download database file", - resource_class=DatabaseResource, - also_requires="view-database", - ), - Action( - name="execute-sql", - abbr="es", - description="Execute read-only SQL queries", - resource_class=DatabaseResource, - also_requires="view-database", - ), - Action( - name="execute-write-sql", - abbr="ews", - description="Execute writable SQL queries", - resource_class=DatabaseResource, - also_requires="view-database", - ), - Action( - name="create-table", - abbr="ct", - description="Create tables", - resource_class=DatabaseResource, - ), - Action( - name="store-query", - abbr="sq", - description="Create stored queries", - resource_class=DatabaseResource, - also_requires="execute-sql", - ), - # Table-level actions (child-level) - Action( - name="view-table", - abbr="vt", - description="View table", - resource_class=TableResource, - ), - Action( - name="insert-row", - abbr="ir", - description="Insert rows", - resource_class=TableResource, - ), - Action( - name="delete-row", - abbr="dr", - description="Delete rows", - resource_class=TableResource, - ), - Action( - name="update-row", - abbr="ur", - description="Update rows", - resource_class=TableResource, - ), - Action( - name="alter-table", - abbr="at", - description="Alter tables", - resource_class=TableResource, - ), - Action( - name="set-column-type", - abbr="sct", - description="Set column type", - resource_class=TableResource, - ), - Action( - name="drop-table", - abbr="dt", - description="Drop tables", - resource_class=TableResource, - ), - # Query-level actions (child-level) - Action( - name="view-query", - abbr="vq", - description="View named query results", - resource_class=QueryResource, - ), - Action( - name="update-query", - abbr="uq", - description="Update stored queries", - resource_class=QueryResource, - ), - Action( - name="delete-query", - abbr="dq", - description="Delete stored queries", - resource_class=QueryResource, - ), - ) diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py deleted file mode 100644 index 24493994..00000000 --- a/datasette/default_column_types.py +++ /dev/null @@ -1,81 +0,0 @@ -import json -import re - -import markupsafe - -from datasette import hookimpl -from datasette.column_types import ColumnType, SQLiteType - - -class UrlColumnType(ColumnType): - name = "url" - description = "URL" - sqlite_types = (SQLiteType.TEXT,) - - async def render_cell(self, value, column, table, database, datasette, request): - if not value or not isinstance(value, str): - return None - escaped = markupsafe.escape(value.strip()) - return markupsafe.Markup(f'{escaped}') - - async def validate(self, value, datasette): - if value is None or value == "": - return None - if not isinstance(value, str): - return "URL must be a string" - if not re.match(r"^https?://\S+$", value.strip()): - return "Invalid URL" - return None - - -class EmailColumnType(ColumnType): - name = "email" - description = "Email address" - sqlite_types = (SQLiteType.TEXT,) - - async def render_cell(self, value, column, table, database, datasette, request): - if not value or not isinstance(value, str): - return None - escaped = markupsafe.escape(value.strip()) - return markupsafe.Markup(f'{escaped}') - - async def validate(self, value, datasette): - if value is None or value == "": - return None - if not isinstance(value, str): - return "Email must be a string" - if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()): - return "Invalid email address" - return None - - -class JsonColumnType(ColumnType): - name = "json" - description = "JSON data" - sqlite_types = (SQLiteType.TEXT,) - - async def render_cell(self, value, column, table, database, datasette, request): - if value is None: - return None - try: - parsed = json.loads(value) if isinstance(value, str) else value - formatted = json.dumps(parsed, indent=2) - escaped = markupsafe.escape(formatted) - return markupsafe.Markup(f"
{escaped}
") - except (json.JSONDecodeError, TypeError): - return None - - async def validate(self, value, datasette): - if value is None or value == "": - return None - if isinstance(value, str): - try: - json.loads(value) - except json.JSONDecodeError: - return "Invalid JSON" - return None - - -@hookimpl -def register_column_types(datasette): - return [UrlColumnType, EmailColumnType, JsonColumnType] diff --git a/datasette/default_database_actions.py b/datasette/default_database_actions.py deleted file mode 100644 index e0cb3cdf..00000000 --- a/datasette/default_database_actions.py +++ /dev/null @@ -1,24 +0,0 @@ -from datasette import hookimpl -from datasette.resources import DatabaseResource - - -@hookimpl -def database_actions(datasette, actor, database, request): - async def inner(): - if not datasette.get_database(database).is_mutable: - return [] - if not await datasette.allowed( - action="execute-write-sql", - resource=DatabaseResource(database), - actor=actor, - ): - return [] - return [ - { - "href": datasette.urls.database(database) + "/-/execute-write", - "label": "Execute write SQL", - "description": "Run writable SQL with table permission checks.", - } - ] - - return inner diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py deleted file mode 100644 index 6127b2a6..00000000 --- a/datasette/default_debug_menu.py +++ /dev/null @@ -1,75 +0,0 @@ -from datasette import hookimpl -from datasette.jump import JumpSQL - -DEBUG_MENU_ITEMS = ( - ( - "/-/databases", - "Databases", - "List of databases known to this Datasette instance.", - ), - ( - "/-/plugins", - "Installed plugins", - "Review loaded plugins, their versions and their registered hooks.", - ), - ( - "/-/versions", - "Version info", - "Check the Python, SQLite and dependency versions used by this server.", - ), - ( - "/-/settings", - "Settings", - "Inspect the active Datasette settings and configuration values.", - ), - ( - "/-/permissions", - "Debug permissions", - "Test permission checks for actors, actions and resources.", - ), - ( - "/-/messages", - "Debug messages", - "Try out temporary flash messages shown to users.", - ), - ( - "/-/allow-debug", - "Debug allow rules", - "Explore how allow blocks match actors against permission rules.", - ), - ( - "/-/threads", - "Debug threads", - "Inspect worker threads and database tasks.", - ), - ( - "/-/actor", - "Debug actor", - "View the actor object for the current signed-in user.", - ), - ( - "/-/patterns", - "Pattern portfolio", - "Browse Datasette UI patterns.", - ), -) - - -@hookimpl -def jump_items_sql(datasette, actor, request): - async def inner(): - if not await datasette.allowed(action="debug-menu", actor=actor): - return [] - - return [ - JumpSQL.menu_item( - label=label, - url=datasette.urls.path(path), - description=description, - search_text=f"debug {label} {description}", - item_type="debug", - ) - for path, label, description in DEBUG_MENU_ITEMS - ] - - return inner diff --git a/datasette/default_jump_items.py b/datasette/default_jump_items.py deleted file mode 100644 index d215e7ec..00000000 --- a/datasette/default_jump_items.py +++ /dev/null @@ -1,82 +0,0 @@ -from datasette import hookimpl -from datasette.jump import JumpSQL - - -@hookimpl -def jump_items_sql(datasette, actor, request): - async def inner(): - database_sql, database_params = await datasette.allowed_resources_sql( - action="view-database", actor=actor - ) - table_sql, table_params = await datasette.allowed_resources_sql( - action="view-table", actor=actor - ) - query_sql, query_params = await datasette.allowed_resources_sql( - action="view-query", actor=actor - ) - return [ - JumpSQL( - sql=f""" - WITH allowed_databases AS ( - {database_sql} - ) - SELECT - 'database' AS type, - parent AS label, - NULL AS description, - json_object( - 'method', 'database', - 'database', parent - ) AS url, - parent AS search_text, - NULL AS display_name - FROM allowed_databases - """, - params=database_params, - ), - JumpSQL( - sql=f""" - WITH allowed_tables AS ( - {table_sql} - ) - SELECT - CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type, - allowed_tables.parent || ': ' || allowed_tables.child AS label, - NULL AS description, - json_object( - 'method', 'table', - 'database', allowed_tables.parent, - 'table', allowed_tables.child - ) AS url, - allowed_tables.parent || ' ' || allowed_tables.child AS search_text, - NULL AS display_name - FROM allowed_tables - LEFT JOIN catalog_views - ON catalog_views.database_name = allowed_tables.parent - AND catalog_views.view_name = allowed_tables.child - """, - params=table_params, - ), - JumpSQL( - sql=f""" - WITH allowed_queries AS ( - {query_sql} - ) - SELECT - 'query' AS type, - allowed_queries.parent || ': ' || allowed_queries.child AS label, - NULL AS description, - json_object( - 'method', 'query', - 'database', allowed_queries.parent, - 'query', allowed_queries.child - ) AS url, - allowed_queries.parent || ' ' || allowed_queries.child AS search_text, - NULL AS display_name - FROM allowed_queries - """, - params=query_params, - ), - ] - - return inner diff --git a/datasette/default_magic_parameters.py b/datasette/default_magic_parameters.py deleted file mode 100644 index 91c1c5aa..00000000 --- a/datasette/default_magic_parameters.py +++ /dev/null @@ -1,57 +0,0 @@ -from datasette import hookimpl -import datetime -import os -import time - - -def header(key, request): - key = key.replace("_", "-").encode("utf-8") - headers_dict = dict(request.scope["headers"]) - return headers_dict.get(key, b"").decode("utf-8") - - -def actor(key, request): - if request.actor is None: - raise KeyError - return request.actor[key] - - -def cookie(key, request): - return request.cookies[key] - - -def now(key, request): - if key == "epoch": - return int(time.time()) - elif key == "date_utc": - return datetime.datetime.now(datetime.timezone.utc).date().isoformat() - elif key == "datetime_utc": - return ( - datetime.datetime.now(datetime.timezone.utc).strftime(r"%Y-%m-%dT%H:%M:%S") - + "Z" - ) - else: - raise KeyError - - -def random(key, request): - if key.startswith("chars_") and key.split("chars_")[-1].isdigit(): - num_chars = int(key.split("chars_")[-1]) - if num_chars % 2 == 1: - urandom_len = (num_chars + 1) / 2 - else: - urandom_len = num_chars / 2 - return os.urandom(int(urandom_len)).hex()[:num_chars] - else: - raise KeyError - - -@hookimpl -def register_magic_parameters(): - return [ - ("header", header), - ("actor", actor), - ("cookie", cookie), - ("now", now), - ("random", random), - ] diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py deleted file mode 100644 index 6cd46f04..00000000 --- a/datasette/default_permissions/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Default permission implementations for Datasette. - -This module provides the built-in permission checking logic through implementations -of the permission_resources_sql hook. The hooks are organized by their purpose: - -1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens -2. Root User - Grants full access when --root flag is used -3. Config Rules - Applies permissions from datasette.yaml -4. Default Settings - Enforces default_allow_sql and default view permissions - -IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL -UNION/INTERSECT operations. The order of evaluation is: - - restriction_sql fields are INTERSECTed (all must match) - - Regular sql fields are UNIONed and evaluated with cascading priority -""" - -from __future__ import annotations - -# Re-export all hooks and public utilities -from .restrictions import ( - actor_restrictions_sql as actor_restrictions_sql, - restrictions_allow_action as restrictions_allow_action, - ActorRestrictions as ActorRestrictions, -) -from .root import root_user_permissions_sql as root_user_permissions_sql -from .config import config_permissions_sql as config_permissions_sql -from .defaults import ( - # Avoid "datasette.default_permissions" does not explicitly export attribute - default_allow_sql_check as default_allow_sql_check, - default_action_permissions_sql as default_action_permissions_sql, - default_query_permissions_sql as default_query_permissions_sql, - DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, -) diff --git a/datasette/default_permissions/config.py b/datasette/default_permissions/config.py deleted file mode 100644 index aab87c1c..00000000 --- a/datasette/default_permissions/config.py +++ /dev/null @@ -1,442 +0,0 @@ -""" -Config-based permission handling for Datasette. - -Applies permission rules from datasette.yaml configuration. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl -from datasette.permissions import PermissionSQL -from datasette.utils import actor_matches_allow - -from .helpers import PermissionRowCollector, get_action_name_variants - - -class ConfigPermissionProcessor: - """ - Processes permission rules from datasette.yaml configuration. - - Configuration structure: - - permissions: # Root-level permissions block - view-instance: - id: admin - - databases: - mydb: - permissions: # Database-level permissions - view-database: - id: admin - allow: # Database-level allow block (for view-*) - id: viewer - allow_sql: # execute-sql allow block - id: analyst - tables: - users: - permissions: # Table-level permissions - view-table: - id: admin - allow: # Table-level allow block - id: viewer - queries: - my_query: - permissions: # Query-level permissions - view-query: - id: admin - allow: # Query-level allow block - id: viewer - """ - - def __init__( - self, - datasette: "Datasette", - actor: Optional[dict], - action: str, - ): - self.datasette = datasette - self.actor = actor - self.action = action - self.config = datasette.config or {} - self.collector = PermissionRowCollector(prefix="cfg") - - # Pre-compute action variants - self.action_checks = get_action_name_variants(datasette, action) - self.action_obj = datasette.actions.get(action) - - # Parse restrictions if present - self.has_restrictions = actor and "_r" in actor if actor else False - self.restrictions = actor.get("_r", {}) if actor else {} - - # Pre-compute restriction info for efficiency - self.restricted_databases: Set[str] = set() - self.restricted_tables: Set[Tuple[str, str]] = set() - - if self.has_restrictions: - self.restricted_databases = { - db_name - for db_name, db_actions in (self.restrictions.get("d") or {}).items() - if self.action_checks.intersection(db_actions) - } - self.restricted_tables = { - (db_name, table_name) - for db_name, tables in (self.restrictions.get("r") or {}).items() - for table_name, table_actions in tables.items() - if self.action_checks.intersection(table_actions) - } - # Tables implicitly reference their parent databases - self.restricted_databases.update(db for db, _ in self.restricted_tables) - - def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]: - """Evaluate an allow block against the current actor.""" - if allow_block is None: - return None - return actor_matches_allow(self.actor, allow_block) - - def is_in_restriction_allowlist( - self, - parent: Optional[str], - child: Optional[str], - ) -> bool: - """Check if resource is allowed by actor restrictions.""" - if not self.has_restrictions: - return True # No restrictions, all resources allowed - - # Check global allowlist - if self.action_checks.intersection(self.restrictions.get("a", [])): - return True - - # Check database-level allowlist - if parent and self.action_checks.intersection( - self.restrictions.get("d", {}).get(parent, []) - ): - return True - - # Check table-level allowlist - if parent: - table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {}) - if child: - table_actions = table_restrictions.get(child, []) - if self.action_checks.intersection(table_actions): - return True - else: - # Parent query should proceed if any child in this database is allowlisted - for table_actions in table_restrictions.values(): - if self.action_checks.intersection(table_actions): - return True - - # Parent/child both None: include if any restrictions exist for this action - if parent is None and child is None: - if self.action_checks.intersection(self.restrictions.get("a", [])): - return True - if self.restricted_databases: - return True - if self.restricted_tables: - return True - - return False - - def add_permissions_rule( - self, - parent: Optional[str], - child: Optional[str], - permissions_block: Optional[dict], - scope_desc: str, - ) -> None: - """Add a rule from a permissions:{action} block.""" - if permissions_block is None: - return - - action_allow_block = permissions_block.get(self.action) - result = self.evaluate_allow_block(action_allow_block) - - self.collector.add( - parent=parent, - child=child, - allow=result, - reason=f"config {'allow' if result else 'deny'} {scope_desc}", - if_not_none=True, - ) - - def add_allow_block_rule( - self, - parent: Optional[str], - child: Optional[str], - allow_block: Any, - scope_desc: str, - ) -> None: - """ - Add rules from an allow:{} block. - - For allow blocks, if the block exists but doesn't match the actor, - this is treated as a deny. We also handle the restriction-gate logic. - """ - if allow_block is None: - return - - # Skip if resource is not in restriction allowlist - if not self.is_in_restriction_allowlist(parent, child): - return - - result = self.evaluate_allow_block(allow_block) - bool_result = bool(result) - - self.collector.add( - parent, - child, - bool_result, - f"config {'allow' if result else 'deny'} {scope_desc}", - ) - - # Handle restriction-gate: add explicit denies for restricted resources - self._add_restriction_gate_denies(parent, child, bool_result, scope_desc) - - def _add_restriction_gate_denies( - self, - parent: Optional[str], - child: Optional[str], - is_allowed: bool, - scope_desc: str, - ) -> None: - """ - When a config rule denies at a higher level, add explicit denies - for restricted resources to prevent child-level allows from - incorrectly granting access. - """ - if is_allowed or child is not None or not self.has_restrictions: - return - - if not self.action_obj: - return - - reason = f"config deny {scope_desc} (restriction gate)" - - if parent is None: - # Root-level deny: add denies for all restricted resources - if self.action_obj.takes_parent: - for db_name in self.restricted_databases: - self.collector.add(db_name, None, False, reason) - if self.action_obj.takes_child: - for db_name, table_name in self.restricted_tables: - self.collector.add(db_name, table_name, False, reason) - else: - # Database-level deny: add denies for tables in that database - if self.action_obj.takes_child: - for db_name, table_name in self.restricted_tables: - if db_name == parent: - self.collector.add(db_name, table_name, False, reason) - - def process(self) -> Optional[PermissionSQL]: - """Process all config rules and return combined PermissionSQL.""" - self._process_root_permissions() - self._process_databases() - self._process_root_allow_blocks() - - return self.collector.to_permission_sql() - - def _process_root_permissions(self) -> None: - """Process root-level permissions block.""" - root_perms = self.config.get("permissions") or {} - self.add_permissions_rule( - None, - None, - root_perms, - f"permissions for {self.action}", - ) - - def _process_databases(self) -> None: - """Process database-level and nested configurations.""" - databases = self.config.get("databases") or {} - - for db_name, db_config in databases.items(): - self._process_database(db_name, db_config or {}) - - def _process_database(self, db_name: str, db_config: dict) -> None: - """Process a single database's configuration.""" - # Database-level permissions block - db_perms = db_config.get("permissions") or {} - self.add_permissions_rule( - db_name, - None, - db_perms, - f"permissions for {self.action} on {db_name}", - ) - - # Process tables - for table_name, table_config in (db_config.get("tables") or {}).items(): - self._process_table(db_name, table_name, table_config or {}) - - # Process queries - for query_name, query_config in (db_config.get("queries") or {}).items(): - self._process_query(db_name, query_name, query_config) - - # Database-level allow blocks - self._process_database_allow_blocks(db_name, db_config) - - def _process_table( - self, - db_name: str, - table_name: str, - table_config: dict, - ) -> None: - """Process a single table's configuration.""" - # Table-level permissions block - table_perms = table_config.get("permissions") or {} - self.add_permissions_rule( - db_name, - table_name, - table_perms, - f"permissions for {self.action} on {db_name}/{table_name}", - ) - - # Table-level allow block (for view-table) - if self.action == "view-table": - self.add_allow_block_rule( - db_name, - table_name, - table_config.get("allow"), - f"allow for {self.action} on {db_name}/{table_name}", - ) - - def _process_query( - self, - db_name: str, - query_name: str, - query_config: Any, - ) -> None: - """Process a single query's configuration.""" - # Query config can be a string (just SQL) or dict - if not isinstance(query_config, dict): - return - - # Query-level permissions block - query_perms = query_config.get("permissions") or {} - self.add_permissions_rule( - db_name, - query_name, - query_perms, - f"permissions for {self.action} on {db_name}/{query_name}", - ) - - # Query-level allow block (for view-query) - if self.action == "view-query": - self.add_allow_block_rule( - db_name, - query_name, - query_config.get("allow"), - f"allow for {self.action} on {db_name}/{query_name}", - ) - - def _process_database_allow_blocks( - self, - db_name: str, - db_config: dict, - ) -> None: - """Process database-level allow/allow_sql blocks.""" - # view-database allow block - if self.action == "view-database": - self.add_allow_block_rule( - db_name, - None, - db_config.get("allow"), - f"allow for {self.action} on {db_name}", - ) - - # execute-sql allow_sql block - if self.action == "execute-sql": - self.add_allow_block_rule( - db_name, - None, - db_config.get("allow_sql"), - f"allow_sql for {db_name}", - ) - - # view-table uses database-level allow for inheritance - if self.action == "view-table": - self.add_allow_block_rule( - db_name, - None, - db_config.get("allow"), - f"allow for {self.action} on {db_name}", - ) - - # view-query uses database-level allow for inheritance - if self.action == "view-query": - self.add_allow_block_rule( - db_name, - None, - db_config.get("allow"), - f"allow for {self.action} on {db_name}", - ) - - def _process_root_allow_blocks(self) -> None: - """Process root-level allow/allow_sql blocks.""" - root_allow = self.config.get("allow") - - if self.action == "view-instance": - self.add_allow_block_rule( - None, - None, - root_allow, - "allow for view-instance", - ) - - if self.action == "view-database": - self.add_allow_block_rule( - None, - None, - root_allow, - "allow for view-database", - ) - - if self.action == "view-table": - self.add_allow_block_rule( - None, - None, - root_allow, - "allow for view-table", - ) - - if self.action == "view-query": - self.add_allow_block_rule( - None, - None, - root_allow, - "allow for view-query", - ) - - if self.action == "execute-sql": - self.add_allow_block_rule( - None, - None, - self.config.get("allow_sql"), - "allow_sql", - ) - - -@hookimpl(specname="permission_resources_sql") -async def config_permissions_sql( - datasette: "Datasette", - actor: Optional[dict], - action: str, -) -> Optional[List[PermissionSQL]]: - """ - Apply permission rules from datasette.yaml configuration. - - This processes: - - permissions: blocks at root, database, table, and query levels - - allow: blocks for view-* actions - - allow_sql: blocks for execute-sql action - """ - processor = ConfigPermissionProcessor(datasette, actor, action) - result = processor.process() - - if result is None: - return [] - - return [result] diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py deleted file mode 100644 index 5bc74425..00000000 --- a/datasette/default_permissions/defaults.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Default permission settings for Datasette. - -Provides default allow rules for standard view/execute actions. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl -from datasette.permissions import PermissionSQL - -# Actions that are allowed by default (unless --default-deny is used) -DEFAULT_ALLOW_ACTIONS = frozenset( - { - "view-instance", - "view-database", - "view-database-download", - "view-table", - "view-query", - "execute-sql", - } -) - - -@hookimpl(specname="permission_resources_sql") -async def default_allow_sql_check( - datasette: "Datasette", - actor: Optional[dict], - action: str, -) -> Optional[PermissionSQL]: - """ - Enforce the default_allow_sql setting. - - When default_allow_sql is false (the default), execute-sql is denied - unless explicitly allowed by config or other rules. - """ - if action == "execute-sql": - if not datasette.setting("default_allow_sql"): - return PermissionSQL.deny(reason="default_allow_sql is false") - - return None - - -@hookimpl(specname="permission_resources_sql") -async def default_action_permissions_sql( - datasette: "Datasette", - actor: Optional[dict], - action: str, -) -> Optional[PermissionSQL]: - """ - Provide default allow rules for standard view/execute actions. - - These defaults are skipped when datasette is started with --default-deny. - The restriction_sql mechanism (from actor_restrictions_sql) will still - filter these results if the actor has restrictions. - """ - if datasette.default_deny: - return None - - if action in DEFAULT_ALLOW_ACTIONS: - reason = f"default allow for {action}".replace("'", "''") - return PermissionSQL.allow(reason=reason) - - return None - - -@hookimpl(specname="permission_resources_sql") -async def default_query_permissions_sql( - datasette: "Datasette", - actor: Optional[dict], - action: str, -) -> Optional[PermissionSQL]: - actor_id = actor.get("id") if isinstance(actor, dict) else None - - if action not in {"view-query", "update-query", "delete-query"}: - return None - - params = {"query_owner_id": actor_id} - rule_sqls = [] - if actor_id is not None: - if action in {"update-query", "delete-query"}: - # Query owner can update/delete query - rule_sqls.append(""" - SELECT database_name AS parent, name AS child, 1 AS allow, - 'query owner' AS reason - FROM queries - WHERE source = 'user' - AND owner_id = :query_owner_id - """) - else: - # Query owner can view-query - rule_sqls.append(""" - SELECT database_name AS parent, name AS child, 1 AS allow, - 'query owner' AS reason - FROM queries - WHERE owner_id = :query_owner_id - """) - - # restriction_sql enforces private queries ONLY visible/mutable by owner - return PermissionSQL( - sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, - restriction_sql=""" - SELECT database_name AS parent, name AS child - FROM queries - WHERE is_private = 0 - OR owner_id = :query_owner_id - """, - params=params, - ) diff --git a/datasette/default_permissions/helpers.py b/datasette/default_permissions/helpers.py deleted file mode 100644 index 47e03569..00000000 --- a/datasette/default_permissions/helpers.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Shared helper utilities for default permission implementations. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Set - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette.permissions import PermissionSQL - - -def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]: - """ - Get all name variants for an action (full name and abbreviation). - - Example: - get_action_name_variants(ds, "view-table") -> {"view-table", "vt"} - """ - variants = {action} - action_obj = datasette.actions.get(action) - if action_obj and action_obj.abbr: - variants.add(action_obj.abbr) - return variants - - -def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool: - """Check if an action (or its abbreviation) is in a list.""" - return bool(get_action_name_variants(datasette, action).intersection(action_list)) - - -@dataclass -class PermissionRow: - """A single permission rule row.""" - - parent: Optional[str] - child: Optional[str] - allow: bool - reason: str - - -class PermissionRowCollector: - """Collects permission rows and converts them to PermissionSQL.""" - - def __init__(self, prefix: str = "row"): - self.rows: List[PermissionRow] = [] - self.prefix = prefix - - def add( - self, - parent: Optional[str], - child: Optional[str], - allow: Optional[bool], - reason: str, - if_not_none: bool = False, - ) -> None: - """Add a permission row. If if_not_none=True, only add if allow is not None.""" - if if_not_none and allow is None: - return - self.rows.append(PermissionRow(parent, child, allow, reason)) - - def to_permission_sql(self) -> Optional[PermissionSQL]: - """Convert collected rows to a PermissionSQL object.""" - if not self.rows: - return None - - parts = [] - params = {} - - for idx, row in enumerate(self.rows): - key = f"{self.prefix}_{idx}" - parts.append( - f"SELECT :{key}_parent AS parent, :{key}_child AS child, " - f":{key}_allow AS allow, :{key}_reason AS reason" - ) - params[f"{key}_parent"] = row.parent - params[f"{key}_child"] = row.child - params[f"{key}_allow"] = 1 if row.allow else 0 - params[f"{key}_reason"] = row.reason - - sql = "\nUNION ALL\n".join(parts) - return PermissionSQL(sql=sql, params=params) diff --git a/datasette/default_permissions/restrictions.py b/datasette/default_permissions/restrictions.py deleted file mode 100644 index a22cd7e5..00000000 --- a/datasette/default_permissions/restrictions.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Actor restriction handling for Datasette permissions. - -This module handles the _r (restrictions) key in actor dictionaries, which -contains allowlists of resources the actor can access. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Set, Tuple - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl -from datasette.permissions import PermissionSQL - -from .helpers import action_in_list, get_action_name_variants - - -@dataclass -class ActorRestrictions: - """Parsed actor restrictions from the _r key.""" - - global_actions: List[str] # _r.a - globally allowed actions - database_actions: dict # _r.d - {db_name: [actions]} - table_actions: dict # _r.r - {db_name: {table: [actions]}} - - @classmethod - def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]: - """Parse restrictions from actor dict. Returns None if no restrictions.""" - if not actor: - return None - assert isinstance(actor, dict), "actor must be a dictionary" - - restrictions = actor.get("_r") - if restrictions is None: - return None - - return cls( - global_actions=restrictions.get("a", []), - database_actions=restrictions.get("d", {}), - table_actions=restrictions.get("r", {}), - ) - - def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool: - """Check if action is in the global allowlist.""" - return action_in_list(datasette, action, self.global_actions) - - def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]: - """Get database names where this action is allowed.""" - allowed = set() - for db_name, db_actions in self.database_actions.items(): - if action_in_list(datasette, action, db_actions): - allowed.add(db_name) - return allowed - - def get_allowed_tables( - self, datasette: "Datasette", action: str - ) -> Set[Tuple[str, str]]: - """Get (database, table) pairs where this action is allowed.""" - allowed = set() - for db_name, tables in self.table_actions.items(): - for table_name, table_actions in tables.items(): - if action_in_list(datasette, action, table_actions): - allowed.add((db_name, table_name)) - return allowed - - -@hookimpl(specname="permission_resources_sql") -async def actor_restrictions_sql( - datasette: "Datasette", - actor: Optional[dict], - action: str, -) -> Optional[List[PermissionSQL]]: - """ - Handle actor restriction-based permission rules. - - When an actor has an "_r" key, it contains an allowlist of resources they - can access. This function returns restriction_sql that filters the final - results to only include resources in that allowlist. - - The _r structure: - { - "a": ["vi", "pd"], # Global actions allowed - "d": {"mydb": ["vt", "es"]}, # Database-level actions - "r": {"mydb": {"users": ["vt"]}} # Table-level actions - } - """ - if not actor: - return None - - restrictions = ActorRestrictions.from_actor(actor) - - if restrictions is None: - # No restrictions - all resources allowed - return [] - - # If globally allowed, no filtering needed - if restrictions.is_action_globally_allowed(datasette, action): - return [] - - # Build restriction SQL - allowed_dbs = restrictions.get_allowed_databases(datasette, action) - allowed_tables = restrictions.get_allowed_tables(datasette, action) - - # If nothing is allowed for this action, return empty-set restriction - if not allowed_dbs and not allowed_tables: - return [ - PermissionSQL( - params={"deny": f"actor restrictions: {action} not in allowlist"}, - restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0", - ) - ] - - # Build UNION of allowed resources - selects = [] - params = {} - counter = 0 - - # Database-level entries (parent, NULL) - allows all children - for db_name in allowed_dbs: - key = f"restr_{counter}" - counter += 1 - selects.append(f"SELECT :{key}_parent AS parent, NULL AS child") - params[f"{key}_parent"] = db_name - - # Table-level entries (parent, child) - for db_name, table_name in allowed_tables: - key = f"restr_{counter}" - counter += 1 - selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child") - params[f"{key}_parent"] = db_name - params[f"{key}_child"] = table_name - - restriction_sql = "\nUNION ALL\n".join(selects) - - return [PermissionSQL(params=params, restriction_sql=restriction_sql)] - - -def restrictions_allow_action( - datasette: "Datasette", - restrictions: dict, - action: str, - resource: Optional[str | Tuple[str, str]], -) -> bool: - """ - Check if restrictions allow the requested action on the requested resource. - - This is a synchronous utility function for use by other code that needs - to quickly check restriction allowlists. - - Args: - datasette: The Datasette instance - restrictions: The _r dict from an actor - action: The action name to check - resource: None for global, str for database, (db, table) tuple for table - - Returns: - True if allowed, False if denied - """ - # Does this action have an abbreviation? - to_check = get_action_name_variants(datasette, action) - - # Check global level (any resource) - all_allowed = restrictions.get("a") - if all_allowed is not None: - assert isinstance(all_allowed, list) - if to_check.intersection(all_allowed): - return True - - # Check database level - if resource: - if isinstance(resource, str): - database_name = resource - else: - database_name = resource[0] - database_allowed = restrictions.get("d", {}).get(database_name) - if database_allowed is not None: - assert isinstance(database_allowed, list) - if to_check.intersection(database_allowed): - return True - - # Check table/resource level - if resource is not None and not isinstance(resource, str) and len(resource) == 2: - database, table = resource - table_allowed = restrictions.get("r", {}).get(database, {}).get(table) - if table_allowed is not None: - assert isinstance(table_allowed, list) - if to_check.intersection(table_allowed): - return True - - # This action is not explicitly allowed, so reject it - return False diff --git a/datasette/default_permissions/root.py b/datasette/default_permissions/root.py deleted file mode 100644 index 4931f7ff..00000000 --- a/datasette/default_permissions/root.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Root user permission handling for Datasette. - -Grants full permissions to the root user when --root flag is used. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl -from datasette.permissions import PermissionSQL - - -@hookimpl(specname="permission_resources_sql") -async def root_user_permissions_sql( - datasette: "Datasette", - actor: Optional[dict], -) -> Optional[PermissionSQL]: - """ - Grant root user full permissions when --root flag is used. - """ - if not datasette.root_enabled: - return None - if actor is not None and actor.get("id") == "root": - return PermissionSQL.allow(reason="root user") diff --git a/datasette/default_permissions/tokens.py b/datasette/default_permissions/tokens.py deleted file mode 100644 index 7a359dc6..00000000 --- a/datasette/default_permissions/tokens.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Token authentication for Datasette. - -Registers the default SignedTokenHandler and delegates token verification -to datasette.verify_token() so all registered handlers are tried. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl -from datasette.tokens import SignedTokenHandler - - -@hookimpl -def register_token_handler(datasette: "Datasette"): - """Register the default signed token handler.""" - return SignedTokenHandler() - - -@hookimpl(specname="actor_from_request") -async def actor_from_signed_api_token( - datasette: "Datasette", request -) -> Optional[dict]: - """ - Authenticate requests using API tokens by delegating to all registered - token handlers via datasette.verify_token(). - """ - authorization = request.headers.get("authorization") - if not authorization: - return None - if not authorization.startswith("Bearer "): - return None - - token = authorization[len("Bearer ") :] - return await datasette.verify_token(token) diff --git a/datasette/default_query_actions.py b/datasette/default_query_actions.py deleted file mode 100644 index 2183e70b..00000000 --- a/datasette/default_query_actions.py +++ /dev/null @@ -1,48 +0,0 @@ -from datasette import hookimpl -from datasette.resources import QueryResource - - -@hookimpl -def query_actions(datasette, actor, database, query_name, request): - # Only stored queries (with a name) can be edited or deleted - if not query_name: - return None - - async def inner(): - query = await datasette.get_query(database, query_name) - if query is None: - return [] - # Config-defined and trusted queries are managed outside the UI - if query.source == "config" or query.is_trusted: - return [] - - links = [] - if await datasette.allowed( - action="update-query", - resource=QueryResource(database, query_name), - actor=actor, - ): - links.append( - { - "href": datasette.urls.table(database, query_name) + "/-/edit", - "label": "Edit this query", - "description": ( - "Change the title, description, SQL or visibility." - ), - } - ) - if await datasette.allowed( - action="delete-query", - resource=QueryResource(database, query_name), - actor=actor, - ): - links.append( - { - "href": datasette.urls.table(database, query_name) + "/-/delete", - "label": "Delete this query", - "description": "Permanently remove this saved query.", - } - ) - return links - - return inner diff --git a/datasette/events.py b/datasette/events.py deleted file mode 100644 index e8786da9..00000000 --- a/datasette/events.py +++ /dev/null @@ -1,293 +0,0 @@ -from abc import ABC, abstractproperty -from dataclasses import asdict, dataclass, field -from datasette.hookspecs import hookimpl -from datetime import datetime, timezone - - -@dataclass -class Event(ABC): - @abstractproperty - def name(self): - pass - - created: datetime = field( - init=False, default_factory=lambda: datetime.now(timezone.utc) - ) - actor: dict | None - - def properties(self): - properties = asdict(self) - properties.pop("actor", None) - properties.pop("created", None) - return properties - - -@dataclass -class LoginEvent(Event): - """ - Event name: ``login`` - - A user (represented by ``event.actor``) has logged in. - """ - - name = "login" - - -@dataclass -class LogoutEvent(Event): - """ - Event name: ``logout`` - - A user (represented by ``event.actor``) has logged out. - """ - - name = "logout" - - -@dataclass -class CreateTokenEvent(Event): - """ - Event name: ``create-token`` - - A user created an API token. - - :ivar expires_after: Number of seconds after which this token will expire. - :type expires_after: int or None - :ivar restrict_all: Restricted permissions for this token. - :type restrict_all: list - :ivar restrict_database: Restricted database permissions for this token. - :type restrict_database: dict - :ivar restrict_resource: Restricted resource permissions for this token. - :type restrict_resource: dict - """ - - name = "create-token" - expires_after: int | None - restrict_all: list - restrict_database: dict - restrict_resource: dict - - -@dataclass -class CreateTableEvent(Event): - """ - Event name: ``create-table`` - - A new table has been created in the database. - - :ivar database: The name of the database where the table was created. - :type database: str - :ivar table: The name of the table that was created - :type table: str - :ivar schema: The SQL schema definition for the new table. - :type schema: str - """ - - name = "create-table" - database: str - table: str - schema: str - - -@dataclass -class DropTableEvent(Event): - """ - Event name: ``drop-table`` - - A table has been dropped from the database. - - :ivar database: The name of the database where the table was dropped. - :type database: str - :ivar table: The name of the table that was dropped - :type table: str - """ - - name = "drop-table" - database: str - table: str - - -@dataclass -class AlterTableEvent(Event): - """ - Event name: ``alter-table`` - - A table has been altered. - - :ivar database: The name of the database where the table was altered - :type database: str - :ivar table: The name of the table that was altered - :type table: str - :ivar before_schema: The table's SQL schema before the alteration - :type before_schema: str - :ivar after_schema: The table's SQL schema after the alteration - :type after_schema: str - """ - - name = "alter-table" - database: str - table: str - before_schema: str - after_schema: str - - -@dataclass -class InsertRowsEvent(Event): - """ - Event name: ``insert-rows`` - - Rows were inserted into a table. - - :ivar database: The name of the database where the rows were inserted. - :type database: str - :ivar table: The name of the table where the rows were inserted. - :type table: str - :ivar num_rows: The number of rows that were requested to be inserted. - :type num_rows: int - :ivar ignore: Was ignore set? - :type ignore: bool - :ivar replace: Was replace set? - :type replace: bool - """ - - name = "insert-rows" - database: str - table: str - num_rows: int - ignore: bool - replace: bool - - -@dataclass -class UpsertRowsEvent(Event): - """ - Event name: ``upsert-rows`` - - Rows were upserted into a table. - - :ivar database: The name of the database where the rows were inserted. - :type database: str - :ivar table: The name of the table where the rows were inserted. - :type table: str - :ivar num_rows: The number of rows that were requested to be inserted. - :type num_rows: int - """ - - name = "upsert-rows" - database: str - table: str - num_rows: int - - -@dataclass -class UpdateRowEvent(Event): - """ - Event name: ``update-row`` - - A row was updated in a table. - - :ivar database: The name of the database where the row was updated. - :type database: str - :ivar table: The name of the table where the row was updated. - :type table: str - :ivar pks: The primary key values of the updated row. - """ - - name = "update-row" - database: str - table: str - pks: list - - -@dataclass -class RenameTableEvent(Event): - """ - Event name: ``rename-table`` - - A table has been renamed. - - :ivar database: The name of the database containing the renamed table. - :type database: str - :ivar old_table: The previous name of the table. - :type old_table: str - :ivar new_table: The new name of the table. - :type new_table: str - """ - - name = "rename-table" - database: str - old_table: str - new_table: str - - -@dataclass -class DeleteRowEvent(Event): - """ - Event name: ``delete-row`` - - A row was deleted from a table. - - :ivar database: The name of the database where the row was deleted. - :type database: str - :ivar table: The name of the table where the row was deleted. - :type table: str - :ivar pks: The primary key values of the deleted row. - """ - - name = "delete-row" - database: str - table: str - pks: list - - -@hookimpl -def write_wrapper(datasette, database, request, transaction): - def wrapper(conn, track_event): - # Snapshot rootpage -> name before the write - before = { - row[1]: row[0] - for row in conn.execute( - "select name, rootpage from sqlite_master" - " where type='table' and rootpage != 0" - ).fetchall() - } - yield - # Snapshot rootpage -> name after the write - after = { - row[1]: row[0] - for row in conn.execute( - "select name, rootpage from sqlite_master" - " where type='table' and rootpage != 0" - ).fetchall() - } - # Detect renames: same rootpage, different name - for rootpage, old_name in before.items(): - new_name = after.get(rootpage) - if new_name and new_name != old_name: - track_event( - RenameTableEvent( - actor=request.actor if request else None, - database=database, - old_table=old_name, - new_table=new_name, - ) - ) - - return wrapper - - -@hookimpl -def register_events(): - return [ - LoginEvent, - LogoutEvent, - CreateTableEvent, - CreateTokenEvent, - AlterTableEvent, - RenameTableEvent, - DropTableEvent, - InsertRowsEvent, - UpsertRowsEvent, - UpdateRowEvent, - DeleteRowEvent, - ] diff --git a/datasette/extras.py b/datasette/extras.py deleted file mode 100644 index 5cab52a4..00000000 --- a/datasette/extras.py +++ /dev/null @@ -1,118 +0,0 @@ -import re -from dataclasses import dataclass -from enum import Enum -from typing import ClassVar - -from asyncinject import Registry - - -def extra_names_from_request(request): - extra_bits = request.args.getlist("_extra") - extras = set() - for bit in extra_bits: - extras.update(part for part in bit.split(",") if part) - return extras - - -class ExtraScope(Enum): - TABLE = "table" - ROW = "row" - QUERY = "query" - - -@dataclass(frozen=True) -class ExtraExample: - path: str | None = None - key: str | None = None - value: object | None = None - note: str | None = None - - -class Provider: - name: ClassVar[str | None] = None - scopes: ClassVar[set[ExtraScope]] = set() - public: ClassVar[bool] = False - - @classmethod - def key(cls): - return cls.name or _camel_to_snake(cls.__name__) - - @classmethod - def available_for(cls, scope): - return scope in cls.scopes - - async def resolve(self, context): - raise NotImplementedError - - -class Extra(Provider): - description: ClassVar[str | None] = None - example: ClassVar[ExtraExample | None] = None - examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {} - public: ClassVar[bool] = True - expensive: ClassVar[bool] = False - docs_note: ClassVar[str | None] = None - - @classmethod - def example_for_scope(cls, scope): - return cls.examples.get(scope, cls.example) - - -class ExtraRegistry: - def __init__(self, classes): - self.classes = list(classes) - self.classes_by_name = {cls.key(): cls for cls in self.classes} - # Lazily-built shared state, keyed by scope. Safe to share across - # requests because Extra instances are stateless and asyncinject's - # Registry keeps per-call state local to each resolve_multi() call. - # If extras classes ever become registerable at runtime (e.g. via a - # plugin hook) these caches will need invalidating. - self._scope_registries = {} - self._allowed_names = {} - - def classes_for_scope(self, scope, include_internal=True): - classes = [ - cls - for cls in self.classes - if cls.available_for(scope) and (include_internal or cls.public) - ] - return classes - - def public_classes_for_scope(self, scope): - return self.classes_for_scope(scope, include_internal=False) - - def _registry_for_scope(self, scope): - registry = self._scope_registries.get(scope) - if registry is None: - registry = Registry() - for cls in self.classes_for_scope(scope): - registry.register(cls().resolve, name=cls.key()) - self._scope_registries[scope] = registry - return registry - - def _allowed_names_for_scope(self, scope, include_internal): - key = (scope, include_internal) - names = self._allowed_names.get(key) - if names is None: - names = { - cls.key() - for cls in self.classes_for_scope( - scope, include_internal=include_internal - ) - } - self._allowed_names[key] = names - return names - - async def resolve(self, requested, context, scope, include_internal=False): - allowed_names = self._allowed_names_for_scope(scope, include_internal) - requested_names = [name for name in requested if name in allowed_names] - resolved = await self._registry_for_scope(scope).resolve_multi( - requested_names, results={"context": context} - ) - return {name: resolved[name] for name in requested_names} - - -def _camel_to_snake(name): - name = re.sub(r"(Extra|Provider)$", "", name) - name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() diff --git a/datasette/facets.py b/datasette/facets.py deleted file mode 100644 index abe0605e..00000000 --- a/datasette/facets.py +++ /dev/null @@ -1,576 +0,0 @@ -import json -import urllib -from datasette import hookimpl -from datasette.database import QueryInterrupted -from datasette.utils import ( - escape_sqlite, - path_with_added_args, - path_with_removed_args, - detect_json1, - sqlite3, -) - - -def load_facet_configs(request, table_config): - # Given a request and the configuration for a table, return - # a dictionary of selected facets, their lists of configs and for each - # config whether it came from the request or the metadata. - # - # return {type: [ - # {"source": "metadata", "config": config1}, - # {"source": "request", "config": config2}]} - facet_configs = {} - table_config = table_config or {} - table_facet_configs = table_config.get("facets", []) - for facet_config in table_facet_configs: - if isinstance(facet_config, str): - type = "column" - facet_config = {"simple": facet_config} - else: - assert ( - len(facet_config.values()) == 1 - ), "Metadata config dicts should be {type: config}" - type, facet_config = list(facet_config.items())[0] - if isinstance(facet_config, str): - facet_config = {"simple": facet_config} - facet_configs.setdefault(type, []).append( - {"source": "metadata", "config": facet_config} - ) - qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True) - for key, values in qs_pairs.items(): - if key.startswith("_facet"): - # Figure out the facet type - if key == "_facet": - type = "column" - elif key.startswith("_facet_"): - type = key[len("_facet_") :] - for value in values: - # The value is the facet_config - either JSON or not - facet_config = ( - json.loads(value) if value.startswith("{") else {"simple": value} - ) - facet_configs.setdefault(type, []).append( - {"source": "request", "config": facet_config} - ) - return facet_configs - - -@hookimpl -def register_facet_classes(): - classes = [ColumnFacet, DateFacet] - if detect_json1(): - classes.append(ArrayFacet) - return classes - - -class Facet: - type = None - # How many rows to consider when suggesting facets: - suggest_consider = 1000 - - def __init__( - self, - ds, - request, - database, - sql=None, - table=None, - params=None, - table_config=None, - row_count=None, - ): - assert table or sql, "Must provide either table= or sql=" - self.ds = ds - self.request = request - self.database = database - # For foreign key expansion. Can be None for e.g. stored SQL queries: - self.table = table - self.sql = sql or f"select * from [{table}]" - self.params = params or [] - self.table_config = table_config - # row_count can be None, in which case we calculate it ourselves: - self.row_count = row_count - - def get_configs(self): - configs = load_facet_configs(self.request, self.table_config) - return configs.get(self.type) or [] - - def get_querystring_pairs(self): - # ?_foo=bar&_foo=2&empty= becomes: - # [('_foo', 'bar'), ('_foo', '2'), ('empty', '')] - return urllib.parse.parse_qsl(self.request.query_string, keep_blank_values=True) - - def get_facet_size(self): - facet_size = self.ds.setting("default_facet_size") - max_returned_rows = self.ds.setting("max_returned_rows") - table_facet_size = None - if self.table: - config_facet_size = ( - self.ds.config.get("databases", {}) - .get(self.database, {}) - .get("tables", {}) - .get(self.table, {}) - .get("facet_size") - ) - if config_facet_size: - table_facet_size = config_facet_size - custom_facet_size = self.request.args.get("_facet_size") - if custom_facet_size: - if custom_facet_size == "max": - facet_size = max_returned_rows - elif custom_facet_size.isdigit(): - facet_size = int(custom_facet_size) - else: - # Invalid value, ignore it - custom_facet_size = None - if table_facet_size and not custom_facet_size: - if table_facet_size == "max": - facet_size = max_returned_rows - else: - facet_size = table_facet_size - return min(facet_size, max_returned_rows) - - async def suggest(self): - return [] - - async def facet_results(self): - # returns ([results], [timed_out]) - # TODO: Include "hideable" with each one somehow, which indicates if it was - # defined in metadata (in which case you cannot turn it off) - raise NotImplementedError - - async def get_columns(self, sql, params=None): - # Detect column names using the "limit 0" trick - return ( - await self.ds.execute( - self.database, f"select * from ({sql}) limit 0", params or [] - ) - ).columns - - -class ColumnFacet(Facet): - type = "column" - - async def suggest(self): - row_count = await self.get_row_count() - columns = await self.get_columns(self.sql, self.params) - facet_size = self.get_facet_size() - suggested_facets = [] - already_enabled = [c["config"]["simple"] for c in self.get_configs()] - for column in columns: - if column in already_enabled: - continue - suggested_facet_sql = """ - with limited as (select * from ({sql}) limit {suggest_consider}) - select {column} as value, count(*) as n from limited - where value is not null - group by value - limit {limit} - """.format( - column=escape_sqlite(column), - sql=self.sql, - limit=facet_size + 1, - suggest_consider=self.suggest_consider, - ) - distinct_values = None - try: - distinct_values = await self.ds.execute( - self.database, - suggested_facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_suggest_time_limit_ms"), - ) - num_distinct_values = len(distinct_values) - if ( - 1 < num_distinct_values < row_count - and num_distinct_values <= facet_size - # And at least one has n > 1 - and any(r["n"] > 1 for r in distinct_values) - ): - suggested_facets.append( - { - "name": column, - "toggle_url": self.ds.absolute_url( - self.request, - self.ds.urls.path( - path_with_added_args( - self.request, {"_facet": column} - ) - ), - ), - } - ) - except QueryInterrupted: - continue - return suggested_facets - - async def get_row_count(self): - if self.row_count is None: - self.row_count = ( - await self.ds.execute( - self.database, - f"select count(*) from (select * from ({self.sql}) limit {self.suggest_consider})", - self.params, - ) - ).rows[0][0] - return self.row_count - - async def facet_results(self): - facet_results = [] - facets_timed_out = [] - - qs_pairs = self.get_querystring_pairs() - - facet_size = self.get_facet_size() - for source_and_config in self.get_configs(): - config = source_and_config["config"] - source = source_and_config["source"] - column = config.get("column") or config["simple"] - facet_sql = """ - select {col} as value, count(*) as count from ( - {sql} - ) - where {col} is not null - group by {col} order by count desc, value limit {limit} - """.format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) - try: - facet_rows_results = await self.ds.execute( - self.database, - facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_time_limit_ms"), - ) - facet_results_values = [] - facet_results.append( - { - "name": column, - "type": self.type, - "hideable": source != "metadata", - "toggle_url": self.ds.urls.path( - path_with_removed_args(self.request, {"_facet": column}) - ), - "results": facet_results_values, - "truncated": len(facet_rows_results) > facet_size, - } - ) - facet_rows = facet_rows_results.rows[:facet_size] - if self.table: - # Attempt to expand foreign keys into labels - values = [row["value"] for row in facet_rows] - expanded = await self.ds.expand_foreign_keys( - self.request.actor, self.database, self.table, column, values - ) - else: - expanded = {} - for row in facet_rows: - column_qs = column - if column.startswith("_"): - column_qs = "{}__exact".format(column) - selected = (column_qs, str(row["value"])) in qs_pairs - if selected: - toggle_path = path_with_removed_args( - self.request, {column_qs: str(row["value"])} - ) - else: - toggle_path = path_with_added_args( - self.request, {column_qs: row["value"]} - ) - facet_results_values.append( - { - "value": row["value"], - "label": expanded.get((column, row["value"]), row["value"]), - "count": row["count"], - "toggle_url": self.ds.absolute_url( - self.request, self.ds.urls.path(toggle_path) - ), - "selected": selected, - } - ) - except QueryInterrupted: - facets_timed_out.append(column) - - return facet_results, facets_timed_out - - -class ArrayFacet(Facet): - type = "array" - - def _is_json_array_of_strings(self, json_string): - try: - array = json.loads(json_string) - except ValueError: - return False - for item in array: - if not isinstance(item, str): - return False - return True - - async def suggest(self): - columns = await self.get_columns(self.sql, self.params) - suggested_facets = [] - already_enabled = [c["config"]["simple"] for c in self.get_configs()] - for column in columns: - if column in already_enabled: - continue - # Is every value in this column either null or a JSON array? - suggested_facet_sql = """ - with limited as (select * from ({sql}) limit {suggest_consider}) - select distinct json_type({column}) - from limited - where {column} is not null and {column} != '' - """.format( - column=escape_sqlite(column), - sql=self.sql, - suggest_consider=self.suggest_consider, - ) - try: - results = await self.ds.execute( - self.database, - suggested_facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_suggest_time_limit_ms"), - log_sql_errors=False, - ) - types = tuple(r[0] for r in results.rows) - if types in (("array",), ("array", None)): - # Now check that first 100 arrays contain only strings - first_100 = [ - v[0] - for v in await self.ds.execute( - self.database, - ( - "select {column} from ({sql}) " - "where {column} is not null " - "and {column} != '' " - "and json_array_length({column}) > 0 " - "limit 100" - ).format(column=escape_sqlite(column), sql=self.sql), - self.params, - truncate=False, - custom_time_limit=self.ds.setting( - "facet_suggest_time_limit_ms" - ), - log_sql_errors=False, - ) - ] - if first_100 and all( - self._is_json_array_of_strings(r) for r in first_100 - ): - suggested_facets.append( - { - "name": column, - "type": "array", - "toggle_url": self.ds.absolute_url( - self.request, - self.ds.urls.path( - path_with_added_args( - self.request, {"_facet_array": column} - ) - ), - ), - } - ) - except (QueryInterrupted, sqlite3.OperationalError): - continue - return suggested_facets - - async def facet_results(self): - # self.configs should be a plain list of columns - facet_results = [] - facets_timed_out = [] - - facet_size = self.get_facet_size() - for source_and_config in self.get_configs(): - config = source_and_config["config"] - source = source_and_config["source"] - column = config.get("column") or config["simple"] - # https://github.com/simonw/datasette/issues/448 - facet_sql = """ - with inner as ({sql}), - deduped_array_items as ( - select - distinct j.value, - inner.* - from - json_each([inner].{col}) j - join inner - ) - select - value as value, - count(*) as count - from - deduped_array_items - group by - value - order by - count(*) desc, value limit {limit} - """.format( - col=escape_sqlite(column), - sql=self.sql, - limit=facet_size + 1, - ) - try: - facet_rows_results = await self.ds.execute( - self.database, - facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_time_limit_ms"), - ) - facet_results_values = [] - facet_results.append( - { - "name": column, - "type": self.type, - "results": facet_results_values, - "hideable": source != "metadata", - "toggle_url": self.ds.urls.path( - path_with_removed_args( - self.request, {"_facet_array": column} - ) - ), - "truncated": len(facet_rows_results) > facet_size, - } - ) - facet_rows = facet_rows_results.rows[:facet_size] - pairs = self.get_querystring_pairs() - for row in facet_rows: - value = str(row["value"]) - selected = (f"{column}__arraycontains", value) in pairs - if selected: - toggle_path = path_with_removed_args( - self.request, {f"{column}__arraycontains": value} - ) - else: - toggle_path = path_with_added_args( - self.request, {f"{column}__arraycontains": value} - ) - facet_results_values.append( - { - "value": value, - "label": value, - "count": row["count"], - "toggle_url": self.ds.absolute_url( - self.request, toggle_path - ), - "selected": selected, - } - ) - except QueryInterrupted: - facets_timed_out.append(column) - - return facet_results, facets_timed_out - - -class DateFacet(Facet): - type = "date" - - async def suggest(self): - columns = await self.get_columns(self.sql, self.params) - already_enabled = [c["config"]["simple"] for c in self.get_configs()] - suggested_facets = [] - for column in columns: - if column in already_enabled: - continue - # Does this column contain any dates in the first 100 rows? - suggested_facet_sql = """ - select date({column}) from ( - select * from ({sql}) limit 100 - ) where {column} glob "????-??-*" - """.format(column=escape_sqlite(column), sql=self.sql) - try: - results = await self.ds.execute( - self.database, - suggested_facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_suggest_time_limit_ms"), - log_sql_errors=False, - ) - values = tuple(r[0] for r in results.rows) - if any(values): - suggested_facets.append( - { - "name": column, - "type": "date", - "toggle_url": self.ds.absolute_url( - self.request, - self.ds.urls.path( - path_with_added_args( - self.request, {"_facet_date": column} - ) - ), - ), - } - ) - except (QueryInterrupted, sqlite3.OperationalError): - continue - return suggested_facets - - async def facet_results(self): - facet_results = [] - facets_timed_out = [] - args = dict(self.get_querystring_pairs()) - facet_size = self.get_facet_size() - for source_and_config in self.get_configs(): - config = source_and_config["config"] - source = source_and_config["source"] - column = config.get("column") or config["simple"] - # TODO: does this query break if inner sql produces value or count columns? - facet_sql = """ - select date({col}) as value, count(*) as count from ( - {sql} - ) - where date({col}) is not null - group by date({col}) order by count desc, value limit {limit} - """.format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) - try: - facet_rows_results = await self.ds.execute( - self.database, - facet_sql, - self.params, - truncate=False, - custom_time_limit=self.ds.setting("facet_time_limit_ms"), - ) - facet_results_values = [] - facet_results.append( - { - "name": column, - "type": self.type, - "results": facet_results_values, - "hideable": source != "metadata", - "toggle_url": path_with_removed_args( - self.request, {"_facet_date": column} - ), - "truncated": len(facet_rows_results) > facet_size, - } - ) - facet_rows = facet_rows_results.rows[:facet_size] - for row in facet_rows: - selected = str(args.get(f"{column}__date")) == str(row["value"]) - if selected: - toggle_path = path_with_removed_args( - self.request, {f"{column}__date": str(row["value"])} - ) - else: - toggle_path = path_with_added_args( - self.request, {f"{column}__date": row["value"]} - ) - facet_results_values.append( - { - "value": row["value"], - "label": row["value"], - "count": row["count"], - "toggle_url": self.ds.absolute_url( - self.request, toggle_path - ), - "selected": selected, - } - ) - except QueryInterrupted: - facets_timed_out.append(column) - - return facet_results, facets_timed_out diff --git a/datasette/filters.py b/datasette/filters.py deleted file mode 100644 index 95cc5f37..00000000 --- a/datasette/filters.py +++ /dev/null @@ -1,427 +0,0 @@ -from datasette import hookimpl -from datasette.resources import DatabaseResource -from datasette.views.base import DatasetteError -from datasette.utils.asgi import BadRequest -import json -from .utils import detect_json1, escape_sqlite, path_with_removed_args - - -@hookimpl(specname="filters_from_request") -def where_filters(request, database, datasette): - # This one deals with ?_where= - async def inner(): - where_clauses = [] - extra_wheres_for_ui = [] - if "_where" in request.args: - if not await datasette.allowed( - action="execute-sql", - resource=DatabaseResource(database=database), - actor=request.actor, - ): - raise DatasetteError("_where= is not allowed", status=403) - else: - where_clauses.extend(request.args.getlist("_where")) - extra_wheres_for_ui = [ - { - "text": text, - "remove_url": path_with_removed_args(request, {"_where": text}), - } - for text in request.args.getlist("_where") - ] - - return FilterArguments( - where_clauses, - extra_context={ - "extra_wheres_for_ui": extra_wheres_for_ui, - }, - ) - - return inner - - -@hookimpl(specname="filters_from_request") -def search_filters(request, database, table, datasette): - # ?_search= and _search_colname= - async def inner(): - where_clauses = [] - params = {} - human_descriptions = [] - extra_context = {} - - # Figure out which fts_table to use - table_metadata = await datasette.table_config(database, table) - db = datasette.get_database(database) - fts_table = request.args.get("_fts_table") - fts_table = fts_table or table_metadata.get("fts_table") - fts_table = fts_table or await db.fts_table(table) - fts_pk = request.args.get("_fts_pk", table_metadata.get("fts_pk", "rowid")) - search_args = { - key: request.args[key] - for key in request.args - if key.startswith("_search") and key != "_searchmode" - } - search = "" - search_mode_raw = table_metadata.get("searchmode") == "raw" - # Or set search mode from the querystring - qs_searchmode = request.args.get("_searchmode") - if qs_searchmode == "escaped": - search_mode_raw = False - if qs_searchmode == "raw": - search_mode_raw = True - - extra_context["supports_search"] = bool(fts_table) - - if fts_table and search_args: - if "_search" in search_args: - # Simple ?_search=xxx - search = search_args["_search"] - where_clauses.append( - "{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})".format( - fts_table=escape_sqlite(fts_table), - fts_pk=escape_sqlite(fts_pk), - match_clause=( - ":search" if search_mode_raw else "escape_fts(:search)" - ), - ) - ) - human_descriptions.append(f'search matches "{search}"') - params["search"] = search - extra_context["search"] = search - else: - # More complex: search against specific columns - for i, (key, search_text) in enumerate(search_args.items()): - search_col = key.split("_search_", 1)[1] - if search_col not in await db.table_columns(fts_table): - raise BadRequest("Cannot search by that column") - - where_clauses.append( - "rowid in (select rowid from {fts_table} where {search_col} match {match_clause})".format( - fts_table=escape_sqlite(fts_table), - search_col=escape_sqlite(search_col), - match_clause=( - ":search_{}".format(i) - if search_mode_raw - else "escape_fts(:search_{})".format(i) - ), - ) - ) - human_descriptions.append( - f'search column "{search_col}" matches "{search_text}"' - ) - params[f"search_{i}"] = search_text - extra_context["search"] = search_text - - return FilterArguments(where_clauses, params, human_descriptions, extra_context) - - return inner - - -@hookimpl(specname="filters_from_request") -def through_filters(request, database, table, datasette): - # ?_search= and _search_colname= - async def inner(): - where_clauses = [] - params = {} - human_descriptions = [] - extra_context = {} - - # Support for ?_through={table, column, value} - if "_through" in request.args: - for through in request.args.getlist("_through"): - through_data = json.loads(through) - through_table = through_data["table"] - other_column = through_data["column"] - value = through_data["value"] - db = datasette.get_database(database) - outgoing_foreign_keys = await db.foreign_keys_for_table(through_table) - try: - fk_to_us = [ - fk for fk in outgoing_foreign_keys if fk["other_table"] == table - ][0] - except IndexError: - raise DatasetteError( - "Invalid _through - could not find corresponding foreign key" - ) - param = f"p{len(params)}" - where_clauses.append( - "{our_pk} in (select {our_column} from {through_table} where {other_column} = :{param})".format( - through_table=escape_sqlite(through_table), - our_pk=escape_sqlite(fk_to_us["other_column"]), - our_column=escape_sqlite(fk_to_us["column"]), - other_column=escape_sqlite(other_column), - param=param, - ) - ) - params[param] = value - human_descriptions.append(f'{through_table}.{other_column} = "{value}"') - - return FilterArguments(where_clauses, params, human_descriptions, extra_context) - - return inner - - -class FilterArguments: - def __init__( - self, where_clauses, params=None, human_descriptions=None, extra_context=None - ): - self.where_clauses = where_clauses - self.params = params or {} - self.human_descriptions = human_descriptions or [] - self.extra_context = extra_context or {} - - -class Filter: - key = None - display = None - no_argument = False - - def where_clause(self, table, column, value, param_counter): - raise NotImplementedError - - def human_clause(self, column, value): - raise NotImplementedError - - -class TemplatedFilter(Filter): - def __init__( - self, - key, - display, - sql_template, - human_template, - format="{}", - numeric=False, - no_argument=False, - ): - self.key = key - self.display = display - self.sql_template = sql_template - self.human_template = human_template - self.format = format - self.numeric = numeric - self.no_argument = no_argument - - def where_clause(self, table, column, value, param_counter): - converted = self.format.format(value) - if self.numeric and converted.isdigit(): - converted = int(converted) - if self.no_argument: - kwargs = {"c": column} - converted = None - else: - kwargs = {"c": column, "p": f"p{param_counter}", "t": table} - return self.sql_template.format(**kwargs), converted - - def human_clause(self, column, value): - if callable(self.human_template): - template = self.human_template(column, value) - else: - template = self.human_template - if self.no_argument: - return template.format(c=column) - else: - return template.format(c=column, v=value) - - -class InFilter(Filter): - key = "in" - display = "in" - - def split_value(self, value): - if value.startswith("["): - return json.loads(value) - else: - return [v.strip() for v in value.split(",")] - - def where_clause(self, table, column, value, param_counter): - values = self.split_value(value) - params = [f":p{param_counter + i}" for i in range(len(values))] - sql = f"{escape_sqlite(column)} in ({', '.join(params)})" - return sql, values - - def human_clause(self, column, value): - return f"{column} in {json.dumps(self.split_value(value))}" - - -class NotInFilter(InFilter): - key = "notin" - display = "not in" - - def where_clause(self, table, column, value, param_counter): - values = self.split_value(value) - params = [f":p{param_counter + i}" for i in range(len(values))] - sql = f"{escape_sqlite(column)} not in ({', '.join(params)})" - return sql, values - - def human_clause(self, column, value): - return f"{column} not in {json.dumps(self.split_value(value))}" - - -class Filters: - _filters = ( - [ - # key, display, sql_template, human_template, format=, numeric=, no_argument= - TemplatedFilter( - "exact", - "=", - '"{c}" = :{p}', - lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"', - ), - TemplatedFilter( - "not", - "!=", - '"{c}" != :{p}', - lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"', - ), - TemplatedFilter( - "contains", - "contains", - '"{c}" like :{p}', - '{c} contains "{v}"', - format="%{}%", - ), - TemplatedFilter( - "notcontains", - "does not contain", - '"{c}" not like :{p}', - '{c} does not contain "{v}"', - format="%{}%", - ), - TemplatedFilter( - "endswith", - "ends with", - '"{c}" like :{p}', - '{c} ends with "{v}"', - format="%{}", - ), - TemplatedFilter( - "startswith", - "starts with", - '"{c}" like :{p}', - '{c} starts with "{v}"', - format="{}%", - ), - TemplatedFilter("gt", ">", '"{c}" > :{p}', "{c} > {v}", numeric=True), - TemplatedFilter( - "gte", "\u2265", '"{c}" >= :{p}', "{c} \u2265 {v}", numeric=True - ), - TemplatedFilter("lt", "<", '"{c}" < :{p}', "{c} < {v}", numeric=True), - TemplatedFilter( - "lte", "\u2264", '"{c}" <= :{p}', "{c} \u2264 {v}", numeric=True - ), - TemplatedFilter("like", "like", '"{c}" like :{p}', '{c} like "{v}"'), - TemplatedFilter( - "notlike", "not like", '"{c}" not like :{p}', '{c} not like "{v}"' - ), - TemplatedFilter("glob", "glob", '"{c}" glob :{p}', '{c} glob "{v}"'), - InFilter(), - NotInFilter(), - ] - + ( - [ - TemplatedFilter( - "arraycontains", - "array contains", - """:{p} in (select value from json_each([{t}].[{c}]))""", - '{c} contains "{v}"', - ), - TemplatedFilter( - "arraynotcontains", - "array does not contain", - """:{p} not in (select value from json_each([{t}].[{c}]))""", - '{c} does not contain "{v}"', - ), - ] - if detect_json1() - else [] - ) - + [ - TemplatedFilter( - "date", "date", 'date("{c}") = :{p}', '"{c}" is on date {v}' - ), - TemplatedFilter( - "isnull", "is null", '"{c}" is null', "{c} is null", no_argument=True - ), - TemplatedFilter( - "notnull", - "is not null", - '"{c}" is not null', - "{c} is not null", - no_argument=True, - ), - TemplatedFilter( - "isblank", - "is blank", - '("{c}" is null or "{c}" = "")', - "{c} is blank", - no_argument=True, - ), - TemplatedFilter( - "notblank", - "is not blank", - '("{c}" is not null and "{c}" != "")', - "{c} is not blank", - no_argument=True, - ), - ] - ) - _filters_by_key = {f.key: f for f in _filters} - - def __init__(self, pairs): - self.pairs = pairs - - def lookups(self): - """Yields (lookup, display, no_argument) pairs""" - for filter in self._filters: - yield filter.key, filter.display, filter.no_argument - - def human_description_en(self, extra=None): - bits = [] - if extra: - bits.extend(extra) - for column, lookup, value in self.selections(): - filter = self._filters_by_key.get(lookup, None) - if filter: - bits.append(filter.human_clause(column, value)) - # Comma separated, with an ' and ' at the end - and_bits = [] - commas, tail = bits[:-1], bits[-1:] - if commas: - and_bits.append(", ".join(commas)) - if tail: - and_bits.append(tail[0]) - s = " and ".join(and_bits) - if not s: - return "" - return f"where {s}" - - def selections(self): - """Yields (column, lookup, value) tuples""" - for key, value in self.pairs: - if "__" in key: - column, lookup = key.rsplit("__", 1) - else: - column = key - lookup = "exact" - yield column, lookup, value - - def has_selections(self): - return bool(self.pairs) - - def build_where_clauses(self, table): - sql_bits = [] - params = {} - i = 0 - for column, lookup, value in self.selections(): - filter = self._filters_by_key.get(lookup, None) - if filter: - sql_bit, param = filter.where_clause(table, column, value, i) - sql_bits.append(sql_bit) - if param is not None: - if not isinstance(param, list): - param = [param] - for individual_param in param: - param_id = f"p{i}" - params[param_id] = individual_param - i += 1 - return sql_bits, params diff --git a/datasette/fixtures.py b/datasette/fixtures.py deleted file mode 100644 index 7c85e16a..00000000 --- a/datasette/fixtures.py +++ /dev/null @@ -1,415 +0,0 @@ -from datasette.utils.sqlite import sqlite3 -from datasette.utils import documented -import itertools -import random -import string - -__all__ = [ - "EXTRA_DATABASE_SQL", - "TABLES", - "TABLE_PARAMETERIZED_SQL", - "generate_compound_rows", - "generate_sortable_rows", - "populate_extra_database", - "populate_fixture_database", - "write_extra_database", - "write_fixture_database", -] - - -def generate_compound_rows(num): - """Generate rows for the compound_three_primary_keys fixture table.""" - for a, b, c in itertools.islice( - itertools.product(string.ascii_lowercase, repeat=3), num - ): - yield a, b, c, f"{a}-{b}-{c}" - - -def generate_sortable_rows(num): - """Generate rows for the sortable fixture table.""" - rand = random.Random(42) - for a, b in itertools.islice( - itertools.product(string.ascii_lowercase, repeat=2), num - ): - yield { - "pk1": a, - "pk2": b, - "content": f"{a}-{b}", - "sortable": rand.randint(-100, 100), - "sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]), - "sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]), - "text": rand.choice(["$null", "$blah"]), - } - - -TABLES = ( - """ -CREATE TABLE simple_primary_key ( - id integer primary key, - content text -); - -CREATE TABLE primary_key_multiple_columns ( - id varchar(30) primary key, - content text, - content2 text -); - -CREATE TABLE primary_key_multiple_columns_explicit_label ( - id varchar(30) primary key, - content text, - content2 text -); - -CREATE TABLE compound_primary_key ( - pk1 varchar(30), - pk2 varchar(30), - content text, - PRIMARY KEY (pk1, pk2) -); - -INSERT INTO compound_primary_key VALUES ('a', 'b', 'c'); -INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c'); -INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO'); - -CREATE TABLE compound_three_primary_keys ( - pk1 varchar(30), - pk2 varchar(30), - pk3 varchar(30), - content text, - PRIMARY KEY (pk1, pk2, pk3) -); -CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content); - -CREATE TABLE foreign_key_references ( - pk varchar(30) primary key, - foreign_key_with_label integer, - foreign_key_with_blank_label integer, - foreign_key_with_no_label varchar(30), - foreign_key_compound_pk1 varchar(30), - foreign_key_compound_pk2 varchar(30), - FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id), - FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id), - FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id) - FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2) -); - -CREATE TABLE sortable ( - pk1 varchar(30), - pk2 varchar(30), - content text, - sortable integer, - sortable_with_nulls real, - sortable_with_nulls_2 real, - text text, - PRIMARY KEY (pk1, pk2) -); - -CREATE TABLE no_primary_key ( - content text, - a text, - b text, - c text -); - -CREATE TABLE [123_starts_with_digits] ( - content text -); - -CREATE VIEW paginated_view AS - SELECT - content, - '- ' || content || ' -' AS content_extra - FROM no_primary_key; - -CREATE TABLE "Table With Space In Name" ( - pk varchar(30) primary key, - content text -); - -CREATE TABLE "table/with/slashes.csv" ( - pk varchar(30) primary key, - content text -); - -CREATE TABLE "complex_foreign_keys" ( - pk varchar(30) primary key, - f1 integer, - f2 integer, - f3 integer, - FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id), - FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id), - FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id) -); - -CREATE TABLE "custom_foreign_key_label" ( - pk varchar(30) primary key, - foreign_key_with_custom_label text, - FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id) -); - -CREATE TABLE tags ( - tag TEXT PRIMARY KEY -); - -CREATE TABLE searchable ( - pk integer primary key, - text1 text, - text2 text, - [name with . and spaces] text -); - -CREATE TABLE searchable_tags ( - searchable_id integer, - tag text, - PRIMARY KEY (searchable_id, tag), - FOREIGN KEY (searchable_id) REFERENCES searchable(pk), - FOREIGN KEY (tag) REFERENCES tags(tag) -); - -INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther'); -INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma'); - -INSERT INTO tags VALUES ("canine"); -INSERT INTO tags VALUES ("feline"); - -INSERT INTO searchable_tags (searchable_id, tag) VALUES - (1, "feline"), - (2, "canine") -; - -CREATE VIRTUAL TABLE "searchable_fts" - USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk"); -INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild'); - -CREATE TABLE [select] ( - [group] text, - [having] text, - [and] text, - [json] text -); -INSERT INTO [select] VALUES ('group', 'having', 'and', - '{"href": "http://example.com/", "label":"Example"}' -); - -CREATE TABLE infinity ( - value REAL -); -INSERT INTO infinity VALUES - (1e999), - (-1e999), - (1.5) -; - -CREATE TABLE facet_cities ( - id integer primary key, - name text -); -INSERT INTO facet_cities (id, name) VALUES - (1, 'San Francisco'), - (2, 'Los Angeles'), - (3, 'Detroit'), - (4, 'Memnonia') -; - -CREATE TABLE facetable ( - pk integer primary key, - created text, - planet_int integer, - on_earth integer, - state text, - _city_id integer, - _neighborhood text, - tags text, - complex_array text, - distinct_some_null, - n text, - FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id) -); -INSERT INTO facetable - (created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n) -VALUES - ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'), - ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'), - ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null), - ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null), - ("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null), - ("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null), - ("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null), - ("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null), - ("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null), - ("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null), - ("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null), - ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null), - ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null), - ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null), - ("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null) -; - -CREATE TABLE binary_data ( - data BLOB -); - --- Many 2 Many demo: roadside attractions! - -CREATE TABLE roadside_attractions ( - pk integer primary key, - name text, - address text, - url text, - latitude real, - longitude real -); -INSERT INTO roadside_attractions VALUES ( - 1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/", - 37.0167, -122.0024 -); -INSERT INTO roadside_attractions VALUES ( - 2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/", - 37.3184, -121.9511 -); -INSERT INTO roadside_attractions VALUES ( - 3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null, - 37.5793, -122.3442 -); -INSERT INTO roadside_attractions VALUES ( - 4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/", - 37.0414, -122.0725 -); - -CREATE TABLE attraction_characteristic ( - pk integer primary key, - name text -); -INSERT INTO attraction_characteristic VALUES ( - 1, "Museum" -); -INSERT INTO attraction_characteristic VALUES ( - 2, "Paranormal" -); - -CREATE TABLE roadside_attraction_characteristics ( - attraction_id INTEGER REFERENCES roadside_attractions(pk), - characteristic_id INTEGER REFERENCES attraction_characteristic(pk) -); -INSERT INTO roadside_attraction_characteristics VALUES ( - 1, 2 -); -INSERT INTO roadside_attraction_characteristics VALUES ( - 2, 2 -); -INSERT INTO roadside_attraction_characteristics VALUES ( - 4, 2 -); -INSERT INTO roadside_attraction_characteristics VALUES ( - 3, 1 -); -INSERT INTO roadside_attraction_characteristics VALUES ( - 4, 1 -); - -INSERT INTO simple_primary_key VALUES (1, 'hello'); -INSERT INTO simple_primary_key VALUES (2, 'world'); -INSERT INTO simple_primary_key VALUES (3, ''); -INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO'); -INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC'); - -INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world'); -INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2'); - -INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b'); -INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null); - -INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1); -INSERT INTO custom_foreign_key_label VALUES (1, 1); - -INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey'); - -CREATE VIEW simple_view AS - SELECT content, upper(content) AS upper_content FROM simple_primary_key; - -CREATE VIEW searchable_view AS - SELECT * from searchable; - -CREATE VIEW searchable_view_configured_by_metadata AS - SELECT * from searchable; - -""" - + "\n".join( - [ - 'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format( - i=i + 1 - ) - for i in range(201) - ] - ) - + '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n' - + "\n".join( - [ - 'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format( - a=a, b=b, c=c, content=content - ) - for a, b, c, content in generate_compound_rows(1001) - ] - ) - + "\n".join(["""INSERT INTO sortable VALUES ( - "{pk1}", "{pk2}", "{content}", {sortable}, - {sortable_with_nulls}, {sortable_with_nulls_2}, "{text}"); - """.format(**row).replace("None", "null") for row in generate_sortable_rows(201)]) -) - -TABLE_PARAMETERIZED_SQL = [ - ("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]), - ("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]), - ("insert into binary_data (data) values (null);", []), -] - -EXTRA_DATABASE_SQL = """ -CREATE TABLE searchable ( - pk integer primary key, - text1 text, - text2 text -); - -CREATE VIEW searchable_view AS SELECT * FROM searchable; - -INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog'); -INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel'); - -CREATE VIRTUAL TABLE "searchable_fts" - USING FTS3 (text1, text2, content="searchable"); -INSERT INTO "searchable_fts" (rowid, text1, text2) - SELECT rowid, text1, text2 FROM searchable; -""" - - -@documented(label="datasette_fixtures_populate_fixture_database") -def populate_fixture_database(conn): - """Populate a SQLite connection with Datasette's test fixture tables.""" - conn.executescript(TABLES) - for sql, params in TABLE_PARAMETERIZED_SQL: - with conn: - conn.execute(sql, params) - - -def populate_extra_database(conn): - """Populate a SQLite connection with the extra database used in tests.""" - conn.executescript(EXTRA_DATABASE_SQL) - - -def write_fixture_database(db_filename): - """Write Datasette's test fixture tables to a SQLite database file.""" - conn = sqlite3.connect(db_filename) - try: - populate_fixture_database(conn) - finally: - conn.close() - - -def write_extra_database(db_filename): - """Write the extra test database tables to a SQLite database file.""" - conn = sqlite3.connect(db_filename) - try: - populate_extra_database(conn) - finally: - conn.close() diff --git a/datasette/forbidden.py b/datasette/forbidden.py deleted file mode 100644 index 41c48396..00000000 --- a/datasette/forbidden.py +++ /dev/null @@ -1,19 +0,0 @@ -from datasette import hookimpl, Response - - -@hookimpl(trylast=True) -def forbidden(datasette, request, message): - async def inner(): - return Response.html( - await datasette.render_template( - "error.html", - { - "title": "Forbidden", - "error": message, - }, - request=request, - ), - status=403, - ) - - return inner diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py deleted file mode 100644 index 96398a4c..00000000 --- a/datasette/handle_exception.py +++ /dev/null @@ -1,77 +0,0 @@ -from datasette import hookimpl, Response -from .utils import add_cors_headers -from .utils.asgi import ( - Base400, -) -from .views.base import DatasetteError -from markupsafe import Markup -import traceback - -try: - import ipdb as pdb -except ImportError: - import pdb - -try: - import rich -except ImportError: - rich = None - - -@hookimpl(trylast=True) -def handle_exception(datasette, request, exception): - async def inner(): - if datasette.pdb: - pdb.post_mortem(exception.__traceback__) - - if rich is not None: - rich.get_console().print_exception(show_locals=True) - - title = None - if isinstance(exception, Base400): - status = exception.status - info = {} - message = exception.args[0] - elif isinstance(exception, DatasetteError): - status = exception.status - info = exception.error_dict - message = exception.message - if exception.message_is_html: - message = Markup(message) - title = exception.title - else: - status = 500 - info = {} - message = str(exception) - traceback.print_exc() - templates = [f"{status}.html", "error.html"] - info.update( - { - "ok": False, - "error": message, - "status": status, - "title": title, - } - ) - headers = {} - if datasette.cors: - add_cors_headers(headers) - if request.path.split("?")[0].endswith(".json"): - return Response.json(info, status=status, headers=headers) - else: - environment = datasette.get_jinja_environment(request) - template = environment.select_template(templates) - return Response.html( - await template.render_async( - dict( - info, - urls=datasette.urls, - app_css_hash=datasette.app_css_hash(), - menu_links=lambda: [], - ) - ), - status=status, - headers=headers, - ) - - return inner diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py deleted file mode 100644 index dcd502af..00000000 --- a/datasette/hookspecs.py +++ /dev/null @@ -1,265 +0,0 @@ -from pluggy import HookimplMarker -from pluggy import HookspecMarker - -hookspec = HookspecMarker("datasette") -hookimpl = HookimplMarker("datasette") - - -@hookspec -def startup(datasette): - """Fires directly after Datasette first starts running""" - - -@hookspec -def asgi_wrapper(datasette): - """Returns an ASGI middleware callable to wrap our ASGI application with""" - - -@hookspec -def prepare_connection(conn, database, datasette): - """Modify SQLite connection in some way e.g. register custom SQL functions""" - - -@hookspec -def prepare_jinja2_environment(env, datasette): - """Modify Jinja2 template environment e.g. register custom template tags""" - - -@hookspec -def extra_css_urls(template, database, table, columns, view_name, request, datasette): - """Extra CSS URLs added by this plugin""" - - -@hookspec -def extra_js_urls(template, database, table, columns, view_name, request, datasette): - """Extra JavaScript URLs added by this plugin""" - - -@hookspec -def extra_body_script( - template, database, table, columns, view_name, request, datasette -): - """Extra JavaScript code to be included in diff --git a/datasette/templates/_codemirror.html b/datasette/templates/_codemirror.html deleted file mode 100644 index c4629aeb..00000000 --- a/datasette/templates/_codemirror.html +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/datasette/templates/_codemirror_foot.html b/datasette/templates/_codemirror_foot.html deleted file mode 100644 index a624c8a4..00000000 --- a/datasette/templates/_codemirror_foot.html +++ /dev/null @@ -1,42 +0,0 @@ - diff --git a/datasette/templates/_crumbs.html b/datasette/templates/_crumbs.html deleted file mode 100644 index bd1ff0da..00000000 --- a/datasette/templates/_crumbs.html +++ /dev/null @@ -1,15 +0,0 @@ -{% macro nav(request, database=None, table=None) -%} -{% if crumb_items is defined %} - {% set items=crumb_items(request=request, database=database, table=table) %} - {% if items %} -

- {% for item in items %} - {{ item.label }} - {% if not loop.last %} - / - {% endif %} - {% endfor %} -

- {% endif %} -{% endif %} -{%- endmacro %} diff --git a/datasette/templates/_debug_common_functions.html b/datasette/templates/_debug_common_functions.html deleted file mode 100644 index d988a2f3..00000000 --- a/datasette/templates/_debug_common_functions.html +++ /dev/null @@ -1,50 +0,0 @@ - diff --git a/datasette/templates/_description_source_license.html b/datasette/templates/_description_source_license.html deleted file mode 100644 index f852268f..00000000 --- a/datasette/templates/_description_source_license.html +++ /dev/null @@ -1,30 +0,0 @@ -{% if metadata.get("description_html") or metadata.get("description") %} - -{% endif %} -{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %} -

- {% if metadata.license or metadata.license_url %}Data license: - {% if metadata.license_url %} - {{ metadata.license or metadata.license_url }} - {% else %} - {{ metadata.license }} - {% endif %} - {% endif %} - {% if metadata.source or metadata.source_url %}{% if metadata.license or metadata.license_url %}·{% endif %} - Data source: {% if metadata.source_url %} - - {% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}{% endif %} - {% endif %} - {% if metadata.about or metadata.about_url %}{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}·{% endif %} - About: {% if metadata.about_url %} - - {% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}{% endif %} - {% endif %} -

-{% endif %} diff --git a/datasette/templates/_execute_write_analysis_scripts.html b/datasette/templates/_execute_write_analysis_scripts.html deleted file mode 100644 index a19bae13..00000000 --- a/datasette/templates/_execute_write_analysis_scripts.html +++ /dev/null @@ -1,111 +0,0 @@ - diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html deleted file mode 100644 index 165cfe9f..00000000 --- a/datasette/templates/_execute_write_analysis_styles.html +++ /dev/null @@ -1,41 +0,0 @@ - diff --git a/datasette/templates/_facet_results.html b/datasette/templates/_facet_results.html deleted file mode 100644 index 570bb37e..00000000 --- a/datasette/templates/_facet_results.html +++ /dev/null @@ -1,28 +0,0 @@ -
- {% for facet_info in sorted_facet_results %} -
-

- {{ facet_info.name }}{% if facet_info.type != "column" %} ({{ facet_info.type }}){% endif %} - {% if facet_info.truncated %}>{% endif %}{{ facet_info.results|length }} - - {% if facet_info.hideable %} - - {% endif %} -

-
    - {% for facet_value in facet_info.results %} - {% if not facet_value.selected %} -
  • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
  • - {% else %} -
  • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
  • - {% endif %} - {% endfor %} - {% if facet_info.truncated %} -
  • {% if request.args._facet_size != "max" -%} - {% else -%}…{% endif %} -
  • - {% endif %} -
-
- {% endfor %} -
diff --git a/datasette/templates/_footer.html b/datasette/templates/_footer.html deleted file mode 100644 index 074270f1..00000000 --- a/datasette/templates/_footer.html +++ /dev/null @@ -1,21 +0,0 @@ -Powered by Datasette -{% if query_ms %}· Queries took {{ query_ms|round(3) }}ms{% endif %} -{% if metadata %} - {% if metadata.license or metadata.license_url %}· Data license: - {% if metadata.license_url %} - {{ metadata.license or metadata.license_url }} - {% else %} - {{ metadata.license }} - {% endif %} - {% endif %} - {% if metadata.source or metadata.source_url %}· - Data source: {% if metadata.source_url %} - - {% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}{% endif %} - {% endif %} - {% if metadata.about or metadata.about_url %}· - About: {% if metadata.about_url %} - - {% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}{% endif %} - {% endif %} -{% endif %} diff --git a/datasette/templates/_permission_ui_styles.html b/datasette/templates/_permission_ui_styles.html deleted file mode 100644 index 53a824f1..00000000 --- a/datasette/templates/_permission_ui_styles.html +++ /dev/null @@ -1,145 +0,0 @@ - diff --git a/datasette/templates/_permissions_debug_tabs.html b/datasette/templates/_permissions_debug_tabs.html deleted file mode 100644 index d7203c1e..00000000 --- a/datasette/templates/_permissions_debug_tabs.html +++ /dev/null @@ -1,54 +0,0 @@ -{% if has_debug_permission %} -{% set query_string = '?' + request.query_string if request.query_string else '' %} - - - - -{% endif %} diff --git a/datasette/templates/_query_form_styles.html b/datasette/templates/_query_form_styles.html deleted file mode 100644 index cf2dd42c..00000000 --- a/datasette/templates/_query_form_styles.html +++ /dev/null @@ -1,138 +0,0 @@ - diff --git a/datasette/templates/_query_results.html b/datasette/templates/_query_results.html deleted file mode 100644 index 5e1e2f72..00000000 --- a/datasette/templates/_query_results.html +++ /dev/null @@ -1,20 +0,0 @@ -{% if display_rows %} -
- - - {% for column in columns %}{% endfor %} - - - - {% for row in display_rows %} - - {% for column, td in zip(columns, row) %} - - {% endfor %} - - {% endfor %} - -
{{ column }}
{{ td }}
-{% elif show_zero_results %} -

0 results

-{% endif %} diff --git a/datasette/templates/_rows_and_columns.html b/datasette/templates/_rows_and_columns.html new file mode 100644 index 00000000..7695be3d --- /dev/null +++ b/datasette/templates/_rows_and_columns.html @@ -0,0 +1,16 @@ + + + + {% for column in display_columns %}{% endfor %} + + + + {% for row in display_rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ cell.value }}
diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html deleted file mode 100644 index 9b83889e..00000000 --- a/datasette/templates/_sql_parameter_scripts.html +++ /dev/null @@ -1,307 +0,0 @@ - diff --git a/datasette/templates/_sql_parameter_styles.html b/datasette/templates/_sql_parameter_styles.html deleted file mode 100644 index bc6838f5..00000000 --- a/datasette/templates/_sql_parameter_styles.html +++ /dev/null @@ -1,58 +0,0 @@ - diff --git a/datasette/templates/_sql_parameters.html b/datasette/templates/_sql_parameters.html deleted file mode 100644 index b5c1bde8..00000000 --- a/datasette/templates/_sql_parameters.html +++ /dev/null @@ -1,10 +0,0 @@ -{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %} -
- {% if parameter_names %} -

Parameters

- {% for parameter in parameter_names %} - {% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %} -

{% if sql_parameters_allow_expand|default(false) %} {% endif %}

- {% endfor %} - {% endif %} -
diff --git a/datasette/templates/_suggested_facets.html b/datasette/templates/_suggested_facets.html deleted file mode 100644 index b80208c3..00000000 --- a/datasette/templates/_suggested_facets.html +++ /dev/null @@ -1,3 +0,0 @@ -

- Suggested facets: {% for facet in suggested_facets %}{{ facet.name }}{% if facet.get("type") %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %} -

diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html deleted file mode 100644 index f47a325f..00000000 --- a/datasette/templates/_table.html +++ /dev/null @@ -1,37 +0,0 @@ - -
-{% if display_columns %} -
- - - - {% for column in display_columns %} - - {% endfor %} - - - - {% for row in display_rows %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - -
- {% if not column.sortable %} - {{ column.name }} - {% else %} - {% if column.name == sort %} - {{ column.name }} ▼ - {% else %} - {{ column.name }}{% if column.name == sort_desc %} ▲{% endif %} - {% endif %} - {% endif %} -
{{ cell.value }}
-
-{% endif %} -{% if not display_rows %} -

0 records

-{% endif %} diff --git a/datasette/templates/allow_debug.html b/datasette/templates/allow_debug.html deleted file mode 100644 index 1ecc92df..00000000 --- a/datasette/templates/allow_debug.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Debug allow rules{% endblock %} - -{% block extra_head %} - -{% endblock %} - -{% block content %} - -

Debug allow rules

- -{% set current_tab = "allow_debug" %} -{% include "_permissions_debug_tabs.html" %} - -

Use this tool to try out different actor and allow combinations. See Defining permissions with "allow" blocks for documentation.

- -
-
-

- -
-
-

- -
-
- -
-
- -{% if error %}

{{ error }}

{% endif %} - -{% if result == "True" %}

Result: allow

{% endif %} - -{% if result == "False" %}

Result: deny

{% endif %} - -{% endblock %} diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html deleted file mode 100644 index 58e2a88c..00000000 --- a/datasette/templates/api_explorer.html +++ /dev/null @@ -1,208 +0,0 @@ -{% extends "base.html" %} - -{% block title %}API Explorer{% endblock %} - -{% block extra_head %} - -{% endblock %} - -{% block content %} - -

API Explorer{% if private %} 🔒{% endif %}

- -

Use this tool to try out the - {% if datasette_version %} - Datasette API. - {% else %} - Datasette API. - {% endif %} -

-
- GET -
-
- - - -
-
-
-
- POST -
-
- - -
-
- - -
-

-
-
- - - - - -{% if example_links %} -

API endpoints

-
    - {% for database in example_links %} -
  • Database: {{ database.name }}
  • -
      - {% for link in database.links %} -
    • {{ link.path }} - {{ link.label }}
    • - {% endfor %} - {% for table in database.tables %} -
    • {{ table.name }} -
        - {% for link in table.links %} -
      • {{ link.path }} - {{ link.label }}
      • - {% endfor %} -
      -
    • - {% endfor %} -
    - {% endfor %} -
-{% endif %} - -{% endblock %} diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 82ab48dd..1976b4ac 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -1,76 +1,34 @@ -{% import "_crumbs.html" as crumbs with context %} - + + {% block title %}{% endblock %} - + -{% for url in extra_css_urls %} - -{% endfor %} - - -{% for url in extra_js_urls %} - -{% endfor %} -{%- if alternate_url_json -%} - -{%- endif -%} -{%- block extra_head %}{% endblock -%} +{% block extra_head %}{% endblock %} - -