From 7b351d53cf74afdd5723af5490a28560edda9a4e Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 30 Jul 2026 17:39:49 -0400 Subject: [PATCH 01/67] fix(mcp): fall back to a temporal dataset column (#42575) --- superset/mcp_service/chart/chart_utils.py | 46 +++++++--- superset/mcp_service/chart/schemas.py | 2 +- .../chart/test_big_number_chart.py | 85 ++++++++++++++++++- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index ea8079ea1e9..d02e9aeb9ce 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -27,6 +27,8 @@ import logging from dataclasses import dataclass from typing import Any, Dict, TYPE_CHECKING +from sqlalchemy.exc import SQLAlchemyError + if TYPE_CHECKING: from superset.connectors.sqla.models import SqlaTable @@ -1067,23 +1069,39 @@ def _resolve_big_number_temporal_column( ) -> str | None: """Resolve the column to bind a Big Number's TEMPORAL_RANGE filter to. - Falls back to the dataset's main_dttm_col when the caller didn't specify - temporal_column, and guards the result with is_column_truly_temporal (same - check map_xy_config applies to its x-axis) so a non-temporal column never - gets a TEMPORAL_RANGE filter. The dataset is fetched at most once here and - reused by is_column_truly_temporal instead of letting it re-query by - dataset_id. + Matches the Explore UI default: use the dataset's main_dttm_col, or its + first temporal column when no main column is configured. Guards candidates + with is_column_truly_temporal (the same check map_xy_config applies to its + x-axis) so a non-temporal column never gets a TEMPORAL_RANGE filter. The + dataset is fetched at most once here and reused by the temporal checks + instead of letting them re-query by dataset_id. """ - dataset = None - if not config.temporal_column: + if config.temporal_column: + if is_column_truly_temporal(config.temporal_column, dataset_id): + return config.temporal_column + return None + + try: dataset = _find_dataset_by_id_or_uuid(dataset_id) - temporal_column = config.temporal_column or ( - dataset.main_dttm_col if dataset else None + except SQLAlchemyError: + logger.warning( + "Unable to resolve a temporal column for dataset %s", + dataset_id, + exc_info=True, + ) + return None + if not dataset: + return None + + candidates: list[str] = [] + if dataset.main_dttm_col: + candidates.append(dataset.main_dttm_col) + candidates.extend( + column.column_name for column in dataset.columns if column.column_name ) - if temporal_column and is_column_truly_temporal( - temporal_column, dataset_id, dataset=dataset - ): - return temporal_column + for temporal_column in dict.fromkeys(candidates): + if is_column_truly_temporal(temporal_column, dataset_id, dataset=dataset): + return temporal_column return None diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index d6fdaab5e5e..f58da946088 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -1392,7 +1392,7 @@ class BigNumberChartConfig(UnknownFieldCheckMixin): "Temporal column for the trendline x-axis. Required when " "show_trendline is True. Also used (whether or not a trendline is " "shown) to bind the chart's dashboard time-range filter; when " - "omitted, the dataset's main temporal column is used instead." + "omitted, the dataset's default temporal column is used instead." ), min_length=1, max_length=255, diff --git a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py index cbf255ac784..88a11b9e0b3 100644 --- a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py +++ b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py @@ -21,6 +21,7 @@ from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError from superset.mcp_service.chart.chart_utils import ( _resolve_viz_type, @@ -388,13 +389,13 @@ class TestMapBigNumberConfig: mock_find_by_id_or_uuid.assert_called_once_with("42") @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") - def test_total_no_dataset_main_dttm_col_skips_temporal_filter( + def test_total_without_temporal_columns_skips_temporal_filter( self, mock_find_by_id_or_uuid: MagicMock ) -> None: - """When the dataset has no temporal column, no TEMPORAL_RANGE filter - can be added — there's nothing for a dashboard filter to bind to.""" + """A dataset without temporal columns has nothing to bind to.""" mock_dataset = MagicMock() mock_dataset.main_dttm_col = None + mock_dataset.columns = [] mock_find_by_id_or_uuid.return_value = mock_dataset config = BigNumberChartConfig( @@ -405,6 +406,84 @@ class TestMapBigNumberConfig: assert "adhoc_filters" not in form_data + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_falls_back_to_first_temporal_column_without_main_dttm_col( + self, mock_find_by_id_or_uuid: MagicMock + ) -> None: + """Match Explore when temporal columns exist but no main one is set.""" + non_temporal_column = MagicMock( + column_name="revenue", + is_dttm=False, + ) + temporal_column = MagicMock( + column_name="order_date", + is_dttm=True, + type=None, + ) + mock_dataset = MagicMock( + main_dttm_col=None, + columns=[non_temporal_column, temporal_column], + ) + mock_find_by_id_or_uuid.return_value = mock_dataset + + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["adhoc_filters"][0]["subject"] == "order_date" + assert form_data["adhoc_filters"][0]["operator"] == "TEMPORAL_RANGE" + mock_find_by_id_or_uuid.assert_called_once_with("42") + + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_accepts_native_temporal_column_without_is_dttm( + self, + mock_find_by_id_or_uuid: MagicMock, + mock_is_temporal: MagicMock, + ) -> None: + """Use backend type classification instead of relying only on is_dttm.""" + mock_dataset = MagicMock( + main_dttm_col=None, + columns=[ + MagicMock(column_name="revenue", is_dttm=False), + MagicMock(column_name="order_date", is_dttm=False), + ], + ) + mock_find_by_id_or_uuid.return_value = mock_dataset + mock_is_temporal.side_effect = lambda column, *_args, **_kwargs: ( + column == "order_date" + ) + + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["adhoc_filters"][0]["subject"] == "order_date" + assert [call.args[0] for call in mock_is_temporal.call_args_list] == [ + "revenue", + "order_date", + ] + + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_ignores_optional_temporal_binding_on_dataset_lookup_failure( + self, mock_find_by_id_or_uuid: MagicMock + ) -> None: + """A metadata failure must not make optional time binding abort mapping.""" + mock_find_by_id_or_uuid.side_effect = SQLAlchemyError("metadata unavailable") + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["viz_type"] == "big_number_total" + assert "adhoc_filters" not in form_data + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") def test_total_non_temporal_column_skips_temporal_filter( self, mock_is_temporal: MagicMock From 7d2b184079811d07fd3f525f1b3592987095474a Mon Sep 17 00:00:00 2001 From: endimonan <65144790+endimonan@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:55:09 +0200 Subject: [PATCH 02/67] fix(explore): show selected current date range (#42616) Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> --- .../components/CurrentCalendarFrame.tsx | 1 + .../tests/CurrentCalendarFrame.test.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx index effc52860f9..25fe2fcc723 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx @@ -49,6 +49,7 @@ export function CurrentCalendarFrame({ onChange, value }: FrameComponentProps) { wrap: true, }} size="large" + value={value} onChange={(e: any) => { let newValue = e.target.value; newValue = newValue.trim(); diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx index 7038879269b..df6c140a105 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { render } from 'spec/helpers/testing-library'; +import { render, screen } from 'spec/helpers/testing-library'; import { CurrentCalendarFrame } from '../components/CurrentCalendarFrame'; -import { CurrentWeek } from '../types'; +import { CurrentDay, CurrentWeek } from '../types'; const mockOnChange = jest.fn(); @@ -33,3 +33,9 @@ test('returns null if value is not a valid CurrentRangeType', () => { ); expect(container.childNodes.length).toBe(0); }); + +test('selects the radio button matching the current value', () => { + render(); + + expect(screen.getByRole('radio', { name: CurrentDay })).toBeChecked(); +}); From 1df022b3641766baa778094aaed3f1c249e07f74 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 09:14:30 -0700 Subject: [PATCH 03/67] ci: mirror CI service images to GHCR (fork-safe Docker Hub pulls, groundwork) (#40880) Co-authored-by: Claude Opus 4.8 --- .github/workflows/mirror-service-images.yml | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/mirror-service-images.yml diff --git a/.github/workflows/mirror-service-images.yml b/.github/workflows/mirror-service-images.yml new file mode 100644 index 00000000000..e798847cb23 --- /dev/null +++ b/.github/workflows/mirror-service-images.yml @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Mirror the Docker Hub service-container images that CI relies on into the +# repository's GitHub Container Registry (GHCR) namespace. +# +# WHY: CI jobs declare Postgres/MySQL/Redis/Presto as `services:` containers +# pulled anonymously from Docker Hub. Anonymous pulls share the runner's IP +# rate limit, which causes intermittent timeouts / 429s / 502s on `master` +# and same-repo PRs. The obvious fix — adding `credentials:` to the service +# blocks — breaks fork PRs hard: forks can't read secrets, so the templated +# username/password resolve to '' and GitHub rejects the workflow at parse +# time ("Unexpected value ''"), failing every fork job at "Set up job". +# +# Mirroring to GHCR sidesteps both problems: public GHCR images are pulled +# without Docker Hub's anonymous rate limit AND without any credentials, so +# the consuming workflows need no `credentials:` block and forks work +# unchanged. +# +# ONE-TIME BOOTSTRAP (maintainer, after this lands on the default branch): +# 1. Run this workflow once (Actions tab → "Mirror service images to GHCR" +# → Run workflow), or wait for the weekly schedule. +# 2. In the org's Packages settings, set each mirrored package's visibility +# to **public** (apache/superset → ci/postgres, ci/mysql, ci/redis, +# ci/presto). Public visibility is what lets fork CI pull without auth. +# 3. Only then merge the follow-up that repoints the `services.*.image` +# refs at these GHCR copies and drops the `credentials:` blocks. +# +# NOTE: this mirrors only the images declared as `services:` containers (the +# ones that broke forks). The `bde2020` hive-metastore image pulled via +# `docker compose` in the Presto/Hive job is a separate path and is left for +# a follow-up. + +name: Mirror service images to GHCR + +on: + schedule: + # Weekly, Monday 06:00 UTC — keeps the mirror fresh as upstream tags move. + - cron: "0 6 * * 1" + workflow_dispatch: {} + +concurrency: + group: mirror-service-images + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + mirror: + # Never run on forks: they lack both the secrets and write access to the + # apache GHCR namespace, so a scheduled run there would only ever fail. + if: github.repository == 'apache/superset' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Keep this list in sync with the `services.*.image` refs in + # superset-e2e.yml, superset-python-integrationtest.yml, and + # superset-python-presto-hive.yml. + image: + - postgres:17-alpine + - redis:7-alpine + - mysql:8.0 + - starburstdata/presto:350-e.6 + steps: + - name: Log in to Docker Hub (authenticated source pulls) + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR (push target) + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Copy image to GHCR + env: + # Pass the matrix value through the environment rather than + # interpolating it into the shell, to avoid template injection. + SRC_IMAGE: ${{ matrix.image }} + run: | + set -euo pipefail + # Destination keeps the image's short name (drop any namespace), + # under a `ci/` prefix in this repo's GHCR namespace. + name="${SRC_IMAGE##*/}" + dst="ghcr.io/${GITHUB_REPOSITORY}/ci/${name}" + echo "Mirroring docker.io/${SRC_IMAGE} -> ${dst}" + # imagetools copies the full (multi-arch) manifest registry-to- + # registry without a local pull/retag/push round trip. + docker buildx imagetools create --tag "${dst}" "docker.io/${SRC_IMAGE}" + echo "- \`docker.io/${SRC_IMAGE}\` → \`${dst}\`" >> "${GITHUB_STEP_SUMMARY}" From 3692ac587017eb032851276dc1e8b5997badf8b2 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 09:15:11 -0700 Subject: [PATCH 04/67] ci: de-vendor helm/chart-releaser-action (#42506) Co-authored-by: Claude Code --- .github/actions/chart-releaser-action | 1 - .github/workflows/superset-helm-lint-test.yml | 5 +++++ .github/workflows/superset-helm-release.yml | 7 +------ .gitmodules | 3 --- 4 files changed, 6 insertions(+), 10 deletions(-) delete mode 160000 .github/actions/chart-releaser-action diff --git a/.github/actions/chart-releaser-action b/.github/actions/chart-releaser-action deleted file mode 160000 index a917fd15b20..00000000000 --- a/.github/actions/chart-releaser-action +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a917fd15b20e8b64b94d9158ad54cd6345335584 diff --git a/.github/workflows/superset-helm-lint-test.yml b/.github/workflows/superset-helm-lint-test.yml index db59101bfa9..18d18ff4ef2 100644 --- a/.github/workflows/superset-helm-lint-test.yml +++ b/.github/workflows/superset-helm-lint-test.yml @@ -38,6 +38,11 @@ jobs: with: install-superset: "false" + # Still vendored (not de-vendored like chart-releaser-action below): the + # allowlisted helm/chart-testing-action@v2.8.0 depends internally on + # astral-sh/setup-uv@v7.0.0, which isn't itself on the ASF Actions + # allowlist (only v8.1.0+ are, at apache/infrastructure-actions' + # actions.yml). Needs an INFRA request before this can de-vendor too. - name: Set up chart-testing uses: ./.github/actions/chart-testing-action diff --git a/.github/workflows/superset-helm-release.yml b/.github/workflows/superset-helm-release.yml index ae6511d49da..53483c908c6 100644 --- a/.github/workflows/superset-helm-release.yml +++ b/.github/workflows/superset-helm-release.yml @@ -95,13 +95,8 @@ jobs: # Return to the original branch git checkout local_gha_temp - - name: Fetch/list all tags - run: | - git submodule update - cat .github/actions/chart-releaser-action/action.yml - - name: Run chart-releaser - uses: ./.github/actions/chart-releaser-action + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 with: version: v1.6.0 charts_dir: helm diff --git a/.gitmodules b/.gitmodules index 54a0fb6b8f4..39e51236da9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,9 +30,6 @@ [submodule ".github/actions/chart-testing-action"] path = .github/actions/chart-testing-action url = https://github.com/helm/chart-testing-action -[submodule ".github/actions/chart-releaser-action"] - path = .github/actions/chart-releaser-action - url = https://github.com/helm/chart-releaser-action [submodule ".github/actions/github-action-push-to-another-repository"] path = .github/actions/github-action-push-to-another-repository url = https://github.com/cpina/github-action-push-to-another-repository From 837ae95b7b59428fedfe85a4268caffef0a93fe2 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 09:15:26 -0700 Subject: [PATCH 05/67] feat(ci): auto-approve Dependabot patch-level bumps (#42508) Co-authored-by: Superset Dev Co-authored-by: Claude Fable 5 --- .github/workflows/dependabot-auto-approve.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/dependabot-auto-approve.yml diff --git a/.github/workflows/dependabot-auto-approve.yml b/.github/workflows/dependabot-auto-approve.yml new file mode 100644 index 00000000000..721cf6321d8 --- /dev/null +++ b/.github/workflows/dependabot-auto-approve.yml @@ -0,0 +1,63 @@ +name: Auto-approve Dependabot patch bumps + +# Posts an approving review on Dependabot PRs that only bump a patch +# version, using the same trigger/guard convention already proven to work +# for Dependabot PRs in sync-requirements-for-python-dep-upgrade-pr.yml +# (plain `pull_request` gets a working, write-capable GITHUB_TOKEN here +# because Dependabot pushes branches directly to this repo, not a fork). +# +# This does NOT auto-merge anything - repo-wide auto-merge is disabled +# (Settings > General > Pull Requests > "Allow auto-merge" is off), and +# flipping that is a separate, repo-wide decision this workflow doesn't +# make on its own. Branch protection also still requires 1 approving +# review; this just means that review can already exist by the time a +# human looks at the PR, for the (large majority of) ecosystems whose +# files aren't matched by any CODEOWNERS pattern. One ecosystem - the npm +# bump under .github/actions - matches the /.github/ CODEOWNERS entry, so +# those PRs will still need a human owner's approval regardless of this +# workflow; it posts a review there too, but that alone won't satisfy the +# code-owner requirement. +on: + pull_request: + types: [opened, synchronize] + +# Cancel a superseded run if Dependabot pushes to the same PR again before +# the previous run finished (matches the pattern used elsewhere in +# superset-docs-verify.yml). +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + approve-patch-bump: + name: Approve patch-level bump + # Mirrors the guard in sync-requirements-for-python-dep-upgrade-pr.yml: + # limited to (1) PRs authored by Dependabot and (2) the upstream repo, + # since forked PRs don't get a write-capable token here anyway. + if: > + github.repository == 'apache/superset' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + pull-requests: write # to post the approving review via `gh pr review` + steps: + - name: Fetch Dependabot metadata + id: metadata + # This exact SHA is on ASF Infra's action allowlist + # (apache/infrastructure-actions approved_patterns.yml) as of this + # writing. Do not bump without opening an Infra ticket to allow + # the new SHA first! + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + + - name: Approve patch-level bump + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }} + run: | + gh pr review --approve "$PR_URL" \ + --body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)." From 67d05d0ed7a9bdbae41221dd082632deac070fcf Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 09:15:42 -0700 Subject: [PATCH 06/67] docs(installation): document how to add translations to a custom Docker image (#42586) Co-authored-by: Claude Opus 4.8 --- .../admin_docs/installation/docker-builds.mdx | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/admin_docs/installation/docker-builds.mdx b/docs/admin_docs/installation/docker-builds.mdx index ffd523b906d..da7965291f4 100644 --- a/docs/admin_docs/installation/docker-builds.mdx +++ b/docs/admin_docs/installation/docker-builds.mdx @@ -112,12 +112,61 @@ USER superset CMD ["/app/docker/entrypoints/run-server.sh"] ``` +### Adding translations to a custom image + +The pattern above, a small Dockerfile that just extends `FROM apache/superset:...`, can't add +translations after the fact. By the time an official tag is published, its frontend and backend +layers have already had non-English translation files stripped out unless `BUILD_TRANSLATIONS` +was set at build time (see below), and there's no `superset/translations` source tree left in the +final image to compile from. + +To get translations into your own image, you need to build from the full Superset source (a +clone or fork of this repo) rather than extend a published tag. The most efficient way to do this +is to append your customizations as one more stage at the end of the repo's own `Dockerfile`, so +Docker can reuse the cached upstream layers and only rebuild what your stage adds: + +```Dockerfile +# Append this to the end of the repo's Dockerfile +# Keep this tag in sync with the branch/tag of the repo you cloned, so the +# translation files built from source match the keys the runtime expects: +FROM apache/superset:5.0.0 AS my-custom-image +USER root + +# Pull the translation files out of the earlier build stages (frontend +# .json in `superset-node`, backend .mo in `python-translation-compiler`). +# Those stages' own cleanup only matches single-character extensions, so +# the source `.po` files can still be present here; strip them explicitly +# so this stage only keeps the compiled translations. +COPY --from=superset-node /app/superset/translations superset/translations +COPY --from=python-translation-compiler /app/translations_mo superset/translations +RUN find superset/translations -name '*.po' -delete + +USER superset +``` + +Then build with: + +```bash +docker build --target=my-custom-image --build-arg=BUILD_TRANSLATIONS=true -t mysuperset:5.0.0 . +``` + +You can combine this with the database-driver/dependency pattern above by adding your own +`RUN uv pip install ...` step before switching back to `USER superset`. See +[issue #35959](https://github.com/apache/superset/issues/35959) for the discussion this pattern +came out of, credit to the community for working it out. + ## Key ARGs in Dockerfile -- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the - frontend build this tells webpack to strip out all locales other than `en` from - the `moment-timezone` library. For the backendthis skips compiling the - `*.po` translation files +- `BUILD_TRANSLATIONS`: whether to compile non-English translations into the image. + When `true`, the frontend build converts the `*.po` files to locale JSON and the + backend runs `pybabel compile` to produce `*.mo` files; both source `*.po` files + are stripped afterward either way. When `false` (the default), those compile + steps are skipped and only `en` ships. This only takes effect when building the image from source + (`docker build` against this repo's own `Dockerfile`); it has no effect on a downstream + Dockerfile that just extends an already-published tag, see + "Adding translations to a custom image" above. Note that the backend `pybabel compile` + step ignores its exit code, so a `.po` file with a compile error won't fail the build; + check the build logs for `pybabel` warnings if a locale's backend strings aren't showing up. - `DEV_MODE`: whether to skip the frontend build, this is used by our `docker-compose` dev setup where we mount the local volume and build using `webpack` in `--watch` mode, meaning as you alter the code in the local file system, webpack, from within a docker image used for this From 8235d0c4fb114487d820b1a504e1791cd11ed707 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 09:15:59 -0700 Subject: [PATCH 07/67] fix(alerts-reports): skip inactive users when resolving report executor (#42612) --- superset/tasks/utils.py | 30 +++++-- tests/unit_tests/tasks/test_utils.py | 120 +++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/superset/tasks/utils.py b/superset/tasks/utils.py index 607e482d74a..6ffefed7933 100644 --- a/superset/tasks/utils.py +++ b/superset/tasks/utils.py @@ -155,25 +155,39 @@ def get_executor( # noqa: C901 if executor == ExecutorType.CURRENT_USER and current_user: return executor, current_user if executor == ExecutorType.CREATOR_EDITOR: - if (user := model.created_by) and _is_editor(user.id): + if (user := model.created_by) and user.is_active and _is_editor(user.id): return executor, user.username if executor == ExecutorType.CREATOR: - if user := model.created_by: + if (user := model.created_by) and user.is_active: return executor, user.username if executor == ExecutorType.MODIFIER_EDITOR: - if (user := model.changed_by) and _is_editor(user.id): + if (user := model.changed_by) and user.is_active and _is_editor(user.id): return executor, user.username if executor == ExecutorType.MODIFIER: - if user := model.changed_by: + if (user := model.changed_by) and user.is_active: return executor, user.username if executor == ExecutorType.EDITOR: # Priority: modifier → creator → direct user editor → indirect editor. - if (modifier := model.changed_by) and _is_editor(modifier.id): + # Inactive users are skipped at every step so that scheduling can + # fall through to another active owner/editor instead of failing + # outright (see: ExecutorNotFoundError only once no active + # candidate remains). + if ( + (modifier := model.changed_by) + and modifier.is_active + and _is_editor(modifier.id) + ): return executor, modifier.username - if (creator := model.created_by) and _is_editor(creator.id): + if ( + (creator := model.created_by) + and creator.is_active + and _is_editor(creator.id) + ): return executor, creator.username - if editor_users: - return executor, editor_users[0].username + if active_editor_user := next( + (user for user in editor_users if user.is_active), None + ): + return executor, active_editor_user.username if indirect_editor := _get_indirect_editor_user( getattr(model, "editors", []) ): diff --git a/tests/unit_tests/tasks/test_utils.py b/tests/unit_tests/tasks/test_utils.py index 1abab022293..2c423e4f3fd 100644 --- a/tests/unit_tests/tasks/test_utils.py +++ b/tests/unit_tests/tasks/test_utils.py @@ -42,11 +42,11 @@ FIXED_USER_ID = 1234 FIXED_USERNAME = "admin" -def _make_user_subject(user_id: int) -> MagicMock: +def _make_user_subject(user_id: int, active: bool = True) -> MagicMock: """Create a mock user-type Subject with an underlying User.""" from superset.subjects.types import SubjectType - user = User(id=user_id, username=str(user_id)) + user = User(id=user_id, username=str(user_id), active=active) subject = MagicMock() subject.id = user_id # deterministic subject ID subject.type = SubjectType.USER @@ -83,12 +83,13 @@ def _make_group_subject(group_id: int) -> MagicMock: def _get_users( params: Optional[Union[int, list[int]]], + active: bool = True, ) -> Optional[Union[User, list[User]]]: if params is None: return None if isinstance(params, int): - return User(id=params, username=str(params)) - return [User(id=user, username=str(user)) for user in params] + return User(id=params, username=str(params), active=active) + return [User(id=user, username=str(user), active=active) for user in params] @dataclass @@ -98,10 +99,14 @@ class EditorSpec: user_ids: list[int] role_ids: list[int] | None = None group_ids: list[int] | None = None + # Direct user-type editors that should be built as inactive users + inactive_user_ids: list[int] | None = None def build(self) -> list[MagicMock]: editors: list[MagicMock] = [] editors.extend(_make_user_subject(uid) for uid in self.user_ids) + for uid in self.inactive_user_ids or []: + editors.append(_make_user_subject(uid, active=False)) for rid in self.role_ids or []: editors.append(_make_role_subject(rid)) for gid in self.group_ids or []: @@ -114,6 +119,8 @@ class ModelConfig: editors: EditorSpec creator: Optional[int] = None modifier: Optional[int] = None + creator_active: bool = True + modifier_active: bool = True # Maps user_id → role_ids the user belongs to (for indirect editor resolution) user_roles: dict[int, list[int]] = field(default_factory=dict) # Maps user_id → group_ids the user belongs to (for indirect editor resolution) @@ -530,6 +537,105 @@ class ModelType(int, Enum): None, ExecutorNotFoundError(), ), + # CREATOR: an inactive creator is skipped (no other executor configured) + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR], + ModelConfig( + editors=EditorSpec(user_ids=[]), + creator=3, + creator_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # CREATOR: an inactive creator is skipped, falling through to an active + # MODIFIER later in the executor chain. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR, ExecutorType.MODIFIER], + ModelConfig( + editors=EditorSpec(user_ids=[]), + creator=3, + creator_active=False, + modifier=4, + ), + None, + (ExecutorType.MODIFIER, 4), + ), + # CREATOR_EDITOR: creator is an editor but inactive → not resolved + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR_EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[4]), + creator=4, + creator_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # MODIFIER_EDITOR: modifier is an editor but inactive → not resolved + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.MODIFIER_EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[4]), + modifier=4, + modifier_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # EDITOR: creator (the only editor) is inactive, so scheduling falls + # through to the still-active direct-user editor instead of failing. + # This is the scenario from #33584: the original report creator has + # gone inactive but a currently-active owner/editor should still be + # usable as the executor. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[2], inactive_user_ids=[3]), + creator=3, + creator_active=False, + ), + None, + (ExecutorType.EDITOR, 2), + ), + # EDITOR: both modifier and creator are inactive editors, and the only + # direct-user editor is also inactive → falls through to the indirect + # (role/group) editor resolution. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec( + user_ids=[], inactive_user_ids=[3, 4], role_ids=[10] + ), + creator=3, + creator_active=False, + modifier=4, + modifier_active=False, + user_roles={6: [10]}, + ), + None, + (ExecutorType.EDITOR, 6), + ), + # EDITOR: modifier is an inactive editor, creator is an active editor → + # resolves to the creator instead of failing outright. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[3], inactive_user_ids=[4]), + creator=3, + modifier=4, + modifier_active=False, + ), + None, + (ExecutorType.EDITOR, 3), + ), ], ) def test_get_executor( @@ -561,8 +667,10 @@ def test_get_executor( obj = model( id=1, - created_by=_get_users(model_config.creator), - changed_by=_get_users(model_config.modifier), + created_by=_get_users(model_config.creator, active=model_config.creator_active), + changed_by=_get_users( + model_config.modifier, active=model_config.modifier_active + ), **model_kwargs, ) obj.editors = model_config.editors.build() From 06effe2961e92f8533f52d1da0fb05c97cb141e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:19:50 +0700 Subject: [PATCH 08/67] chore(deps): bump fs-extra from 11.3.2 to 11.4.0 in /superset-frontend (#42635) --- superset-frontend/package-lock.json | 10 +++++----- superset-frontend/package.json | 2 +- .../packages/generator-superset/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2b71c954a42..9dbe4e23b60 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -93,7 +93,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^6.1.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "fuse.js": "^7.5.0", "geolib": "^3.3.14", "geostyler": "^18.6.0", @@ -21389,9 +21389,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -43562,7 +43562,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "jest": "^30.4.2", "yeoman-test": "^11.6.0" }, diff --git a/superset-frontend/package.json b/superset-frontend/package.json index d8d868c9aba..c6b5dd97661 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -178,7 +178,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^6.1.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "fuse.js": "^7.5.0", "geolib": "^3.3.14", "geostyler": "^18.6.0", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index 716b9a139ae..5180eccee7e 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -35,7 +35,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "jest": "^30.4.2", "yeoman-test": "^11.6.0" }, From 96a12e04420335241c9da243aae3a99079bf65f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:20:44 +0700 Subject: [PATCH 09/67] chore(deps-dev): bump webpack from 5.108.4 to 5.109.0 in /superset-frontend (#42634) --- superset-frontend/package-lock.json | 53 +++++++---------------------- superset-frontend/package.json | 2 +- 2 files changed, 13 insertions(+), 42 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9dbe4e23b60..d9477b99d81 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -282,7 +282,7 @@ "typescript": "5.4.5", "unzipper": "^0.12.5", "wait-on": "^9.1.0", - "webpack": "^5.108.4", + "webpack": "^5.109.0", "webpack-bundle-analyzer": "^5.3.1", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", @@ -19070,9 +19070,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", - "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -28315,20 +28315,6 @@ "node": ">=8" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -41752,9 +41738,9 @@ } }, "node_modules/webpack": { - "version": "5.108.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", - "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "version": "5.109.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.0.tgz", + "integrity": "sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg==", "dev": true, "license": "MIT", "dependencies": { @@ -41764,22 +41750,20 @@ "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.2", + "enhanced-resolve": "^5.24.2", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "watchpack": "^2.5.2", - "webpack-sources": "^3.5.0" + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -42313,9 +42297,9 @@ } }, "node_modules/webpack/node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -42325,19 +42309,6 @@ "node": ">=0.4.0" } }, - "node_modules/webpack/node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/webpack/node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index c6b5dd97661..d6fd1a06404 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -367,7 +367,7 @@ "typescript": "5.4.5", "unzipper": "^0.12.5", "wait-on": "^9.1.0", - "webpack": "^5.108.4", + "webpack": "^5.109.0", "webpack-bundle-analyzer": "^5.3.1", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", From 05f45863cbf24a3e8fac866d6a6a008e14d83b31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:36:21 +0700 Subject: [PATCH 10/67] chore(deps-dev): bump webpack from 5.108.4 to 5.109.0 in /docs (#42631) --- docs/package.json | 2 +- docs/yarn.lock | 40 ++++++++++++++-------------------------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/docs/package.json b/docs/package.json index a46d19d7557..1b3ce1feff2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -99,7 +99,7 @@ "prettier": "^3.9.6", "typescript": "~6.0.3", "typescript-eslint": "^8.65.0", - "webpack": "^5.108.2" + "webpack": "^5.109.0" }, "browserslist": { "production": [ diff --git a/docs/yarn.lock b/docs/yarn.lock index 2e9e644e498..89e2bf06c0a 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5835,11 +5835,6 @@ acorn-dynamic-import@^4.0.0: resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== -acorn-import-phases@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" - integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== - acorn-jsx@^5.0.0, acorn-jsx@^5.0.1, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -8066,10 +8061,10 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -enhanced-resolve@^5.22.2: - version "5.24.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz#b2439adf5d31d7e4764de1f9ecf942d6cd3fc874" - integrity sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw== +enhanced-resolve@^5.24.2: + version "5.24.5" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz#b4dad3255b7545f07ba5535189868e9f85f47573" + integrity sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A== dependencies: graceful-fs "^4.2.4" tapable "^2.3.3" @@ -10431,11 +10426,6 @@ liquid-json@0.3.1: resolved "https://registry.yarnpkg.com/liquid-json/-/liquid-json-0.3.1.tgz#9155a18136d8a6b2615e5f16f9a2448ab6b50eea" integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ== -loader-runner@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9" - integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w== - loader-utils@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" @@ -15855,20 +15845,20 @@ webpack-merge@^6.0.1: flat "^5.0.2" wildcard "^2.0.1" -webpack-sources@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08" - integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ== +webpack-sources@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.1.tgz#76c2418486dcc02b2aa0694c104176c2858fe84a" + integrity sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw== webpack-virtual-modules@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== -webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0: - version "5.108.4" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.4.tgz#141818a411662773a0bb32dc5536acc5409943b7" - integrity sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w== +webpack@^5.109.0, webpack@^5.88.1, webpack@^5.95.0: + version "5.109.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.109.0.tgz#871d8eee5e2d5e6eaf5ec8d1a6db74ea65491030" + integrity sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg== dependencies: "@types/estree" "^1.0.8" "@types/json-schema" "^7.0.15" @@ -15876,22 +15866,20 @@ webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0: "@webassemblyjs/wasm-edit" "^1.14.1" "@webassemblyjs/wasm-parser" "^1.14.1" acorn "^8.16.0" - acorn-import-phases "^1.0.3" browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.22.2" + enhanced-resolve "^5.24.2" es-module-lexer "^2.1.0" eslint-scope "5.1.1" events "^3.2.0" graceful-fs "^4.2.11" - loader-runner "^4.3.2" mime-db "^1.54.0" minimizer-webpack-plugin "^5.6.1" neo-async "^2.6.2" schema-utils "^4.3.3" tapable "^2.3.0" watchpack "^2.5.2" - webpack-sources "^3.5.0" + webpack-sources "^3.5.1" webpackbar@^7.0.0: version "7.0.0" From 1a7f2afae9fe8bd7cd93a0106061a01133a0c498 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:36:46 +0700 Subject: [PATCH 11/67] chore(deps): bump antd from 6.5.1 to 6.5.2 in /docs (#42630) --- docs/package.json | 2 +- docs/yarn.lock | 31 ++++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/package.json b/docs/package.json index 1b3ce1feff2..7281412d073 100644 --- a/docs/package.json +++ b/docs/package.json @@ -61,7 +61,7 @@ "@storybook/addon-docs": "^10.5.3", "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.46", - "antd": "^6.5.1", + "antd": "^6.5.2", "baseline-browser-mapping": "^2.11.1", "caniuse-lite": "^1.0.30001806", "docusaurus-plugin-openapi-docs": "^5.1.2", diff --git a/docs/yarn.lock b/docs/yarn.lock index 89e2bf06c0a..a87bc04f8f8 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3779,7 +3779,7 @@ "@rc-component/virtual-list" "^1.2.0" clsx "^2.1.1" -"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1": +"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86" integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw== @@ -3798,13 +3798,13 @@ "@rc-component/util" "^1.11.1" clsx "^2.1.1" -"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.11.1.tgz#07d698908339c55648e4f974afa739345e65b483" - integrity sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg== +"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0": + version "1.12.0" + resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f" + integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ== dependencies: is-mobile "^5.0.0" - react-is "^18.2.0" + react-is "^19.2.7" "@rc-component/virtual-list@^1.0.1", "@rc-component/virtual-list@^1.2.0": version "1.2.0" @@ -6023,10 +6023,10 @@ ansis@^3.2.0: resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7" integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg== -antd@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.1.tgz#2623db1f3b0ae32a2e311ef20f2f1413934d0094" - integrity sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg== +antd@^6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.2.tgz#d771211beddf539f37303f862df24e8aaf32b7c4" + integrity sha512-ntYx0lr4Jq192QnBkDWkDqEeoberXZ34vSE9SgiP/0J6DY8O0pzR3bVZLBsdpCSguVkwjtEAP+QNeMN7LNAvgw== dependencies: "@ant-design/colors" "^8.0.1" "@ant-design/cssinjs" "^2.1.2" @@ -6068,9 +6068,9 @@ antd@^6.5.1: "@rc-component/tour" "~2.4.0" "@rc-component/tree" "~1.3.2" "@rc-component/tree-select" "~1.11.0" - "@rc-component/trigger" "^3.10.0" + "@rc-component/trigger" "^3.10.1" "@rc-component/upload" "~1.1.1" - "@rc-component/util" "^1.11.1" + "@rc-component/util" "^1.12.0" clsx "^2.1.1" dayjs "^1.11.11" scroll-into-view-if-needed "^3.1.0" @@ -13317,11 +13317,16 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0, react-is@^18.2.0: +react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-is@^19.2.7: + version "19.2.8" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018" + integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ== + react-json-view-lite@^2.3.0: version "2.5.0" resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz#c7ff011c7cc80e9900abc7aa4916c6a5c6d6c1c6" From 8e08a65abb424cd0637f5158d64da1e4a69fa691 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:37:20 +0700 Subject: [PATCH 12/67] chore(deps): bump google-auth-library from 10.9.0 to 10.9.1 in /superset-frontend (#42633) --- superset-frontend/package-lock.json | 8 ++++---- superset-frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d9477b99d81..49181447c37 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -101,7 +101,7 @@ "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.9.0", + "google-auth-library": "^10.9.1", "immer": "^11.1.15", "interweave": "^13.1.1", "jquery": "^4.0.0", @@ -22718,9 +22718,9 @@ "license": "MIT" }, "node_modules/google-auth-library": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", - "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index d6fd1a06404..faf81dc3ae9 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -186,7 +186,7 @@ "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.9.0", + "google-auth-library": "^10.9.1", "immer": "^11.1.15", "interweave": "^13.1.1", "jquery": "^4.0.0", From c0f9dec96359d9d7454a22b837dbc269c9853cfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:41:06 -0700 Subject: [PATCH 13/67] chore(deps): bump docker/login-action from 3.7.0 to 4.5.1 (#42632) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-python-deps.yml | 2 +- .github/workflows/mirror-service-images.yml | 4 ++-- .../workflows/sync-requirements-for-python-dep-upgrade-pr.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-python-deps.yml b/.github/workflows/check-python-deps.yml index da1a79c6950..54e25176e4d 100644 --- a/.github/workflows/check-python-deps.yml +++ b/.github/workflows/check-python-deps.yml @@ -46,7 +46,7 @@ jobs: - name: Login to Docker Hub if: steps.check.outputs.python continue-on-error: true - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/mirror-service-images.yml b/.github/workflows/mirror-service-images.yml index e798847cb23..16e41455037 100644 --- a/.github/workflows/mirror-service-images.yml +++ b/.github/workflows/mirror-service-images.yml @@ -80,13 +80,13 @@ jobs: - starburstdata/presto:350-e.6 steps: - name: Log in to Docker Hub (authenticated source pulls) - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR (push target) - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml b/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml index 98c9d36d910..18be0b49f4c 100644 --- a/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml +++ b/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml @@ -38,7 +38,7 @@ jobs: - name: Login to Docker Hub if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }} continue-on-error: true - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} From 268662fa4927e840f066f78db09dc1fcef5211d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:41:41 -0700 Subject: [PATCH 14/67] chore(deps): bump mapbox-gl from 3.26.0 to 3.27.0 in /superset-frontend (#42636) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 10 +++++----- superset-frontend/package.json | 2 +- .../plugin-chart-point-cluster-map/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 49181447c37..18f6768aa4e 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -110,7 +110,7 @@ "json-stringify-pretty-compact": "^4.0.0", "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "markdown-to-jsx": "^9.9.0", "match-sorter": "^8.3.0", "memoize-one": "^6.0.0", @@ -28636,9 +28636,9 @@ } }, "node_modules/mapbox-gl": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.26.0.tgz", - "integrity": "sha512-N+Y8VmvpD6xDeP0hAbegPZeEMY5TtnLcV0gEUBZ/KHq3s/yUKQm0PVG9JEkId0QFMMe5/W+FEjoyV4NI3B/N7A==", + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.27.0.tgz", + "integrity": "sha512-K8W9LTTjFEJsg9qsnJbKk+zbXrmSqa+nU1EiFXez5gQ0T0RMtylZUelgg1/RE6vCUMvHX0gaYfWU9g2mTWuA0g==", "license": "SEE LICENSE IN LICENSE.txt", "workspaces": [ "src/style-spec", @@ -44247,7 +44247,7 @@ "license": "Apache-2.0", "dependencies": { "@math.gl/web-mercator": "^4.1.0", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.1", "supercluster": "^8.0.1" diff --git a/superset-frontend/package.json b/superset-frontend/package.json index faf81dc3ae9..ba97df83196 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -195,7 +195,7 @@ "json-stringify-pretty-compact": "^4.0.0", "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "markdown-to-jsx": "^9.9.0", "match-sorter": "^8.3.0", "memoize-one": "^6.0.0", diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json index 846fc752088..d7c297330a8 100644 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json @@ -27,7 +27,7 @@ ], "dependencies": { "@math.gl/web-mercator": "^4.1.0", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.1", "supercluster": "^8.0.1" From cd77d13cfef8ee789bb29fcfae10d542a2793a7e Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 1 Aug 2026 05:44:11 +1200 Subject: [PATCH 15/67] =?UTF-8?q?docs:=20fix=20typo=20'throuth'=20?= =?UTF-8?q?=E2=86=92=20'through'=20in=20superset-embedded-sdk/README.md=20?= =?UTF-8?q?(#42647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pi --- superset-embedded-sdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-embedded-sdk/README.md b/superset-embedded-sdk/README.md index e26d2109be7..fc23cc173c6 100644 --- a/superset-embedded-sdk/README.md +++ b/superset-embedded-sdk/README.md @@ -215,7 +215,7 @@ Common permissions you might need: By default, the Embedded SDK creates an `iframe` element without a `referrerPolicy` value enforced. This means that a policy defined for `iframe` elements at the host app level would reflect to it. -This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens throuth the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail. +This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens through the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail. Use the `referrerPolicy` parameter in the `embedDashboard` method to specify [a particular policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy) that works for your implementation. From 263d793b772401d0135b41b996713164a6946f9c Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 1 Aug 2026 05:44:30 +1200 Subject: [PATCH 16/67] docs: fix missing apostrophe in 'doesn't' in superset/mcp_service/README.md (#42648) Co-authored-by: pi --- superset/mcp_service/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/mcp_service/README.md b/superset/mcp_service/README.md index b9599632fda..507c3d1ca7c 100644 --- a/superset/mcp_service/README.md +++ b/superset/mcp_service/README.md @@ -157,7 +157,7 @@ Add this to your Claude Desktop config file: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -Since claude desktop doesnt like non https mcp servers you can use this proxy: +Since claude desktop doesn't like non https mcp servers you can use this proxy: ```json { "mcpServers": { From a419a2a47f670e725eb26ace2d84c91d2c4cebc9 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 1 Aug 2026 05:44:51 +1200 Subject: [PATCH 17/67] docs: fix incorrect capitalization 'PiPY' to 'PyPI' in RELEASING/README.md (#42649) Co-authored-by: pi --- RELEASING/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING/README.md b/RELEASING/README.md index d49671c598c..23f3f116aca 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -423,7 +423,7 @@ git push origin ${SUPERSET_VERSION} ### Publishing a Convenience Release to PyPI -Extract the release to the `/tmp` folder to build the PiPY release. Files in the `/tmp` folder will be automatically deleted by the OS. +Extract the release to the `/tmp` folder to build the PyPI release. Files in the `/tmp` folder will be automatically deleted by the OS. ```bash mkdir -p /tmp/superset && cd /tmp/superset From e391691328cbf9b172002eb0bd453ad20a1725b5 Mon Sep 17 00:00:00 2001 From: Abdul Rehman <76230556+Abdulrehman-PIAIC80387@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:56:36 +0500 Subject: [PATCH 18/67] fix(menu): highlight Datasets tab on /dataset/add/ and /dataset/:id (#42529) --- .../src/features/home/Menu.test.tsx | 53 +++++++++++++++++++ superset-frontend/src/features/home/Menu.tsx | 10 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx index 7fdf67dd0ef..ea1a44178ad 100644 --- a/superset-frontend/src/features/home/Menu.test.tsx +++ b/superset-frontend/src/features/home/Menu.test.tsx @@ -1052,6 +1052,59 @@ describe('active tab highlighting (regression #36403)', () => { expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected'); }); + test.each([ + ['/tablemodelview/list/', 'the legacy FAB dataset list route'], + ['/dataset/add/', 'the modern React dataset create route'], + ['/dataset/42', 'a dataset detail route'], + ])( + 'highlights the Datasets tab on %s (%s) — regression #42467', + async (route, _label) => { + // ``/tablemodelview/list/`` is the pre-existing legacy path (kept as + // a coverage anchor so future prefix-matching changes cannot silently + // regress it); the ``/dataset/*`` routes are the modern React ones + // added by #42467. + useSelectorMock.mockReturnValue({ roles: user.roles }); + window.history.pushState({}, '', route); + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + // Datasets is a child under the Sources submenu — expand it first so + // the item is in the DOM (same pattern as the existing "render the top + // navbar child menu items" test). + const sources = await screen.findByText('Sources'); + userEvent.hover(sources); + + const datasets = await screen.findByText('Datasets'); + expect(datasets.closest('li')).toHaveClass('ant-menu-item-selected'); + }, + ); + + test('does not highlight the Datasets tab on lookalike prefixes (e.g. /datasetXyz)', async () => { + // The active-tab matcher must use a boundary-aware startsWith so that + // an unrelated future route beginning with ``/dataset`` (e.g. a + // hypothetical ``/datasetXyz``) does not falsely trigger the highlight. + useSelectorMock.mockReturnValue({ roles: user.roles }); + window.history.pushState({}, '', '/datasetXyz'); + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const sources = await screen.findByText('Sources'); + userEvent.hover(sources); + + const datasets = await screen.findByText('Datasets'); + expect(datasets.closest('li')).not.toHaveClass('ant-menu-item-selected'); + }); + test('highlights the active SQL tab when the label is localized', async () => { // The SQL Lab top-level entry is a FAB category: its stable `name` is // "SQL Lab" while its label ("SQL") is localized. diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index 07cc228db6c..b78a0dff5a4 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -212,6 +212,12 @@ export function Menu({ Dashboard = '/dashboard', Chart = '/chart', Datasets = '/tablemodelview', + // The legacy FAB dataset list still lives at ``/tablemodelview/list/``, + // but the modern React-managed dataset add + detail routes are under + // ``/dataset/*`` (``/dataset/add/``, ``/dataset/:datasetId``). Both + // prefixes must map to the Datasets tab so the top-nav highlight + // survives navigation into the create/edit flow. See #42467. + Dataset = '/dataset', SqlLab = '/sqllab', SavedQueries = '/savedqueryview', } @@ -238,7 +244,9 @@ export function Menu({ case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore): setActiveTabs([MenuKeys.Charts]); break; - case path.startsWith(Paths.Datasets): + case path.startsWith(Paths.Datasets) || + path === Paths.Dataset || + path.startsWith(`${Paths.Dataset}/`): setActiveTabs([MenuKeys.Datasets]); break; case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries): From dc632a9737e6af2bcede60e3b1bf4f848cdf3bc0 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 10:58:59 -0700 Subject: [PATCH 19/67] fix(db_engine_specs): stop filtering out Postgres schemas prefixed with pg (#42312) Co-authored-by: Claude Opus 4.8 --- superset/db_engine_specs/postgres.py | 26 ++++++++++++++++ .../db_engine_specs/test_postgres.py | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/superset/db_engine_specs/postgres.py b/superset/db_engine_specs/postgres.py index 1945fa3f303..406e85effe2 100644 --- a/superset/db_engine_specs/postgres.py +++ b/superset/db_engine_specs/postgres.py @@ -821,6 +821,32 @@ WHERE datistemplate = false; ) } + @classmethod + def get_schema_names(cls, inspector: Inspector) -> set[str]: + """ + Return all schema names, excluding the ``pg_``-prefixed Postgres + system schemas (e.g. ``pg_catalog``, ``pg_toast``). + + SQLAlchemy's Postgres dialect filters out system schemas with the + query ``nspname NOT LIKE 'pg_%'``. Since ``_`` is a single-character + wildcard in SQL ``LIKE`` patterns, this unintentionally excludes any + user-defined schema that merely starts with ``pg`` followed by any + other character (e.g. ``pgsql``, ``pgstats``), not only the + ``pg_``-prefixed system schemas. Matching on the literal ``pg_`` + prefix instead keeps those user-defined schemas. + + TODO: drop this override once sqlalchemy/sqlalchemy#13471 is merged + and released, and SQLAlchemy is bumped past that version. + """ + with inspector.engine.connect() as conn: + return { + name + for (name,) in conn.execute( + text("SELECT nspname FROM pg_namespace ORDER BY nspname") + ) + if not name.startswith("pg_") + } + @classmethod def get_table_names( cls, database: Database, inspector: PGInspector, schema: str | None diff --git a/tests/unit_tests/db_engine_specs/test_postgres.py b/tests/unit_tests/db_engine_specs/test_postgres.py index 287fcec38a3..c1840d9fd71 100644 --- a/tests/unit_tests/db_engine_specs/test_postgres.py +++ b/tests/unit_tests/db_engine_specs/test_postgres.py @@ -450,3 +450,33 @@ def test_interval_type_mutator() -> None: assert mutator(True) is None assert mutator([1, 2, 3]) is None assert mutator({"days": 1}) is None + + +def test_get_schema_names_excludes_only_actual_system_schemas( + mocker: MockerFixture, +) -> None: + """ + DB Eng Specs (postgres): Test ``get_schema_names`` + + User-defined schemas that merely start with ``pg`` (but are not + actual Postgres system schemas, which always start with the literal + ``pg_``) must not be filtered out. See issue #30678. + """ + inspector = mocker.MagicMock() + inspector.engine.connect().__enter__().execute.return_value = [ + ("public",), + ("pgsql",), + ("pgstats",), + ("pg_catalog",), + ("pg_toast",), + ("information_schema",), + ] + + schemas = spec.get_schema_names(inspector) + + assert schemas == { + "public", + "pgsql", + "pgstats", + "information_schema", + } From ec8405aeea625e82864bfa4e7796d4e61037a88d Mon Sep 17 00:00:00 2001 From: Arijit Kumar Roy <43462564+arijitroy003@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:31:39 +0530 Subject: [PATCH 20/67] chore(mcp): remove unused MCP_SERVICE_HOST and MCP_SERVICE_PORT config (#42569) Signed-off-by: arijitroy003 --- superset/mcp_service/mcp_config.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py index d0f2e135a24..5094251e1c7 100644 --- a/superset/mcp_service/mcp_config.py +++ b/superset/mcp_service/mcp_config.py @@ -56,10 +56,6 @@ SUPERSET_WEBSERVER_ADDRESS = "http://localhost:9001" WEBDRIVER_BASEURL = "http://localhost:9001/" WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL -# MCP Service Host/Port -MCP_SERVICE_HOST = "localhost" -MCP_SERVICE_PORT = 5008 - # Bug-report support contact surfaced by the generate_bug_report tool. Each # deployment should override this in superset_config.py to point users at the # right channel (e.g. an internal support address, a vendor support team). @@ -691,8 +687,6 @@ def get_mcp_config(app_config: dict[str, Any] | None = None) -> dict[str, Any]: "SUPERSET_WEBSERVER_ADDRESS": SUPERSET_WEBSERVER_ADDRESS, "WEBDRIVER_BASEURL": WEBDRIVER_BASEURL, "WEBDRIVER_BASEURL_USER_FRIENDLY": WEBDRIVER_BASEURL_USER_FRIENDLY, - "MCP_SERVICE_HOST": MCP_SERVICE_HOST, - "MCP_SERVICE_PORT": MCP_SERVICE_PORT, "MCP_DEBUG": MCP_DEBUG, "MCP_RBAC_ENABLED": MCP_RBAC_ENABLED, "MCP_DISABLED_TOOLS": set(MCP_DISABLED_TOOLS), From 035eaa8b80c8b168d7e21cc2be57d81cb9e66973 Mon Sep 17 00:00:00 2001 From: PRATHAMESH HUKKERI Date: Fri, 31 Jul 2026 23:33:47 +0530 Subject: [PATCH 21/67] fix(time-comparison): preserve inner bounds for relative offsets (#42357) Co-authored-by: Prathamesh Hukkeri --- superset/models/helpers.py | 4 +- .../common/test_query_context_processor.py | 103 ++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index fbd2865abf6..438b4c70231 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -2054,8 +2054,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods offset, outer_to_dttm ) - query_object_clone.inner_from_dttm = query_object_clone.from_dttm - query_object_clone.inner_to_dttm = query_object_clone.to_dttm + query_object_clone.inner_from_dttm = outer_from_dttm + query_object_clone.inner_to_dttm = outer_to_dttm x_axis_label = get_x_axis_label(query_object.columns) query_object_clone.granularity = ( diff --git a/tests/unit_tests/common/test_query_context_processor.py b/tests/unit_tests/common/test_query_context_processor.py index a2f259dd627..88c786ed1da 100644 --- a/tests/unit_tests/common/test_query_context_processor.py +++ b/tests/unit_tests/common/test_query_context_processor.py @@ -2256,3 +2256,106 @@ def test_grouping_sets_fallback_applies_row_offset_once_globally() -> None: # ...and the requested offset is applied exactly once, to the combined # result: 2 + 1 = 3 total rows in, minus an offset of 1 = 2 rows out. assert len(result.df) == 2 + + +def test_relative_offset_preserves_inner_bounds( + processor: QueryContextProcessor, +) -> None: + """ + Regression test for #40501: Relative time comparison offset should + preserve inner bounds as the original (unshifted) period, not the shifted one. + + When comparing 2026-05-01 : 2026-05-28 with offset "365 days ago": + - inner_from_dttm should be 2026-05-01 (original), NOT 2025-05-01 (shifted) + - inner_to_dttm should be 2026-05-28 (original), NOT 2025-05-28 (shifted) + """ + from superset.common.query_object import QueryObject + from superset.models.helpers import ExploreMixin + + datasource: Any = processor._qc_datasource + + for method in ( + "processing_time_offsets", + "_align_offset_without_time_grain", + "_coalesce_offset_index", + ): + setattr( + datasource, + method, + getattr(ExploreMixin, method).__get__(datasource), + ) + + df = pd.DataFrame( + { + "__timestamp": pd.to_datetime(["2026-05-01", "2026-05-15", "2026-05-28"]), + "sum__num": [100, 200, 300], + } + ) + + query_object = QueryObject( + datasource=MagicMock(), + granularity="ds", + columns=[], + metrics=["sum__num"], + is_timeseries=True, + time_offsets=["365 days ago"], + filters=[ + { + "col": "ds", + "op": "TEMPORAL_RANGE", + "val": "2026-05-01 : 2026-05-28", + } + ], + ) + + captured: list[dict[str, Any]] = [] + + def fake_query(dct: dict[str, Any]) -> MagicMock: + captured.append(dct) + result = MagicMock() + result.df = pd.DataFrame( + { + "__timestamp": pd.date_range( + start=dct["from_dttm"], periods=3, freq="14D" + ), + "sum__num": [1.0, 2.0, 3.0], + } + ) + result.query = "SELECT 1" + return result + + datasource.query = fake_query + datasource.normalize_df = MagicMock( + side_effect=lambda offset_df, _query_object: offset_df + ) + + with ( + patch( + "superset.models.helpers.get_since_until_from_query_object", + return_value=(pd.Timestamp("2026-05-01"), pd.Timestamp("2026-05-28")), + ), + patch( + "superset.common.utils.query_cache_manager.QueryCacheManager" + ) as mock_cache_manager, + patch.object( + datasource, + "get_time_grain", + return_value=None, + ), + ): + mock_cache = MagicMock() + mock_cache.is_loaded = False + mock_cache_manager.get.return_value = mock_cache + + datasource.processing_time_offsets(df, query_object, None, None, False) + + # The offset query should use shifted dates for the main window + assert len(captured) == 1 + assert captured[0]["from_dttm"] == pd.Timestamp("2025-05-01") + assert captured[0]["to_dttm"] == pd.Timestamp("2025-05-28") + + # The inner bounds (used for series-limit subquery) should be the + # ORIGINAL unshifted dates, not the shifted ones — this is the fix + # for #40501. Without the fix, inner_from/to_dttm == shifted dates. + assert captured[0]["inner_from_dttm"] == pd.Timestamp("2026-05-01") + assert captured[0]["inner_to_dttm"] == pd.Timestamp("2026-05-28") From 6c8763bf5a48caea94acc979b2fd3a432eed7e97 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 31 Jul 2026 12:36:36 -0700 Subject: [PATCH 22/67] fix(db2): stop truncating table comments to one character (#42645) Co-authored-by: Claude Code --- superset/db_engine_specs/db2.py | 10 ++------ tests/unit_tests/db_engine_specs/test_db2.py | 25 +++++++++++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/superset/db_engine_specs/db2.py b/superset/db_engine_specs/db2.py index 0e24d341513..33b02130ae7 100644 --- a/superset/db_engine_specs/db2.py +++ b/superset/db_engine_specs/db2.py @@ -125,23 +125,17 @@ class Db2EngineSpec(BaseEngineSpec): """ Get comment of table from a given schema - Ibm Db2 return comments as tuples, so we need to get the first element - :param inspector: SqlAlchemy Inspector instance :param table: Table instance :return: comment of table """ - comment = None try: table_comment = inspector.get_table_comment(table.table, table.schema) - comment = table_comment.get("text") - return comment[0] - except IndexError: - return comment + return table_comment.get("text") except Exception as ex: # pylint: disable=broad-except logger.error("Unexpected error while fetching table comment", exc_info=True) logger.exception(ex) - return comment + return None @classmethod def get_prequeries( diff --git a/tests/unit_tests/db_engine_specs/test_db2.py b/tests/unit_tests/db_engine_specs/test_db2.py index 6f469fb7a20..2d2421449b1 100644 --- a/tests/unit_tests/db_engine_specs/test_db2.py +++ b/tests/unit_tests/db_engine_specs/test_db2.py @@ -39,13 +39,16 @@ def test_epoch_to_dttm() -> None: def test_get_table_comment(mocker: MockerFixture): """ Test the `get_table_comment` method. + + ibm_db_sa >= 0.4.1 returns the comment as a plain string (fixed in + https://github.com/ibmdb/python-ibmdbsa/pull/135), not a tuple as it + used to. Indexing into that string with `comment[0]` truncates every + DB2 table comment to its first character; this guards against that. """ from superset.db_engine_specs.db2 import Db2EngineSpec mock_inspector = mocker.MagicMock() - mock_inspector.get_table_comment.return_value = { - "text": ("This is a table comment",) - } + mock_inspector.get_table_comment.return_value = {"text": "This is a table comment"} assert ( Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema")) @@ -69,6 +72,22 @@ def test_get_table_comment_empty(mocker: MockerFixture): ) +def test_get_table_comment_unexpected_error(mocker: MockerFixture): + """ + Test that `get_table_comment` returns `None` instead of raising + when the inspector call fails unexpectedly. + """ + from superset.db_engine_specs.db2 import Db2EngineSpec + + mock_inspector = mocker.MagicMock() + mock_inspector.get_table_comment.side_effect = Exception("boom") + + assert ( + Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema")) + is None + ) + + def test_get_prequeries(mocker: MockerFixture) -> None: """ Test the ``get_prequeries`` method. From f607e17e3a7fd98b2bb2c0441b6aefb15474bcc9 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 12:41:29 -0700 Subject: [PATCH 23/67] fix(reports): fail loudly instead of falling back to unguarded screenshot when tiled capture fails (#42273) Co-authored-by: Claude --- superset/utils/webdriver.py | 19 +++++---- tests/unit_tests/utils/webdriver_test.py | 53 ++++++++++++++++-------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index 5fd7d94418a..75eeb1d2497 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -563,18 +563,23 @@ class WebDriverPlaywright(WebDriverProxy): log_context=log_context, ) if not img: + # _get_screenshot() has no wait/readiness logic at + # all, so falling back to it here would risk + # silently delivering a screenshot of spinners or + # a blank dashboard. Fail the capture loudly + # (report error, thumbnail cache ERROR) instead of + # guessing at a "safer" fallback. logger.warning( - ( - "Tiled screenshot failed, " - "falling back to standard screenshot" - ) + "Tiled screenshot failed for url %s and no " + "safe fallback exists; failing the capture", + url, ) - img = WebDriverPlaywright._get_screenshot( - page, element, element_name + raise PlaywrightTimeout( + f"Tiled screenshot failed for url {url}" ) logger.debug( "Tiled screenshot result: %d bytes for url: %s", - len(img) if img else 0, + len(img), url, ) else: diff --git a/tests/unit_tests/utils/webdriver_test.py b/tests/unit_tests/utils/webdriver_test.py index a7dea405b8e..c6fa18e9dc9 100644 --- a/tests/unit_tests/utils/webdriver_test.py +++ b/tests/unit_tests/utils/webdriver_test.py @@ -930,10 +930,13 @@ class TestWebDriverPlaywrightErrorHandling: @patch("superset.utils.webdriver._browser_manager") @patch("superset.utils.webdriver.logger") @patch("superset.utils.webdriver.take_tiled_screenshot") - def test_tiled_screenshot_failure_falls_back_to_standard_screenshot( + def test_tiled_screenshot_failure_raises_without_fallback( self, mock_take_tiled, mock_logger, mock_browser_manager ) -> None: - """When take_tiled_screenshot returns None, fall back to standard screenshot.""" + """When take_tiled_screenshot returns None, fail loudly instead of + falling back to an unguarded standard screenshot.""" + from superset.utils.webdriver import PlaywrightTimeout + mock_user = MagicMock() mock_user.username = "test_user" @@ -947,7 +950,8 @@ class TestWebDriverPlaywrightErrorHandling: mock_context.new_page.return_value = mock_page mock_page.locator.return_value = mock_element mock_element.wait_for.return_value = None - # page.screenshot is used by _get_screenshot for the "standalone" element + # page.screenshot is used by _get_screenshot for the "standalone" element; + # it must never be reached by the failure path under test. mock_page.screenshot.return_value = b"fallback_screenshot" def evaluate_side_effect(script): @@ -983,14 +987,20 @@ class TestWebDriverPlaywrightErrorHandling: mock_auth.return_value = mock_context driver = WebDriverPlaywright("chrome") - result = driver.get_screenshot( - "http://example.com", "standalone", mock_user - ) + # match= keeps this assertion meaningful even when playwright + # is not installed and PlaywrightTimeout aliases bare Exception. + with pytest.raises( + PlaywrightTimeout, match="Tiled screenshot failed for url" + ): + driver.get_screenshot("http://example.com", "standalone", mock_user) - assert result == b"fallback_screenshot" mock_take_tiled.assert_called_once() + mock_page.screenshot.assert_not_called() + mock_element.screenshot.assert_not_called() mock_logger.warning.assert_any_call( - ("Tiled screenshot failed, falling back to standard screenshot"), + "Tiled screenshot failed for url %s and no safe fallback " + "exists; failing the capture", + "http://example.com", ) @@ -1514,10 +1524,13 @@ class TestWebDriverPlaywrightAnimationWaitOrder: @patch("superset.utils.webdriver._browser_manager") @patch("superset.utils.webdriver.take_tiled_screenshot") @patch("superset.utils.webdriver.app") - def test_tiled_fallback_triggered_on_empty_bytes( + def test_tiled_empty_bytes_raises_without_fallback( self, mock_app, mock_take_tiled, mock_browser_manager ): - """Tiled fallback fires when take_tiled_screenshot returns b"" (not None).""" + """Tiled failure raises when take_tiled_screenshot returns b"" (not None), + instead of silently falling through to an unguarded raw capture.""" + from superset.utils.webdriver import PlaywrightTimeout + mock_user = MagicMock() mock_user.username = "test_user" mock_app.config = { @@ -1532,20 +1545,24 @@ class TestWebDriverPlaywrightAnimationWaitOrder: mock_page.evaluate.side_effect = [25, 6000] # Empty bytes — falsy but not None; was silently passed through before the fix mock_take_tiled.return_value = b"" - # _get_screenshot("standalone") calls page.screenshot(full_page=True); - # configure that return value so we can assert the fallback was reached + # _get_screenshot("standalone") calls page.screenshot(full_page=True); it + # must never be reached by the failure path under test. mock_page.screenshot.return_value = b"fallback" with patch.object(WebDriverPlaywright, "auth", return_value=mock_context): - result = WebDriverPlaywright("chrome").get_screenshot( - "http://example.com", "standalone", mock_user - ) + # match= keeps this assertion meaningful even when playwright + # is not installed and PlaywrightTimeout aliases bare Exception. + with pytest.raises( + PlaywrightTimeout, match="Tiled screenshot failed for url" + ): + WebDriverPlaywright("chrome").get_screenshot( + "http://example.com", "standalone", mock_user + ) - assert result == b"fallback" # Tiled path was taken (take_tiled_screenshot was called) mock_take_tiled.assert_called_once() - # Standard screenshot was called as fallback (full_page=True for "standalone") - mock_page.screenshot.assert_called_with(full_page=True) + # Standard screenshot must never be called as a fallback + mock_page.screenshot.assert_not_called() @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) @patch("superset.utils.webdriver._browser_manager") From 0981b1101a26c55359e87e01c8962b48449bfb52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:49:05 -0700 Subject: [PATCH 24/67] chore(deps-dev): bump nx from 22.6.1 to 22.7.8 in /superset-frontend (#42651) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 649 ++++++++++++++++------------ 1 file changed, 376 insertions(+), 273 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 18f6768aa4e..e52816d84ba 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -6333,13 +6333,6 @@ "@loaders.gl/core": "^4.3.0" } }, - "node_modules/@ltd/j-toml": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.38.0.tgz", - "integrity": "sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==", - "dev": true, - "license": "LGPL-3.0" - }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", @@ -7588,9 +7581,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.6.1.tgz", - "integrity": "sha512-lixkEBGFdEsUiqEZg9LIyjfiTv12Sg1Es/yUgrdOQUAZu+5oiUPMoybyBwrvINl+fZw+PLh66jOmB4GSP2aUMQ==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.8.tgz", + "integrity": "sha512-IM1geDyWPFsS565de9dByYNZ5I3j8FQZvNUp9LIYw1dNu70sCWbAT0glw0anholOwlAb7JWEscoUeV7ouRBW0A==", "cpu": [ "arm64" ], @@ -7602,9 +7595,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.1.tgz", - "integrity": "sha512-HvgtOtuWnEf0dpfWb05N0ptdFg040YgzsKFhXg6+qaBJg5Hg0e0AXPKaSgh2PCqCIDlKu40YtwVgF7KXxXAGlA==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.8.tgz", + "integrity": "sha512-X/AyooJCmAwHyp9f//bspkAmtaPsv2lPEKe6OiOScsyxJITP4nw+rOfIAJ4Ar64WQcMAY8WLP+ys4ah0K6DZSw==", "cpu": [ "x64" ], @@ -7616,9 +7609,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.6.1.tgz", - "integrity": "sha512-g2wUltGX+7/+mdTV5d6ODa0ylrNu/krgb9YdrsbhW6oZeXYm2LeLOAnYqIlL/Kx140NLrb5Kcz7bi7JrBAw4Ow==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.8.tgz", + "integrity": "sha512-mgdqiFag8txon4XugDtNj+hq+X9Whn8LBMuqwJSez93L1fralzpQFSUip7XnE7ejQRr5Zewg3BFscGlsk8Cy3A==", "cpu": [ "x64" ], @@ -7630,9 +7623,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.6.1.tgz", - "integrity": "sha512-TTqisFPAPrj35EihvzotBbajS+0bX++PQggmRVmDmGwSTrpySRJwZnKNHYDqP6s9tigDvkNJOJftK+GkBEFRRA==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.8.tgz", + "integrity": "sha512-g7ojIloHGFI7wuMnUdg7io42EL7C7ae4zAolNVj7eXZM54amn3qoNjwbyqaVbBQvUNRiAKJizGyn1IhKpzvG9A==", "cpu": [ "arm" ], @@ -7644,13 +7637,16 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.6.1.tgz", - "integrity": "sha512-uIkPcanSTIcyh7/6LOoX0YpGO/7GkVhMRgyM9Mg/7ItFjCtRaeuPEPrJESsaNeB5zIVVhI4cXbGrM9NDnagiiw==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.8.tgz", + "integrity": "sha512-Kws8e7W4epfqpTWYaV7KLKaj33o+9JtJa2rErx/XFTtMXhfVzOs5hC4oIrZsYpgk1DuT0APp7UDvX06q2kdAVQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7658,13 +7654,16 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.6.1.tgz", - "integrity": "sha512-eqkG8s/7remiRZ1Lo2zIrFLSNsQ/0x9fAj++CV1nqFE+rfykPQhC48F8pqsq6tUQpI5HqRQEfQgv4CnFNpLR+w==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.8.tgz", + "integrity": "sha512-fpnyVFL+mSqLdKBPk8+n/rHUEXiem7Xwr9cIOd2Dd5zxQ5wcwj4qSYTNRsBPI2qDFo3esHliwaoFJZULbTHldw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7672,13 +7671,16 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.6.1.tgz", - "integrity": "sha512-6DhSupCcDa6BYzQ48qsMK4LIdIO+y4E+4xuUBkX2YTGOZh58gctELCv7Gi6/FhiC8rzVzM7hDcygOvHCGc30zA==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.8.tgz", + "integrity": "sha512-KRbkSthClEwkbpt/LLJmBJhIOvOsyrp9OICisF9p3mOODjaxAuYmyOgrVreg09BT2F4hXN3JXpvt9eIZpTCRsw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7686,13 +7688,16 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.6.1.tgz", - "integrity": "sha512-QqtfaBhdfLRKGucpP8RSv7KJ51XRWpfUcXPhkb/1dKP/b9/Z0kpaCgczGHdrAtX9m6haWw+sQXYGxnStZIg/TQ==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.8.tgz", + "integrity": "sha512-pdPMWko1yZI5ZNI6/t2Y5rQPGgV/ah5DW+mlHo2aFKav4lnxvgisEh9e4HtOsYBfK/9PyYZ5u3M9hLSaMiJ75w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7700,9 +7705,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.6.1.tgz", - "integrity": "sha512-8pTWXphY5IIgY3edZ5SfzP8yPjBqoAxRV5snAYDctF4e0OC1nDOUims70jLesMle8DTSWiHPSfbLVfp2HkU9WQ==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.8.tgz", + "integrity": "sha512-yYDu3pj7AXu7LtN/T/bA4TMWWWbmvEE4wa8UW8ZU5JeWYblyXQA7HyYpPAb4fLzCtHb/dIrNDghVBRIeAAcnSw==", "cpu": [ "arm64" ], @@ -7714,9 +7719,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.6.1.tgz", - "integrity": "sha512-XMYrtsR5O39uNR4fVpFs65rVB09FyLXvUM735r2rO7IUWWHxHWTAgVcc+gqQaAchBPqR9f1q+3u2i1Inub3Cdw==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.8.tgz", + "integrity": "sha512-cSr0qMt/GgM2aOBFsapxllnIv55j0gAz/6eWvqEOx5sS8d3X1G0IgazZnPbjW2c8gfSYaBZ9UmYnfapWIO/jqg==", "cpu": [ "x64" ], @@ -14098,50 +14103,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz", - "integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/@yarnpkg/parsers/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@yeoman/namespace": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@yeoman/namespace/-/namespace-2.1.0.tgz", @@ -14982,16 +14943,6 @@ "node": ">= 6" } }, - "node_modules/axios/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -18820,9 +18771,9 @@ } }, "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -19048,9 +18999,9 @@ } }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", "dependencies": { @@ -21257,19 +21208,6 @@ "node": ">= 6" } }, - "node_modules/form-data/node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -21331,46 +21269,6 @@ ], "license": "MIT" }, - "node_modules/front-matter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", - "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1" - } - }, - "node_modules/front-matter/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/front-matter/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -22986,9 +22884,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -28421,6 +28319,36 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -31174,65 +31102,143 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.6.1.tgz", - "integrity": "sha512-b4eo52o5aCVt3oG6LPYvD2Cul3JFBMgr2p9OjMBIo6oU6QfSR693H2/UuUMepLtO6jcIniPKOcIrf6Ue8aXAww==", + "version": "22.7.8", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.8.tgz", + "integrity": "sha512-ceEhmaGCvY7oi7L7G/Nm/UZSnbfwaJxU1XbDLro7CTmVcnrmxAnxExCp0J/Tpf1xQaMZtwxgV7QATdKA/7vLQw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@ltd/j-toml": "^1.38.0", + "@emnapi/core": "1.4.5", + "@emnapi/runtime": "1.4.5", + "@emnapi/wasi-threads": "1.0.4", + "@jest/diff-sequences": "30.0.1", "@napi-rs/wasm-runtime": "0.2.4", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "3.0.2", + "@tybys/wasm-util": "0.9.0", + "@yarnpkg/lockfile": "1.1.0", "@zkochan/js-yaml": "0.0.7", - "axios": "^1.12.0", + "agent-base": "6.0.2", + "ansi-colors": "4.1.3", + "ansi-regex": "5.0.1", + "ansi-styles": "4.3.0", + "argparse": "2.0.1", + "asynckit": "0.4.0", + "axios": "1.18.1", + "balanced-match": "4.0.3", + "base64-js": "1.5.1", + "bl": "4.1.0", + "brace-expansion": "5.0.8", + "buffer": "5.7.1", + "call-bind-apply-helpers": "1.0.2", + "chalk": "4.1.2", "cli-cursor": "3.1.0", "cli-spinners": "2.6.1", - "cliui": "^8.0.1", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "ejs": "^3.1.7", - "enquirer": "~2.3.6", + "cliui": "8.0.1", + "clone": "1.0.4", + "color-convert": "2.0.1", + "color-name": "1.1.4", + "combined-stream": "1.0.8", + "debug": "4.4.3", + "defaults": "1.0.4", + "define-lazy-prop": "2.0.0", + "delayed-stream": "1.0.0", + "dotenv": "16.4.7", + "dotenv-expand": "12.0.3", + "dunder-proto": "1.0.1", + "ejs": "5.0.1", + "emoji-regex": "8.0.0", + "end-of-stream": "1.4.5", + "enquirer": "2.3.6", + "es-define-property": "1.0.1", + "es-errors": "1.3.0", + "es-object-atoms": "1.1.1", + "es-set-tostringtag": "2.1.0", + "escalade": "3.2.0", + "escape-string-regexp": "1.0.5", "figures": "3.2.0", - "flat": "^5.0.2", - "front-matter": "^4.0.2", - "ignore": "^7.0.5", - "jest-diff": "^30.0.2", + "flat": "5.0.2", + "follow-redirects": "1.16.0", + "form-data": "4.0.6", + "fs-constants": "1.0.0", + "function-bind": "1.1.2", + "get-caller-file": "2.0.5", + "get-intrinsic": "1.3.0", + "get-proto": "1.0.1", + "gopd": "1.2.0", + "has-flag": "4.0.0", + "has-symbols": "1.1.0", + "has-tostringtag": "1.0.2", + "hasown": "2.0.4", + "https-proxy-agent": "5.0.1", + "ieee754": "1.2.1", + "ignore": "7.0.5", + "inherits": "2.0.4", + "is-docker": "2.2.1", + "is-fullwidth-code-point": "3.0.0", + "is-interactive": "1.0.0", + "is-unicode-supported": "0.1.0", + "is-wsl": "2.2.0", + "json5": "2.2.3", "jsonc-parser": "3.2.0", "lines-and-columns": "2.0.3", - "minimatch": "10.2.4", - "npm-run-path": "^4.0.1", - "open": "^8.4.0", + "log-symbols": "4.1.0", + "math-intrinsics": "1.1.0", + "mime-db": "1.52.0", + "mime-types": "2.1.35", + "mimic-fn": "2.1.0", + "minimatch": "10.2.5", + "minimist": "1.2.8", + "ms": "2.1.3", + "npm-run-path": "4.0.1", + "once": "1.4.0", + "onetime": "5.1.2", + "open": "8.4.2", "ora": "5.3.0", - "picocolors": "^1.1.0", + "path-key": "3.1.1", + "picocolors": "1.1.1", + "proxy-from-env": "2.1.0", + "readable-stream": "3.6.2", + "require-directory": "2.1.1", "resolve.exports": "2.0.3", - "semver": "^7.6.3", - "string-width": "^4.2.3", - "tar-stream": "~2.2.0", - "tmp": "~0.2.1", - "tree-kill": "^1.2.2", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.3.0", - "yaml": "^2.6.0", - "yargs": "^17.6.2", + "restore-cursor": "3.1.0", + "safe-buffer": "5.2.1", + "semver": "7.7.4", + "signal-exit": "3.0.7", + "smol-toml": "1.6.1", + "string_decoder": "1.3.0", + "string-width": "4.2.3", + "strip-ansi": "6.0.1", + "strip-bom": "3.0.0", + "supports-color": "7.2.0", + "tar-stream": "2.2.0", + "tmp": "0.2.7", + "tree-kill": "1.2.2", + "tsconfig-paths": "4.2.0", + "tslib": "2.8.1", + "util-deprecate": "1.0.2", + "wcwidth": "1.0.1", + "wrap-ansi": "7.0.0", + "wrappy": "1.0.2", + "y18n": "5.0.8", + "yaml": "2.9.0", + "yargs": "17.7.2", "yargs-parser": "21.1.1" }, "bin": { - "nx": "bin/nx.js", - "nx-cloud": "bin/nx-cloud.js" + "nx": "dist/bin/nx.js", + "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.6.1", - "@nx/nx-darwin-x64": "22.6.1", - "@nx/nx-freebsd-x64": "22.6.1", - "@nx/nx-linux-arm-gnueabihf": "22.6.1", - "@nx/nx-linux-arm64-gnu": "22.6.1", - "@nx/nx-linux-arm64-musl": "22.6.1", - "@nx/nx-linux-x64-gnu": "22.6.1", - "@nx/nx-linux-x64-musl": "22.6.1", - "@nx/nx-win32-arm64-msvc": "22.6.1", - "@nx/nx-win32-x64-msvc": "22.6.1" + "@nx/nx-darwin-arm64": "22.7.8", + "@nx/nx-darwin-x64": "22.7.8", + "@nx/nx-freebsd-x64": "22.7.8", + "@nx/nx-linux-arm-gnueabihf": "22.7.8", + "@nx/nx-linux-arm64-gnu": "22.7.8", + "@nx/nx-linux-arm64-musl": "22.7.8", + "@nx/nx-linux-x64-gnu": "22.7.8", + "@nx/nx-linux-x64-musl": "22.7.8", + "@nx/nx-win32-arm64-msvc": "22.7.8", + "@nx/nx-win32-x64-msvc": "22.7.8" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -31247,47 +31253,75 @@ } } }, - "node_modules/nx/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "node_modules/nx/node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/nx/node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/nx/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/nx/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "debug": "4" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">= 6.0.0" } }, + "node_modules/nx/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/nx/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/nx/node_modules/brace-expansion": { @@ -31303,6 +31337,60 @@ "node": "20 || >=22" } }, + "node_modules/nx/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/nx/node_modules/ejs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz", + "integrity": "sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/nx/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nx/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/nx/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/nx/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -31313,30 +31401,27 @@ "node": ">= 4" } }, - "node_modules/nx/node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "node_modules/nx/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/nx/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -31345,20 +31430,45 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/nx/node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "node_modules/nx/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nx/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nx/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/nx/node_modules/yargs": { @@ -32034,36 +32144,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -33616,6 +33696,16 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -37578,6 +37668,19 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", From 85bab0b07c780b2df73de531a116ac25e2514705 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 15:00:38 -0700 Subject: [PATCH 25/67] fix(reports): downgrade chart-container timeout log level and fix tiling veto on unknown height (#42153) Co-authored-by: Claude --- superset/utils/webdriver.py | 54 +++++-- tests/unit_tests/utils/webdriver_test.py | 179 ++++++++++++++++++++++- 2 files changed, 220 insertions(+), 13 deletions(-) diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index 75eeb1d2497..e968711ad41 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -482,16 +482,31 @@ class WebDriverPlaywright(WebDriverProxy): logger.exception("Timed out requesting url %s", url) raise + slice_container_elems: list[Locator] = [] + rendered_chart_count = 0 try: # chart containers didn't render logger.debug("Wait for chart containers to draw at url: %s", url) slice_container_locator = page.locator(".chart-container") - for slice_container_elem in slice_container_locator.all(): + # One-time snapshot: containers mounting after this point + # are neither waited on nor counted, so the progress + # numbers below describe the snapshot, not the final DOM. + slice_container_elems = slice_container_locator.all() + for slice_container_elem in slice_container_elems: slice_container_elem.wait_for() + rendered_chart_count += 1 except PlaywrightTimeout: - logger.exception( - "Timed out waiting for chart containers to draw at url %s", + # Customer-side chart loading is often just slow, not a + # Superset bug, so this is a WARNING (matching the other + # locate-wait timeouts below) rather than an ERROR -- but + # it still fails the screenshot; see the `raise` below. + logger.warning( + "Timed out waiting for chart containers to draw at url %s " + "(%s of %s chart containers rendered before the timeout)", url, + rendered_chart_count, + len(slice_container_elems), + exc_info=True, ) raise selenium_animation_wait = app.config[ @@ -529,19 +544,38 @@ class WebDriverPlaywright(WebDriverProxy): "SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height ) - if dashboard_height == 0: - logger.warning( + # A height of 0 means the DOM query above found no matching + # element (or it hadn't laid out yet), not that the + # dashboard is actually empty. Treat it as "unknown" rather + # than "fits in a single tile": chart_count alone already + # tells us whether this looks like a large dashboard, and + # that signal must not be silently vetoed just because we + # couldn't measure height, or a large dashboard could skip + # tiling and ship with unrendered below-the-fold charts. + height_unknown = dashboard_height == 0 + likely_large_dashboard = ( + chart_count >= chart_threshold + or dashboard_height > height_threshold + ) + if height_unknown: + log_fn = ( + logger.warning if likely_large_dashboard else logger.debug + ) + log_fn( "Could not determine dashboard height for element %s " - "at url %s; falling back to standard screenshot behavior", + "at url %s (%s chart containers found); %s", element_name, url, + chart_count, + "attempting tiled screenshot anyway" + if likely_large_dashboard + else "falling back to standard screenshot behavior", ) # Use tiled screenshots for large dashboards - use_tiled = ( - chart_count >= chart_threshold - or dashboard_height > height_threshold - ) and dashboard_height > tile_height + use_tiled = likely_large_dashboard and ( + height_unknown or dashboard_height > tile_height + ) if use_tiled: logger.info( diff --git a/tests/unit_tests/utils/webdriver_test.py b/tests/unit_tests/utils/webdriver_test.py index c6fa18e9dc9..8b0ab64c364 100644 --- a/tests/unit_tests/utils/webdriver_test.py +++ b/tests/unit_tests/utils/webdriver_test.py @@ -919,12 +919,185 @@ class TestWebDriverPlaywrightErrorHandling: ) assert result == b"fake_screenshot" - mock_logger.warning.assert_any_call( - "Could not determine dashboard height for element %s at url %s; " - "falling back to standard screenshot behavior", + # chart_count (1) is well below the tiling threshold (20), so this is + # the benign/expected case and must not be logged as a WARNING. + mock_logger.debug.assert_any_call( + "Could not determine dashboard height for element %s " + "at url %s (%s chart containers found); %s", "dashboard", "http://example.com", + 1, + "falling back to standard screenshot behavior", ) + assert not any( + call.args and "Could not determine dashboard height" in call.args[0] + for call in mock_logger.warning.call_args_list + ) + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver._browser_manager") + @patch("superset.utils.webdriver.logger") + @patch("superset.utils.webdriver.take_tiled_screenshot") + def test_unknown_height_does_not_veto_tiling_for_large_dashboard( + self, mock_take_tiled, mock_logger, mock_browser_manager + ): + """ + A large dashboard (by chart_count) whose height can't be measured + must still attempt tiling instead of being silently downgraded to a + standard screenshot, since below-the-fold charts may not have + rendered without the scroll-driven tiling pass. + """ + mock_user = MagicMock() + mock_user.username = "test_user" + + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + mock_chart_container = MagicMock() + + mock_browser_manager.get_browser.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + + def locator_side_effect(selector): + if selector == ".chart-container": + locator = MagicMock() + locator.all.return_value = [mock_chart_container] + return locator + return mock_element + + mock_page.locator.side_effect = locator_side_effect + mock_element.wait_for.return_value = None + mock_chart_container.wait_for.return_value = None + mock_page.wait_for_timeout.return_value = None + mock_take_tiled.return_value = b"tiled_screenshot" + + def evaluate_side_effect(script): + if script == 'document.querySelectorAll(".chart-container").length': + return 25 # chart_count >= threshold + if "const target = document.querySelector" in script: + return 0 # height could not be determined + return None + + mock_page.evaluate.side_effect = evaluate_side_effect + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False, + "SCREENSHOT_TILED_ENABLED": True, + "SCREENSHOT_TILED_CHART_THRESHOLD": 20, + "SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000, + "SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot( + "http://example.com", "dashboard", mock_user + ) + + assert result == b"tiled_screenshot" + mock_take_tiled.assert_called_once() + mock_logger.warning.assert_any_call( + "Could not determine dashboard height for element %s " + "at url %s (%s chart containers found); %s", + "dashboard", + "http://example.com", + 25, + "attempting tiled screenshot anyway", + ) + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver._browser_manager") + @patch("superset.utils.webdriver.logger") + def test_chart_container_timeout_logs_warning_with_progress_and_raises( + self, mock_logger, mock_browser_manager + ): + """ + Timing out while waiting for `.chart-container` elements to draw must + be logged as a WARNING (matching the other locate-wait timeouts in + this method, and the customer-side-slowness convention established + for these Playwright timeouts) with rendered/total progress, and must + still fail the screenshot by re-raising. + """ + from superset.utils.webdriver import PlaywrightTimeout + + mock_user = MagicMock() + mock_user.username = "test_user" + + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + + mock_browser_manager.get_browser.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + + timeout = PlaywrightTimeout() + rendered_ok = MagicMock() + rendered_ok.wait_for.return_value = None + never_renders = MagicMock() + never_renders.wait_for.side_effect = timeout + + def locator_side_effect(selector): + if selector == ".chart-container": + locator = MagicMock() + locator.all.return_value = [rendered_ok, never_renders] + return locator + return mock_element + + mock_page.locator.side_effect = locator_side_effect + mock_element.wait_for.return_value = None + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False, + "SCREENSHOT_TILED_ENABLED": False, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + with pytest.raises(PlaywrightTimeout) as exc_info: + driver.get_screenshot( + "http://example.com", "test-element", mock_user + ) + + assert exc_info.value is timeout + mock_logger.warning.assert_any_call( + "Timed out waiting for chart containers to draw at url %s " + "(%s of %s chart containers rendered before the timeout)", + "http://example.com", + 1, + 2, + exc_info=True, + ) + mock_logger.exception.assert_not_called() @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) @patch("superset.utils.webdriver._browser_manager") From 1bfbce3cfd65615124ae0c1b6e3efeec3767f436 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 15:04:30 -0700 Subject: [PATCH 26/67] fix(alerts-reports): catch CroniterBadDateError in report frequency validation (#42650) --- superset/commands/report/base.py | 26 +++++++++++------- superset/commands/report/exceptions.py | 17 ++++++++++++ tests/unit_tests/commands/report/base_test.py | 27 ++++++++++++++++++- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py index 353d9fea139..b69c36513c9 100644 --- a/superset/commands/report/base.py +++ b/superset/commands/report/base.py @@ -17,7 +17,7 @@ import logging from typing import Any, Optional -from croniter import croniter +from croniter import croniter, CroniterBadDateError from flask import current_app as app from flask_babel import gettext as _ from marshmallow import ValidationError @@ -29,6 +29,7 @@ from superset.commands.report.exceptions import ( ChartNotSavedValidationError, DashboardNotFoundValidationError, DashboardNotSavedValidationError, + ReportScheduleCrontabNotValidError, ReportScheduleEitherChartOrDashboardError, ReportScheduleForbiddenError, ReportScheduleFrequencyNotAllowed, @@ -288,13 +289,18 @@ class BaseReportScheduleCommand(BaseCommand): return iterations = 60 if minimum_interval <= 3660 else 24 - schedule = croniter(cron_schedule) - current_exec = next(schedule) + try: + schedule = croniter(cron_schedule) + current_exec = next(schedule) - for _i in range(iterations): - next_exec = next(schedule) - diff, current_exec = next_exec - current_exec, next_exec - if int(diff) < minimum_interval: - raise ReportScheduleFrequencyNotAllowed( - report_type=report_type, minimum_interval=minimum_interval - ) + for _i in range(iterations): + next_exec = next(schedule) + diff, current_exec = next_exec - current_exec, next_exec + if int(diff) < minimum_interval: + raise ReportScheduleFrequencyNotAllowed( + report_type=report_type, minimum_interval=minimum_interval + ) + except CroniterBadDateError as ex: + raise ReportScheduleCrontabNotValidError( + cron_schedule=cron_schedule + ) from ex diff --git a/superset/commands/report/exceptions.py b/superset/commands/report/exceptions.py index a2370196eb1..a248d494e0f 100644 --- a/superset/commands/report/exceptions.py +++ b/superset/commands/report/exceptions.py @@ -133,6 +133,23 @@ class ReportScheduleFrequencyNotAllowed(ValidationError): # noqa: N818 ) +class ReportScheduleCrontabNotValidError(ValidationError): # noqa: N818 + """ + Marshmallow validation error for a crontab that is syntactically valid + but never matches a real calendar date (e.g. February 30th) + """ + + def __init__(self, cron_schedule: str = "") -> None: + super().__init__( + _( + "Invalid crontab schedule: %(cron_schedule)s never matches" + " a valid date", + cron_schedule=cron_schedule, + ), + field_name="crontab", + ) + + class ChartNotSavedValidationError(ValidationError): """ Marshmallow validation error for charts that haven't been saved yet diff --git a/tests/unit_tests/commands/report/base_test.py b/tests/unit_tests/commands/report/base_test.py index ec8c6973406..93055e38a32 100644 --- a/tests/unit_tests/commands/report/base_test.py +++ b/tests/unit_tests/commands/report/base_test.py @@ -26,7 +26,10 @@ from unittest.mock import patch import pytest from superset.commands.report.base import BaseReportScheduleCommand -from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed +from superset.commands.report.exceptions import ( + ReportScheduleCrontabNotValidError, + ReportScheduleFrequencyNotAllowed, +) from superset.reports.models import ReportScheduleType REPORT_TYPES = { @@ -174,6 +177,28 @@ def test_validate_report_frequency_report_only(schedule: str) -> None: ) +@pytest.mark.parametrize("report_type", REPORT_TYPES) +@app_custom_config( + alert_minimum_interval=int(timedelta(minutes=5).total_seconds()), + report_minimum_interval=int(timedelta(minutes=5).total_seconds()), +) +def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None: + """ + Test the ``validate_report_frequency`` method with a crontab that is + syntactically valid but never matches a real calendar date (Feb 30th). + + Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus + marshmallow schema validation, but raise ``CroniterBadDateError`` when + iterated. This should surface as a ``ValidationError`` rather than + propagating the raw croniter exception. + """ + with pytest.raises(ReportScheduleCrontabNotValidError): + BaseReportScheduleCommand().validate_report_frequency( + "0 0 30 2 *", + report_type, + ) + + @pytest.mark.parametrize("report_type", REPORT_TYPES) @pytest.mark.parametrize("schedule", TEST_SCHEDULES) @app_custom_config( From b452c1634dcacddf01466625a608a0bf7260ca7d Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 15:05:32 -0700 Subject: [PATCH 27/67] fix(a11y): add aria-label to dashboard IconButton and unlabeled call sites (#41470) Co-authored-by: Claude Sonnet 4.6 --- .../components/DeleteComponentButton.test.tsx | 38 +++++++++++++++++++ .../components/DeleteComponentButton.tsx | 3 ++ .../dashboard/components/IconButton.test.tsx | 14 +++++++ .../src/dashboard/components/IconButton.tsx | 5 ++- .../gridComponents/Column/Column.test.tsx | 11 +++++- .../gridComponents/Column/Column.tsx | 2 + .../gridComponents/Header/Header.test.tsx | 4 +- .../gridComponents/Row/Row.test.tsx | 9 +++++ .../components/gridComponents/Row/Row.tsx | 2 + 9 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx diff --git a/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx b/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx new file mode 100644 index 00000000000..519e31cdba7 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen, fireEvent } from 'spec/helpers/testing-library'; +import DeleteComponentButton from './DeleteComponentButton'; + +test('exposes an accessible name without rendering visible label text', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Delete component' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Delete component')).not.toBeInTheDocument(); +}); + +test('calls onDelete when clicked', () => { + const onDelete = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete component' })); + + expect(onDelete).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx index 37c24a180f3..65a440b10d3 100644 --- a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx +++ b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx @@ -18,6 +18,7 @@ */ import { MouseEventHandler, FC } from 'react'; +import { t } from '@apache-superset/core/translation'; import { Icons } from '@superset-ui/core/components/Icons'; import type { IconType } from '@superset-ui/core/components/Icons/types'; import IconButton from './IconButton'; @@ -33,6 +34,8 @@ const DeleteComponentButton: FC = ({ }) => ( } /> ); diff --git a/superset-frontend/src/dashboard/components/IconButton.test.tsx b/superset-frontend/src/dashboard/components/IconButton.test.tsx index 289cafd4a74..a021e63a275 100644 --- a/superset-frontend/src/dashboard/components/IconButton.test.tsx +++ b/superset-frontend/src/dashboard/components/IconButton.test.tsx @@ -73,3 +73,17 @@ test('renders the provided label', () => { expect(screen.getByText('My Label')).toBeInTheDocument(); }); + +test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => { + render( + , + ); + + expect(screen.queryByText('My Label')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/dashboard/components/IconButton.tsx b/superset-frontend/src/dashboard/components/IconButton.tsx index 3e48730cff1..8e5b842508d 100644 --- a/superset-frontend/src/dashboard/components/IconButton.tsx +++ b/superset-frontend/src/dashboard/components/IconButton.tsx @@ -22,6 +22,7 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme'; interface IconButtonProps extends HTMLAttributes { icon: JSX.Element; label?: string; + hideVisibleLabel?: boolean; onClick: MouseEventHandler; disabled?: boolean; 'data-test'?: string; @@ -63,6 +64,7 @@ const IconButton = forwardRef( { icon, label, + hideVisibleLabel, onClick, onKeyDown, disabled, @@ -75,6 +77,7 @@ const IconButton = forwardRef( {...rest} ref={ref} type="button" + aria-label={label} isDisabled={disabled} aria-disabled={disabled} data-test={dataTest} @@ -91,7 +94,7 @@ const IconButton = forwardRef( }} > {icon} - {label && {label}} + {label && !hideVisibleLabel && {label}} ), ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx index c402942fe63..278548d213c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { fireEvent, render } from 'spec/helpers/testing-library'; +import { fireEvent, render, screen } from 'spec/helpers/testing-library'; import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown'; import IconButton from 'src/dashboard/components/IconButton'; @@ -200,6 +200,15 @@ test('should call deleteComponent when deleted', () => { expect(deleteComponent).toHaveBeenCalledTimes(1); }); +test('settings IconButton exposes an accessible name without visible label text', () => { + setup({ component: columnWithoutChildren, editMode: true }); + + expect( + screen.getByRole('button', { name: 'Column settings' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Column settings')).not.toBeInTheDocument(); +}); + test('should pass its own width as availableColumnCount to children', () => { const { getByTestId } = setup(); expect(getByTestId('mock-dashboard-component')).toHaveTextContent( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx index 72be4b59a67..edb36d1da85 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx @@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => { /> handleChangeFocus(true)} + label={t('Column settings')} + hideVisibleLabel icon={} /> diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx index 5c13f038f03..f51d74d14c0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx @@ -145,7 +145,9 @@ describe('Header', () => { const deleteComponent = jest.fn(); setup({ editMode: true, deleteComponent }); - const trashButton = screen.getByRole('button', { name: 'delete' }); + const trashButton = screen.getByRole('button', { + name: 'Delete component', + }); fireEvent.click(trashButton); expect(deleteComponent).toHaveBeenCalledTimes(1); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx index 69635012d20..39b2b718ec6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx @@ -240,6 +240,15 @@ test('should call deleteComponent when deleted', () => { expect(deleteComponent).toHaveBeenCalledTimes(1); }); +test('settings IconButton exposes an accessible name without visible label text', () => { + setup({ component: rowWithoutChildren, editMode: true }); + + expect( + screen.getByRole('button', { name: 'Row settings' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Row settings')).not.toBeInTheDocument(); +}); + test('should pass appropriate availableColumnCount to children', () => { const { getByTestId } = setup(); expect(getByTestId('mock-dashboard-component')).toHaveTextContent( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx index c5ec0c497f5..b644a65fef8 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx @@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => { handleChangeFocus(true)} + label={t('Row settings')} + hideVisibleLabel icon={} /> From f6c574edd85014bd4ebf77ec6c68fe75b745d14f Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 15:13:59 -0700 Subject: [PATCH 28/67] fix(screenshots): validate cached screenshot image bytes on read and write (#42120) Co-authored-by: Claude --- superset/utils/screenshots.py | 58 +++++++- tests/unit_tests/utils/screenshot_test.py | 10 +- .../utils/test_screenshot_cache_fix.py | 126 +++++++++++++++++- 3 files changed, 179 insertions(+), 15 deletions(-) diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index 750ec54b597..32b7f2bda23 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -85,6 +85,26 @@ class ScreenshotCachePayloadType(TypedDict): status: str +# Magic bytes for a cheap image sanity check. This is intentionally not a full +# decode: it's meant to catch 0-byte/corrupt/blank payloads before they're +# cached or served, not to validate the image is renderable. +PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n" +JPEG_MAGIC_BYTES = b"\xff\xd8\xff" + + +def validate_screenshot_image(image: bytes | None) -> str | None: + """Cheaply validate screenshot bytes before they're cached or served. + + :return: None if the bytes look like a usable image, otherwise a short + reason ("empty" or "undecodable") suitable for logging. + """ + if not image: + return "empty" + if not image.startswith((PNG_MAGIC_BYTES, JPEG_MAGIC_BYTES)): + return "undecodable" + return None + + class ScreenshotCachePayload: def __init__( self, @@ -147,6 +167,13 @@ class ScreenshotCachePayload: def get_status(self) -> str: return self.status.value + def get_invalid_image_reason(self) -> str | None: + """Reason this payload's image should not be served/cached, or None if + it passes validation (or it isn't claiming a successful screenshot).""" + if self.status != StatusValues.UPDATED: + return None + return validate_screenshot_image(self._image) + def is_error_cache_ttl_expired(self) -> bool: error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"] return ( @@ -263,6 +290,14 @@ class BaseScreenshot: elif isinstance(payload, dict): payload = cast(ScreenshotCachePayloadType, payload) payload = ScreenshotCachePayload.from_dict(payload) + if invalid_reason := payload.get_invalid_image_reason(): + logger.warning( + "Rejecting cached screenshot for %s: %s image payload; " + "treating as a cache miss", + cache_key, + invalid_reason, + ) + return None return payload logger.info("Failed at getting from cache: %s", cache_key) return None @@ -331,15 +366,28 @@ class BaseScreenshot: image = None # Cache the result (success or error) to avoid immediate retries - if image: + invalid_reason = validate_screenshot_image(image) + # `image and` is redundant at runtime (validate_screenshot_image + # only returns None for truthy, well-formed bytes) but mypy can't + # infer that image is non-None from invalid_reason being None + # across the function-call boundary, so it's kept for narrowing. + if image and invalid_reason is None: with event_logger.log_context( f"screenshot.cache.{self.thumbnail_type}" ): cache_payload.update(image) - elif cache_payload.status != StatusValues.ERROR: - # Only call error() if not already set — avoids overwriting - # the timestamp recorded when the actual failure occurred above. - cache_payload.error() + else: + if invalid_reason: + logger.warning( + "Not caching screenshot result for %s: %s image payload", + cache_key, + invalid_reason, + ) + if cache_payload.status != StatusValues.ERROR: + # Only call error() if not already set — avoids overwriting + # the timestamp recorded when the actual failure occurred + # above. + cache_payload.error() logger.info("Caching thumbnail: %s", cache_key) self.cache.set(cache_key, cache_payload.to_dict()) diff --git a/tests/unit_tests/utils/screenshot_test.py b/tests/unit_tests/utils/screenshot_test.py index b7f7de6032e..c57df50484c 100644 --- a/tests/unit_tests/utils/screenshot_test.py +++ b/tests/unit_tests/utils/screenshot_test.py @@ -33,6 +33,10 @@ from superset.utils.screenshots import ( BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot" +# A minimal valid PNG header, used wherever a test needs bytes that pass +# ScreenshotCachePayload's image validation. +FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body" + class MockCache: """A class to manage screenshot cache.""" @@ -92,7 +96,7 @@ def test_get_cache_key(app_context, screenshot_obj): def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj): """get_from_cache_key should always return a ScreenshotCachePayload Object""" # backwards compatibility test for retrieving plain bytes - fake_bytes = b"fake_screenshot_data" + fake_bytes = FAKE_PNG_BYTES BaseScreenshot.cache = MockCache() BaseScreenshot.cache.set("key", fake_bytes) cache_payload = screenshot_obj.get_from_cache_key("key") @@ -108,10 +112,10 @@ class TestComputeAndCache: BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None ) get_screenshot = mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) resize_image = mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) BaseScreenshot.cache = MockCache() return { diff --git a/tests/unit_tests/utils/test_screenshot_cache_fix.py b/tests/unit_tests/utils/test_screenshot_cache_fix.py index 4ffe07711c8..133c8c7e4d8 100644 --- a/tests/unit_tests/utils/test_screenshot_cache_fix.py +++ b/tests/unit_tests/utils/test_screenshot_cache_fix.py @@ -37,6 +37,10 @@ from superset.utils.screenshots import ( BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot" DISTRIBUTED_LOCK_PATH = "superset.utils.screenshots.DistributedLock" +# A minimal valid PNG header, used wherever a test needs bytes that pass +# ScreenshotCachePayload's image validation. +FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body" + class MockCache: """A class to manage screenshot cache for testing.""" @@ -83,11 +87,11 @@ class TestCacheOnlyOnSuccess: mocker.patch(DISTRIBUTED_LOCK_PATH) mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) get_screenshot = mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) # Mock resize_image to avoid PIL errors with fake image data mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) BaseScreenshot.cache = MockCache() return get_screenshot @@ -161,13 +165,15 @@ class TestCacheOnlyOnSuccess: screenshot_obj: BaseScreenshot, mock_user: MagicMock, ) -> None: - """Empty bytes from get_screenshot must set ERROR, not leave COMPUTING.""" + """Empty bytes from get_screenshot must set ERROR, not leave COMPUTING, + and must log a WARNING that includes the cache key.""" mocker.patch(DISTRIBUTED_LOCK_PATH) mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) mocker.patch( BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"", ) + mock_logger = mocker.patch("superset.utils.screenshots.logger") BaseScreenshot.cache = MockCache() screenshot_obj.compute_and_cache(user=mock_user, force=True) @@ -177,6 +183,43 @@ class TestCacheOnlyOnSuccess: assert cached_value is not None assert cached_value["status"] == "Error" assert cached_value.get("image") is None + assert any( + cache_key in call.args and "empty" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_cache_error_status_when_screenshot_returns_garbage_bytes( + self, + mocker: MockerFixture, + screenshot_obj: BaseScreenshot, + mock_user: MagicMock, + ) -> None: + """Non-empty bytes without a valid image header must set ERROR, not be + cached as a success, and must log a WARNING that includes the cache key.""" + mocker.patch(DISTRIBUTED_LOCK_PATH) + mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) + mocker.patch( + BASE_SCREENSHOT_PATH + ".get_screenshot", + return_value=b"this-is-not-a-real-image", + ) + mocker.patch( + BASE_SCREENSHOT_PATH + ".resize_image", + return_value=b"this-is-not-a-real-image", + ) + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + + screenshot_obj.compute_and_cache(user=mock_user, force=True) + + cache_key = screenshot_obj.get_cache_key() + cached_value = BaseScreenshot.cache.get(cache_key) + assert cached_value is not None + assert cached_value["status"] == "Error" + assert cached_value.get("image") is None + assert any( + cache_key in call.args and "undecodable" in call.args + for call in mock_logger.warning.call_args_list + ) def test_computing_status_written_to_cache_early( self, @@ -197,14 +240,14 @@ class TestCacheOnlyOnSuccess: "Cache should be set to COMPUTING before screenshot starts" ) assert cached_value["status"] == "Computing" - return b"image_data" + return FAKE_PNG_BYTES mocker.patch( BASE_SCREENSHOT_PATH + ".get_screenshot", side_effect=check_cache_during_screenshot, ) mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) screenshot_obj.compute_and_cache(user=mock_user, force=True) @@ -429,11 +472,11 @@ class TestIntegrationCacheBugFix: BaseScreenshot.cache.set(cache_key, stale_payload.to_dict()) mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) # Mock resize to avoid PIL errors mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) # Should trigger task because COMPUTING is stale @@ -482,3 +525,72 @@ class TestIntegrationCacheBugFix: assert payload._image == old_image assert payload.status == StatusValues.COMPUTING + + +class TestReadSideImageValidation: + """A cached payload that claims a successful screenshot (status UPDATED) + but carries invalid image bytes must be served as a cache miss, not + returned to the caller — this is what the dashboard/chart screenshot + endpoints call to fetch bytes to serve.""" + + def test_zero_byte_image_is_treated_as_cache_miss( + self, mocker: MockerFixture, screenshot_obj: BaseScreenshot + ) -> None: + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + stale_payload = ScreenshotCachePayload(image=b"", status=StatusValues.UPDATED) + BaseScreenshot.cache.set(cache_key, stale_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is None + assert any( + cache_key in call.args and "empty" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_garbage_bytes_image_is_treated_as_cache_miss( + self, mocker: MockerFixture, screenshot_obj: BaseScreenshot + ) -> None: + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + garbage_payload = ScreenshotCachePayload(image=b"not-an-image-at-all") + BaseScreenshot.cache.set(cache_key, garbage_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is None + assert any( + cache_key in call.args and "undecodable" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_valid_image_is_served_normally( + self, screenshot_obj: BaseScreenshot + ) -> None: + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + valid_payload = ScreenshotCachePayload(image=FAKE_PNG_BYTES) + BaseScreenshot.cache.set(cache_key, valid_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is not None + assert result.get_image().read() == FAKE_PNG_BYTES + + def test_pending_status_with_no_image_is_not_rejected( + self, screenshot_obj: BaseScreenshot + ) -> None: + """Non-UPDATED statuses (e.g. PENDING/COMPUTING) aren't claiming a + successful screenshot, so they should be returned as-is.""" + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + pending_payload = ScreenshotCachePayload(status=StatusValues.PENDING) + BaseScreenshot.cache.set(cache_key, pending_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is not None + assert result.status == StatusValues.PENDING From 6929d032b873d598298c3dae752c9fcdb5ca1c80 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 31 Jul 2026 15:37:46 -0700 Subject: [PATCH 29/67] fix(reports): time-budget tiled screenshot to fail cleanly instead of hitting Celery kill (#42118) Co-authored-by: Claude --- superset/utils/screenshot_utils.py | 144 +++++++- superset/utils/webdriver.py | 5 +- .../unit_tests/utils/test_screenshot_utils.py | 324 +++++++++++++++++- 3 files changed, 455 insertions(+), 18 deletions(-) diff --git a/superset/utils/screenshot_utils.py b/superset/utils/screenshot_utils.py index 868b8e731d5..82a06fd66c0 100644 --- a/superset/utils/screenshot_utils.py +++ b/superset/utils/screenshot_utils.py @@ -87,6 +87,29 @@ def resolve_screenshot_task_budget_seconds( return None +# Fallback wall-clock budget, in seconds, for the entire tiled-screenshot +# operation (element lookup plus all per-tile readiness/animation waits +# combined), used when resolve_screenshot_task_budget_seconds() returns None +# (no Celery task context -- e.g. synchronous thumbnail generation -- or no +# usable task limit). The non-tiled readiness path treats None as "keep the +# configured SCREENSHOT_LOAD_WAIT" because it makes exactly one bounded wait; +# the tiled path cannot, because its per-tile waits accumulate: with N tiles, +# an uncapped load_wait allows N * load_wait of total wall-clock time, so the +# operation still needs one fixed total ceiling. Sized against the longest +# Celery hard task_time_limit observed in production for report execution +# (1740s), minus the same 300s cleanup margin the runtime derivation reserves +# for combining tiles, building the PDF, and delivering the notification. +TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS = 1440 # 1740s limit - 300s margin + + +class ScreenshotTaskBudgetExceededError(RuntimeError): + """Raised when no safe task budget remains before screenshot capture.""" + + +class TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError): + """Raised when the tiled-screenshot time budget runs out mid-capture.""" + + try: from playwright.sync_api import TimeoutError as PlaywrightTimeout except ImportError: @@ -251,7 +274,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes: return screenshot_tiles[0] -def take_tiled_screenshot( +def take_tiled_screenshot( # noqa: C901 page: "Page", element_name: str, tile_height: int, @@ -274,6 +297,12 @@ def take_tiled_screenshot( Returns: Combined screenshot bytes or None if failed + + Raises: + TiledScreenshotBudgetExceededError: If the total time budget for the + tiled-screenshot operation runs out before every tile has been + verifiably captured. Callers must treat this as a hard failure + rather than fall back to an unchecked/partial screenshot. """ context_suffix = f" [{log_context}]" if log_context else "" # Set right before re-raising the per-tile readiness timeout below, and @@ -286,6 +315,15 @@ def take_tiled_screenshot( # match `except PlaywrightTimeout` and incorrectly propagate instead of # degrading to `None` like every other unexpected error in this function. readiness_timeout = False + # Cap the whole tiled operation against the running Celery task's own + # time limit, using the same runtime derivation as the non-tiled + # readiness wait (#42253/#42427). Unlike that path, a None budget does + # not mean "keep the configured timeout": per-tile waits accumulate, so + # the operation falls back to a fixed total ceiling instead. + wait_budget_seconds = resolve_screenshot_task_budget_seconds(log_context) + if wait_budget_seconds is None: + wait_budget_seconds = float(TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS) + start_time = time.monotonic() try: # Get the target element element = page.locator(f".{element_name}") @@ -320,9 +358,44 @@ def take_tiled_screenshot( num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height) logger.info("Taking %s screenshot tiles", num_tiles) - screenshot_tiles = [] + screenshot_tiles: list[bytes] = [] + + def _raise_if_budget_exhausted(elapsed: float, remaining_budget: float) -> None: + if remaining_budget > 0: + return + # A customer-side chart-loading issue (a slow/hung dashboard), + # not a Superset system fault, so this is a WARNING rather + # than an ERROR -- consistent with #38130/#38441, which + # deliberately downgraded screenshot timeout logs the same way. + logger.warning( + "Tiled screenshot time budget exhausted on tile %s/%s: " + "%s/%s tiles captured so far, %.1fs elapsed of a %.1fs " + "budget. Aborting instead of capturing remaining tiles " + "unchecked.%s", + i + 1, + num_tiles, + len(screenshot_tiles), + num_tiles, + elapsed, + wait_budget_seconds, + context_suffix, + ) + raise TiledScreenshotBudgetExceededError( + f"Tiled screenshot budget of " + f"{wait_budget_seconds:.1f}s exhausted " + f"after {len(screenshot_tiles)}/{num_tiles} tiles" + ) for i in range(num_tiles): + # Check the time budget before starting this tile's readiness wait. + # If it's already exhausted, we can no longer verify this (or any + # later) tile is actually ready to capture -- fail loudly instead + # of silently snapshotting a spinner or blank chart, or running + # past the Celery task time limit and getting SIGKILLed. + elapsed = time.monotonic() - start_time + remaining_budget = wait_budget_seconds - elapsed + _raise_if_budget_exhausted(elapsed, remaining_budget) + # Calculate scroll position to show this tile's content scroll_y = dashboard_top + (i * tile_height) @@ -332,17 +405,31 @@ def take_tiled_screenshot( ) # Wait for scroll to settle and content to load page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS) + + # Recompute the remaining budget after the scroll-settle sleep -- + # which itself consumes real wall-clock time -- rather than + # reusing the value from before it, so the readiness-check + # timeout below is capped against a fresh number instead of a + # stale one that would let each tile overrun the budget by up + # to one settle interval. + tile_wait_start = time.monotonic() + elapsed = tile_wait_start - start_time + remaining_budget = wait_budget_seconds - elapsed + _raise_if_budget_exhausted(elapsed, remaining_budget) + # Wait for every chart holder visible in the current viewport to reach - # a terminal state (rendered chart or error/empty state). Only check + # a terminal state (rendered chart or error/empty state), capped at + # whatever remains of the total time budget so a slow dashboard + # degrades gracefully instead of exceeding it. Only check # viewport-visible chart holders to avoid blocking on virtualization # placeholders rendered for off-screen charts. A holder that hasn't # mounted anything yet does not satisfy this check -- unlike checking # for the absence of `.loading`, which passes vacuously in that case. - tile_wait_start = time.monotonic() + tile_load_wait = min(load_wait, remaining_budget) try: page.wait_for_function( CHART_HOLDERS_READY_JS, - timeout=load_wait * 1000, + timeout=tile_load_wait * 1000, ) except PlaywrightTimeout: elapsed = time.monotonic() - tile_wait_start @@ -354,14 +441,21 @@ def take_tiled_screenshot( # made the same call for the other screenshot timeout paths. logger.warning( "Timed out after %.2fs waiting for %s chart container(s) to " - "become ready on tile %s/%s (load_wait=%ss)%s; unready chart " - "holders (chart id, state): %s. Aborting tiled screenshot " - "rather than capturing a blank or partially-loaded tile.", + "become ready on tile %s/%s (waited %.1fs of a %ss requested " + "load_wait; %.1fs elapsed of a %.1fs total budget; %s/%s " + "tiles captured so far)%s; unready chart holders (chart id, " + "state): %s. Aborting tiled screenshot rather than capturing " + "a blank or partially-loaded tile.", elapsed, len(unready_chart_holders), i + 1, num_tiles, + tile_load_wait, load_wait, + time.monotonic() - start_time, + wait_budget_seconds, + len(screenshot_tiles), + num_tiles, context_suffix, unready_chart_holders, ) @@ -377,12 +471,36 @@ def take_tiled_screenshot( load_wait, context_suffix, ) + readiness_wait_elapsed = time.monotonic() - tile_wait_start # Wait for chart animations (e.g. ECharts) to finish after spinner clears. # The global animation wait before tiling only covers the first tile; - # subsequent tiles need their own wait after data loads. + # subsequent tiles need their own wait after data loads. Capped at + # whatever remains of the budget; unlike the readiness wait above this + # is cosmetic settling, not a readiness check, so we simply skip it + # (rather than raise) once the budget runs out. + animation_wait_elapsed = 0.0 if animation_wait > 0: - page.wait_for_timeout(animation_wait * 1000) + elapsed = time.monotonic() - start_time + remaining_budget = wait_budget_seconds - elapsed + tile_animation_wait = max(0, min(animation_wait, remaining_budget)) + if tile_animation_wait > 0: + animation_wait_start = time.monotonic() + page.wait_for_timeout(tile_animation_wait * 1000) + animation_wait_elapsed = time.monotonic() - animation_wait_start + + # Per-tile timing breakdown so slow dashboards can be profiled from + # logs alone. DEBUG rather than INFO: this fires once per tile, and + # large dashboards can have dozens of tiles per report run. + logger.debug( + "Tile %s/%s timing: %.2fs waiting for chart readiness, " + "%.2fs waiting for animations.%s", + i + 1, + num_tiles, + readiness_wait_elapsed, + animation_wait_elapsed, + context_suffix, + ) # Calculate what portion of the element we want to capture for this tile tile_start_in_element = i * tile_height @@ -431,6 +549,12 @@ def take_tiled_screenshot( return combined_screenshot + except TiledScreenshotBudgetExceededError: + # Budget exhaustion must fail cleanly, not be swallowed into the + # generic `return None` degradation below -- the raise carries the + # budget diagnostics to the caller, which fails the capture loudly + # (#42273) instead of receiving an anonymous empty result. + raise except Exception as e: if readiness_timeout: # Let the per-tile readiness timeout propagate so the caller diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index e968711ad41..4b8c1837275 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -47,6 +47,7 @@ from superset.utils.screenshot_utils import ( CHART_HOLDERS_READY_JS, FIND_CHART_HOLDER_STATES_JS, resolve_screenshot_task_budget_seconds, + ScreenshotTaskBudgetExceededError, take_tiled_screenshot, ) @@ -61,10 +62,6 @@ PLAYWRIGHT_INSTALL_MESSAGE = ( ) -class ScreenshotTaskBudgetExceededError(RuntimeError): - """Raised when no safe task budget remains before screenshot capture.""" - - if TYPE_CHECKING: from typing import Any diff --git a/tests/unit_tests/utils/test_screenshot_utils.py b/tests/unit_tests/utils/test_screenshot_utils.py index 43829001596..f5569e8a741 100644 --- a/tests/unit_tests/utils/test_screenshot_utils.py +++ b/tests/unit_tests/utils/test_screenshot_utils.py @@ -25,8 +25,11 @@ from superset.utils.screenshot_utils import ( combine_screenshot_tiles, resolve_screenshot_task_budget_seconds, SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS, + ScreenshotTaskBudgetExceededError, SCROLL_SETTLE_TIMEOUT_MS, take_tiled_screenshot, + TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS, + TiledScreenshotBudgetExceededError, ) @@ -453,12 +456,17 @@ class TestTakeTiledScreenshot: assert warning_args[2] == 1 # count of unready chart containers assert warning_args[3] == 1 # tile index assert warning_args[4] == 3 # total tiles - assert warning_args[5] == 30 # load_wait - assert warning_args[6] == "" # no log_context passed + assert warning_args[5] == 30 # tile_load_wait (uncapped: budget remains) + assert warning_args[6] == 30 # requested load_wait + assert isinstance(warning_args[7], float) # total elapsed vs budget + assert warning_args[8] == 1440 # total budget (fixed fallback) + assert warning_args[9] == 0 # tiles captured so far + assert warning_args[10] == 3 # total tiles + assert warning_args[11] == "" # no log_context passed # Diagnostic payload identifies chart id AND the state it's stuck in # (spinner mounted vs nothing mounted vs waiting-on-database) so a # slow query can be told apart from the virtualization race. - assert warning_args[7] == [{"chartId": "42", "state": "waiting_on_database"}] + assert warning_args[12] == [{"chartId": "42", "state": "waiting_on_database"}] def test_timeout_warning_includes_log_context(self, mock_page): """The log context (e.g. report execution id) is threaded through for @@ -484,7 +492,7 @@ class TestTakeTiledScreenshot: ) warning_args = mock_logger.warning.call_args[0] - assert warning_args[6] == " [execution_id=abc-123]" + assert warning_args[11] == " [execution_id=abc-123]" def test_chart_holder_with_nothing_mounted_blocks_wait(self, mock_page): """Regression test for the vacuous-pass race (PR #39895). @@ -646,3 +654,311 @@ class TestTakeTiledScreenshot: sig = inspect.signature(take_tiled_screenshot) assert sig.parameters["animation_wait"].default == 0 + + +class TestTileWaitBudget: + """The tiled operation's cumulative per-tile waits are capped by one + wall-clock budget derived from the running Celery task's own time limit + (resolve_screenshot_task_budget_seconds), falling back to a fixed total + ceiling outside Celery because per-tile waits accumulate.""" + + @pytest.fixture + def mock_page(self): + """Create a mock Playwright page object for a 3-tile (5000px) dashboard.""" + page = MagicMock() + element = MagicMock() + page.locator.return_value = element + page.evaluate.return_value = { + "height": 5000, + "top": 100, + "left": 50, + "width": 800, + } + page.screenshot.return_value = b"fake_screenshot_data" + return page + + class _FakeClock: + """Stateful monotonic() stand-in the test advances explicitly. + + Robust to how many times the code under test samples the clock per + tile (budget check, per-tile wait timing, animation budget) -- only + explicit advances move time forward. + """ + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + def test_budget_error_is_task_budget_error_subclass(self): + """Callers can catch the whole budget-error family with the base + ScreenshotTaskBudgetExceededError type.""" + assert issubclass( + TiledScreenshotBudgetExceededError, ScreenshotTaskBudgetExceededError + ) + + def test_per_tile_wait_shrinks_as_budget_depletes(self, mock_page, monkeypatch): + """Each tile's readiness-wait timeout is capped at the remaining budget.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Simulate slow tiles: the readiness wait itself consumes wall time, + # so each subsequent tile sees less remaining budget. + wait_durations = iter([950, 40, 5]) + + def slow_wait(*args, **kwargs): + clock.now += next(wait_durations) + + mock_page.wait_for_function.side_effect = slow_wait + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + result = take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + assert result is not None + timeouts = [ + call[1]["timeout"] for call in mock_page.wait_for_function.call_args_list + ] + # remaining budget at each tile's wait: 1000, 50, 10 seconds + # -> capped timeouts shrink + assert timeouts == [100 * 1000, 50 * 1000, 10 * 1000] + assert timeouts == sorted(timeouts, reverse=True) + + def test_readiness_wait_uses_budget_recomputed_after_scroll_settle( + self, mock_page, monkeypatch + ): + """The readiness-wait timeout must be capped using the budget + recomputed *after* the scroll-settle sleep, not the stale value from + before it -- otherwise each tile could overrun the total budget by up + to one settle interval.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + # A single-tile dashboard to keep the scenario simple. + mock_page.evaluate.return_value = { + "height": 1000, + "top": 100, + "left": 50, + "width": 800, + } + clock = self._FakeClock() + # The scroll-settle sleep itself consumes 950s of wall-clock time, + # leaving only 50s of the 1000s budget by the time the readiness + # wait is capped. + mock_page.wait_for_timeout.side_effect = lambda *args, **kwargs: setattr( + clock, "now", clock.now + 950 + ) + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=999 + ) + + timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + # Must reflect the post-settle remaining budget (50s), not the + # stale pre-settle value (1000s, which would have let load_wait's + # full 999s through uncapped). + assert timeout == 50 * 1000 + + def test_budget_exhausted_raises_and_stops_capturing(self, mock_page, monkeypatch): + """Exhausting the budget aborts cleanly instead of capturing unchecked.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Tile 0's readiness wait consumes the whole budget; tile 1's budget + # check then sees remaining <= 0 and raises before capturing. + mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr( + clock, "now", 1000.0 + ) + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch( + "superset.utils.screenshot_utils.combine_screenshot_tiles" + ) as mock_combine: + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + # Only the first tile was captured before the budget ran out. + assert mock_page.screenshot.call_count == 1 + # Tiles were never combined -- the function raised before that point. + mock_combine.assert_not_called() + + # Budget exhaustion is a customer chart-loading issue, not a Superset + # system fault, so it must log at WARNING (not ERROR) -- consistent + # with the #38130/#38441 precedent for screenshot timeout logging. + assert mock_logger.error.call_count == 0 + mock_logger.warning.assert_called_once() + warning_args = mock_logger.warning.call_args[0] + assert "budget exhausted" in warning_args[0] + # tile index, tiles total, tiles captured, tiles total, + # elapsed seconds, budget seconds, log-context suffix + assert warning_args[1] == 2 + assert warning_args[2] == 3 + assert warning_args[3] == 1 + assert warning_args[4] == 3 + assert warning_args[5] == 1000 + assert warning_args[6] == 1000 + assert warning_args[7] == "" + + def test_budget_exhausted_warning_includes_log_context( + self, mock_page, monkeypatch + ): + """log_context (e.g. report execution id) is appended to the warning.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Tile 0's readiness wait consumes the whole budget; tile 1's budget + # check then sees remaining <= 0 and raises. + mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr( + clock, "now", 1000.0 + ) + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=100, + log_context="execution_id=abc-123", + ) + + warning_args = mock_logger.warning.call_args[0] + assert warning_args[-1] == " [execution_id=abc-123]" + + def test_budget_exhausted_before_first_tile_raises_without_capture( + self, mock_page, monkeypatch + ): + """No budget floor: a budget already exhausted by setup (element + lookup/dimension probing) raises before the first tile is captured, + matching the non-tiled path's raise-before-capture semantics.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # The dashboard-dimension evaluate() itself consumes the whole budget. + original_return = {"height": 5000, "top": 100, "left": 50, "width": 800} + + def slow_evaluate(*args, **kwargs): + clock.now = 1000.0 + return original_return + + mock_page.evaluate.side_effect = slow_evaluate + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch( + "superset.utils.screenshot_utils.combine_screenshot_tiles" + ) as mock_combine: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + mock_page.screenshot.assert_not_called() + mock_combine.assert_not_called() + + def test_no_celery_context_uses_fixed_total_fallback(self, mock_page): + """Outside Celery the helper returns None; the tiled path must fall + back to the fixed total ceiling rather than running uncapped, because + per-tile waits accumulate across tiles.""" + clock = self._FakeClock() + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=10_000, # deliberately above the fallback + ) + + first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + assert first_timeout == TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS * 1000 + + def test_derived_task_budget_caps_tile_wait(self, mock_page): + """Inside Celery, the tiled path caps waits using the same + task-derived budget as the non-tiled path (helper reuse, #42427).""" + task = MagicMock() + task.request.timelimit = (120, None) # (hard, soft): 120s hard limit + + clock = self._FakeClock() + with patch("superset.utils.screenshot_utils.current_task", task): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=200 + ) + + # margin = min(300, 120 * 0.2) = 24; budget = 120 - 24 = 96 + first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + assert first_timeout == 96 * 1000 + assert first_timeout < 200 * 1000 + + def test_fast_dashboard_matches_default_behavior(self, mock_page): + """Well under budget, waits are not capped and behavior is unchanged.""" + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + result = take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=30, + animation_wait=5, + ) + + assert result is not None + assert mock_page.screenshot.call_count == 3 + + for call in mock_page.wait_for_function.call_args_list: + assert call[1]["timeout"] == 30 * 1000 + + animation_calls = [ + call + for call in mock_page.wait_for_timeout.call_args_list + if call[0][0] == 5 * 1000 + ] + assert len(animation_calls) == 3 + + def test_per_tile_timing_debug_line_logged(self, mock_page): + """Each tile logs a DEBUG timing breakdown (readiness wait, animation + wait) so slow dashboards can be profiled from logs alone.""" + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + log_context="cache_key=xyz", + ) + + timing_calls = [ + call for call in mock_logger.debug.call_args_list if "timing" in call[0][0] + ] + assert len(timing_calls) == 3 + for i, call in enumerate(timing_calls): + args = call[0] + assert args[1] == i + 1 # tile index + assert args[2] == 3 # total tiles + assert args[-1] == " [cache_key=xyz]" From 22c305f75888e5e90ecdd7c71d4223514db38a39 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 31 Jul 2026 20:41:42 -0400 Subject: [PATCH 30/67] fix(dataset): retry metadata after OAuth2 authorization (#42581) --- .../ErrorMessageWithStackTrace.tsx | 2 + .../OAuth2RedirectMessage.test.tsx | 64 ++++++- .../ErrorMessage/OAuth2RedirectMessage.tsx | 34 +++- .../src/components/ErrorMessage/types.ts | 1 + .../DatasetPanel/DatasetPanel.stories.tsx | 1 - .../DatasetPanel.subdirectory.test.tsx | 2 - .../DatasetPanel/DatasetPanel.test.tsx | 46 ++--- .../AddDataset/DatasetPanel/DatasetPanel.tsx | 50 +++-- .../DatasetPanel/DatasetPanelWrapper.test.tsx | 130 ++++++++++++- .../DatasetPanel/MessageContent.tsx | 16 +- .../AddDataset/DatasetPanel/index.tsx | 178 ++++++++++++------ .../DatasetLayout/DatasetLayout.test.tsx | 1 - 12 files changed, 388 insertions(+), 137 deletions(-) diff --git a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx index 890374012a5..ad8b15c730b 100644 --- a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx +++ b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx @@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({ fallback, compact, closable = true, + errorMitigationFunction, }: Props) { // Check if a custom error message component was registered for this message if (error) { @@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({ error={error} source={source} subtitle={subtitle} + errorMitigationFunction={errorMitigationFunction} /> ); } diff --git a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx index 37001745440..89726c8af9d 100644 --- a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx @@ -20,7 +20,7 @@ import * as reduxHooks from 'react-redux'; import { Provider } from 'react-redux'; import { createStore, Store } from 'redux'; -import { render, waitFor } from 'spec/helpers/testing-library'; +import { act, render, waitFor } from 'spec/helpers/testing-library'; import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core'; import { reRunQuery } from 'src/SqlLab/actions/sqlLab'; import { triggerQuery } from 'src/components/Chart/chartAction'; @@ -166,15 +166,55 @@ describe('OAuth2RedirectMessage Component', () => { render(setup()); simulateBroadcastMessage({ tabId: 'tabId' }); + simulateStorageMessage({ tabId: 'tabId' }); + + await waitFor(() => { + expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' }); + }); + expect(reRunQuery).toHaveBeenCalledTimes(1); + }); + + test('dispatches reRunQuery action when storage event has matching tab ID', async () => { + render(setup()); + + simulateStorageMessage({ tabId: 'tabId' }); await waitFor(() => { expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' }); }); }); - test('dispatches reRunQuery action when storage event has matching tab ID', async () => { - render(setup()); + test('waits for the SQL Lab query before consuming the completion', async () => { + const initialState = { + sqlLab: { + queries: {}, + queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }], + tabHistory: ['editor-id'], + }, + explore: { slice: null }, + charts: {}, + dashboardInfo: {}, + }; + const delayedQueryStore = createStore( + (state: typeof initialState = initialState, action) => + action.type === 'load-query' + ? { + ...state, + sqlLab: { + ...state.sqlLab, + queries: { 'query-id': { sql: 'SELECT * FROM table' } }, + }, + } + : state, + ); + render(setup({}, delayedQueryStore)); + simulateBroadcastMessage({ tabId: 'tabId' }); + expect(reRunQuery).not.toHaveBeenCalled(); + + act(() => { + delayedQueryStore.dispatch({ type: 'load-query' }); + }); simulateStorageMessage({ tabId: 'tabId' }); await waitFor(() => { @@ -234,4 +274,22 @@ describe('OAuth2RedirectMessage Component', () => { ]); }); }); + + test('runs scoped mitigation once instead of CRUD invalidation', async () => { + const errorMitigationFunction = jest.fn(); + render( + setup({ + source: 'crud' as ErrorSource, + errorMitigationFunction, + }), + ); + + simulateBroadcastMessage({ tabId: 'tabId' }); + simulateStorageMessage({ tabId: 'tabId' }); + + await waitFor(() => { + expect(errorMitigationFunction).toHaveBeenCalledTimes(1); + }); + expect(api.util.invalidateTags).not.toHaveBeenCalled(); + }); }); diff --git a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx index 3383d7f3a41..5466c2adfe7 100644 --- a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; @@ -58,15 +58,16 @@ interface OAuth2RedirectExtra { * * After the token has been stored, the opened tab will broadcast a message to the * original tab and close itself. This component, running on the original tab, listens - * on a same-origin BroadcastChannel and re-runs the query for the user once it - * receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs - * share a "tab ID" (a UUID generated by the backend) which is echoed back through the - * channel so the original tab only reacts to its own OAuth2 flow. + * for same-origin BroadcastChannel and storage notifications and re-runs the query + * for the user once it receives the success message — be it in SQL Lab, Explore, or + * a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is + * echoed back so the original tab only reacts to its own OAuth2 flow. */ export function OAuth2RedirectMessage({ error, source, closable, + errorMitigationFunction, }: ErrorMessageComponentProps) { const { extra, level } = error; @@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({ ); const dispatch = useDispatch(); + const lastHandledTabIdRef = useRef(); useEffect(() => { const handleOAuthComplete = (tabId?: string) => { - if (tabId !== extra.tab_id) { + if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) { return; } - if (source === 'sqllab' && query) { + + if (errorMitigationFunction) { + errorMitigationFunction(); + } else if (source === 'sqllab' && query) { dispatch(reRunQuery(query)); } else if (source === 'explore') { dispatch(triggerQuery(true, chartId)); @@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({ 'Tables', ]), ); + } else { + return; } + + lastHandledTabIdRef.current = tabId; }; const channel = @@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({ window.removeEventListener('storage', handleStorage); channel?.close(); }; - }, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]); + }, [ + source, + extra.tab_id, + dispatch, + query, + chartId, + chartList, + dashboardId, + errorMitigationFunction, + ]); const body = (

diff --git a/superset-frontend/src/components/ErrorMessage/types.ts b/superset-frontend/src/components/ErrorMessage/types.ts index c48b3dfae4c..fb9c6b1756b 100644 --- a/superset-frontend/src/components/ErrorMessage/types.ts +++ b/superset-frontend/src/components/ErrorMessage/types.ts @@ -27,6 +27,7 @@ export type ErrorMessageComponentProps | null> = subtitle?: ReactNode; compact?: boolean; closable?: boolean; + errorMitigationFunction?: () => void; }; export type ErrorMessageComponent = ComponentType; diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx index 6da60e561b9..581ce2195a1 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx @@ -35,6 +35,5 @@ export const Basic: StoryFn = args => ( Basic.args = { tableName: 'example_table', loading: false, - hasError: false, columnList: exampleColumns, }; diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx index cf5b9d816cd..04158c05755 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx @@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment', render( { test('renders a blank state DatasetPanel', () => { - render(, { + render(, { useRouter: true, }); @@ -70,17 +70,9 @@ describe('DatasetPanel', () => { }); test('renders a no columns screen', () => { - render( - , - { - useRouter: true, - }, - ); + render(, { + useRouter: true, + }); const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); expect(blankDatasetImg).toBeVisible(); @@ -91,17 +83,9 @@ describe('DatasetPanel', () => { }); test('renders a loading screen', () => { - render( - , - { - useRouter: true, - }, - ); + render(, { + useRouter: true, + }); const loadingIndicator = screen.getByTestId('loading-indicator'); expect(loadingIndicator).toBeVisible(); @@ -113,7 +97,12 @@ describe('DatasetPanel', () => { render( , @@ -124,8 +113,9 @@ describe('DatasetPanel', () => { const errorTitle = screen.getByText(ERROR_TITLE); expect(errorTitle).toBeVisible(); - const errorDescription = screen.getByText(ERROR_DESCRIPTION); + const errorDescription = screen.getByText('Structured backend failure'); expect(errorDescription).toBeVisible(); + expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' }); }); test('renders a table with columns displayed', async () => { @@ -133,7 +123,6 @@ describe('DatasetPanel', () => { render( , @@ -159,7 +148,6 @@ describe('DatasetPanel', () => { render( theme.sizeUnit * 6}px; +`; + const StyledAlert = styled(Alert)` ${({ theme }) => ` border: 1px solid ${theme.colorInfoText}; @@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)` export const REFRESHING = t('Refreshing columns'); export const COLUMN_TITLE = t('Table columns'); +export const ERROR_TITLE = t('An Error Occurred'); const pageSizeOptions = ['5', '10', '15', '25']; const DEFAULT_PAGE_SIZE = 25; @@ -201,9 +208,13 @@ export interface IDatasetPanelProps { */ columnList: ITableColumn[]; /** - * Boolean indicating if there is an error state + * Error returned while loading the table metadata */ - hasError: boolean; + error?: SupersetError; + /** + * Function used to retry loading the table metadata after error mitigation + */ + errorMitigationFunction?: () => void; /** * Boolean indicating if the component is in a loading state */ @@ -256,11 +267,11 @@ const DatasetPanel = ({ tableName, columnList, loading, - hasError, + error, + errorMitigationFunction, datasets, }: IDatasetPanelProps) => { - const hasColumns = Boolean(columnList?.length > 0); - const datasetNames = datasets?.map(dataset => dataset.table_name); + const hasColumns = columnList.length > 0; const tableWithDataset = datasets?.find( dataset => dataset.table_name === tableName, ); @@ -278,7 +289,19 @@ const DatasetPanel = ({ ); } if (!loading) { - if (!loading && tableName && hasColumns && !hasError) { + if (error) { + component = ( + + + + ); + } else if (tableName && hasColumns) { component = ( <> {COLUMN_TITLE} @@ -312,13 +335,7 @@ const DatasetPanel = ({ ); } else { - component = ( - - ); + component = ; } } @@ -326,11 +343,12 @@ const DatasetPanel = ({ <> {tableName && ( <> - {datasetNames?.includes(tableName) && - renderExistingDatasetAlert(tableWithDataset)} + {tableWithDataset && renderExistingDatasetAlert(tableWithDataset)} diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx index 9ade2f88786..f3786210d40 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx @@ -16,8 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { render, waitFor } from 'spec/helpers/testing-library'; -import { SupersetClient } from '@superset-ui/core'; +import { act, render, screen, waitFor } from 'spec/helpers/testing-library'; +import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core'; +import type { SupersetClientResponse } from '@superset-ui/core'; +import { + DatabaseErrorMessage, + getErrorMessageComponentRegistry, + OAuth2RedirectMessage, +} from 'src/components/ErrorMessage'; import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel'; jest.mock( @@ -29,17 +35,29 @@ jest.mock( ), ); +const errorMessageRegistry = getErrorMessageComponentRegistry(); + afterEach(() => { + errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR); + errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT); jest.restoreAllMocks(); }); +const tableMetadataResponse = ( + name: string, + columnName: string, +): SupersetClientResponse => ({ + response: new Response(), + json: { + name, + columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }], + }, +}); + test('fetches table metadata for schema-less database without schema', async () => { - const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({ - json: { - name: 'my_table', - columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }], - }, - } as any); + const getSpy = jest + .spyOn(SupersetClient, 'get') + .mockResolvedValue(tableMetadataResponse('my_table', 'id')); render( { + jest.spyOn(SupersetClient, 'get').mockRejectedValue({ + response: new Response('{}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + }); + errorMessageRegistry.registerValue( + ErrorTypeEnum.GENERIC_BACKEND_ERROR, + DatabaseErrorMessage, + ); + + render( + , + { useRouter: true }, + ); + + expect( + await screen.findByText('Unable to load columns for the selected table.'), + ).toBeVisible(); +}); + +test('retries only table metadata after matching OAuth completion', async () => { + const oauthError = { + error_type: ErrorTypeEnum.OAUTH2_REDIRECT, + message: 'OAuth authorization is required.', + extra: { + url: 'https://example.com/authorize', + tab_id: 'dataset-oauth-tab', + }, + level: 'warning', + }; + const getSpy = jest + .spyOn(SupersetClient, 'get') + .mockRejectedValueOnce({ + response: new Response(JSON.stringify({ errors: [oauthError] }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }) + .mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id')); + + errorMessageRegistry.registerValue( + ErrorTypeEnum.OAUTH2_REDIRECT, + OAuth2RedirectMessage, + ); + + render( + , + { + initialState: { + charts: {}, + dashboardInfo: {}, + explore: {}, + sqlLab: { + queries: {}, + queryEditors: [], + tabHistory: [], + }, + }, + useRedux: true, + useRouter: true, + }, + ); + + const authorizationLink = await screen.findByRole('link', { + name: /provide authorization/i, + }); + expect(authorizationLink).toHaveAttribute( + 'href', + 'https://example.com/authorize', + ); + expect(getSpy).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'oauth2_auth_complete', + newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }), + }), + ); + }); + + expect(await screen.findByText('oauth_id')).toBeVisible(); + expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]); +}); diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx index 8b584296d55..5aba5032869 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx @@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns'); export const NO_COLUMNS_DESCRIPTION = t( 'This database table does not contain any data. Please select a different table.', ); -export const ERROR_TITLE = t('An Error Occurred'); -export const ERROR_DESCRIPTION = t( - 'Unable to load columns for the selected table. Please select a different table.', -); interface MessageContentProps { - hasError: boolean; tableName?: string | null; - hasColumns: boolean; } export const MessageContent = (props: MessageContentProps) => { - const { hasError, tableName, hasColumns } = props; - let currentImage: string | undefined = 'empty-dataset.svg'; + const { tableName } = props; + let currentImage = 'empty-dataset.svg'; let currentTitle = SELECT_TABLE_TITLE; let currentDescription = renderEmptyDescription(); - if (hasError) { - currentTitle = ERROR_TITLE; - currentDescription = <>{ERROR_DESCRIPTION}; - currentImage = undefined; - } else if (tableName && !hasColumns) { + if (tableName) { currentImage = 'no-columns.svg'; currentTitle = NO_COLUMNS_TITLE; currentDescription = <>{NO_COLUMNS_DESCRIPTION}; diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx index f41ee9020fc..37fc8172c91 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx @@ -16,9 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { t } from '@apache-superset/core/translation'; -import { SupersetClient } from '@superset-ui/core'; +import { + ErrorTypeEnum, + getClientErrorObject, + SupersetClient, +} from '@superset-ui/core'; +import type { SupersetError } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; import { DatasetObject } from 'src/features/datasets/AddDataset/types'; import { addDangerToast } from 'src/components/MessageToasts/actions'; @@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types'; /** * Interface for the getTableMetadata API call */ -interface IColumnProps { +interface TableMetadataRequest { /** * Unique id of the database */ @@ -43,6 +48,10 @@ interface IColumnProps { * Name of the schema (optional for databases that don't support schemas) */ schema?: string | null; + /** + * Name of the catalog (optional for databases that don't support catalogs) + */ + catalog?: string | null; } export interface IDatasetPanelWrapperProps { @@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps { * The selected database object (used to check engine capabilities) */ database?: Partial | null; - setHasColumns?: Function; + setHasColumns?: (hasColumns: boolean) => void; datasets?: DatasetObject[] | undefined; } @@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({ }: IDatasetPanelWrapperProps) => { const [columnList, setColumnList] = useState([]); const [loading, setLoading] = useState(false); - const [hasError, setHasError] = useState(false); - const tableNameRef = useRef(tableName); + const [error, setError] = useState(); + const requestIdRef = useRef(0); + const currentRequestRef = useRef(); + const supportsSchemas = database?.supports_schemas; - const getTableMetadata = async (props: IColumnProps) => { - const { dbId, tableName, schema } = props; - setLoading(true); - setHasColumns?.(false); - const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({ - name: tableName, - catalog, - schema, - })}`; - try { - const response = await SupersetClient.get({ - endpoint: path, - }); + const getTableMetadata = useCallback( + async (props: TableMetadataRequest) => { + const { dbId, tableName, catalog, schema } = props; + requestIdRef.current += 1; + const requestId = requestIdRef.current; + setLoading(true); + setColumnList([]); + setError(undefined); + setHasColumns?.(false); + const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({ + name: tableName, + catalog, + schema, + })}`; + try { + const response = await SupersetClient.get({ + endpoint: path, + }); - if (isIDatabaseTable(response?.json)) { - const table: IDatabaseTable = response.json as IDatabaseTable; - /** - * The user is able to click other table columns while the http call for last selected table column is made - * This check ensures we process the response that matches the last selected table name and ignore the others - */ - if (table.name === tableNameRef.current) { + if (requestId !== requestIdRef.current) { + return; + } + + const table = isIDatabaseTable(response?.json) + ? (response.json as IDatabaseTable) + : undefined; + if (table?.name === tableName) { setColumnList(table.columns); setHasColumns?.(table.columns.length > 0); - setHasError(false); + setError(undefined); + } else { + const message = t( + 'The API response from %s does not match the IDatabaseTable interface.', + path, + ); + setColumnList([]); + setHasColumns?.(false); + setError({ + error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR, + extra: null, + level: 'error', + message, + }); + addDangerToast(message); + logging.error(message); } - } else { - setColumnList([]); - setHasColumns?.(false); - setHasError(true); - addDangerToast( - t( - 'The API response from %s does not match the IDatabaseTable interface.', - path, - ), - ); - logging.error( - t( - 'The API response from %s does not match the IDatabaseTable interface.', - path, - ), + } catch (caughtError) { + const clientError = await getClientErrorObject( + caughtError as Parameters[0], ); + + if (requestId === requestIdRef.current) { + const parsedError = clientError.errors?.[0] ?? { + error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR, + extra: null, + level: 'error' as const, + message: + clientError.error || + clientError.message || + clientError.statusText || + t('Unable to load columns for the selected table.'), + }; + + setColumnList([]); + setHasColumns?.(false); + setError(parsedError); + } + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } } - } catch (error) { - setColumnList([]); - setHasColumns?.(false); - setHasError(true); - } finally { - setLoading(false); + }, + [setHasColumns], + ); + + const retryGetTableMetadata = useCallback(() => { + if (currentRequestRef.current) { + getTableMetadata(currentRequestRef.current); } - }; + }, [getTableMetadata]); useEffect(() => { - tableNameRef.current = tableName; - const schemaRequired = database?.supports_schemas !== false; + const schemaRequired = supportsSchemas !== false; if (tableName && dbId && (schema || !schemaRequired)) { - getTableMetadata({ tableName, dbId, schema: schema || undefined }); + const request = { + tableName, + dbId, + catalog, + schema: schema || undefined, + }; + currentRequestRef.current = request; + getTableMetadata(request); + } else if (currentRequestRef.current) { + currentRequestRef.current = undefined; + requestIdRef.current += 1; + setColumnList([]); + setError(undefined); + setHasColumns?.(false); + setLoading(false); } - // getTableMetadata is a const and should not be in dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableName, dbId, schema, database]); + + return () => { + requestIdRef.current += 1; + }; + }, [ + tableName, + dbId, + catalog, + schema, + supportsSchemas, + getTableMetadata, + setHasColumns, + ]); return ( { } From d336d2a8b6cd1573f8ff5445e0466314965f1778 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sat, 1 Aug 2026 10:38:02 -0700 Subject: [PATCH 31/67] feat(ci): auto-label PRs with merge conflicts using requires:rebase (#42504) Co-authored-by: Superset Dev Co-authored-by: Claude Fable 5 --- .github/workflows/label-merge-conflicts.yml | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/label-merge-conflicts.yml diff --git a/.github/workflows/label-merge-conflicts.yml b/.github/workflows/label-merge-conflicts.yml new file mode 100644 index 00000000000..b764ec6b964 --- /dev/null +++ b/.github/workflows/label-merge-conflicts.yml @@ -0,0 +1,54 @@ +name: Label Merge Conflicts + +# Sweeps every open PR and labels the ones GitHub reports as CONFLICTING with +# `requires:rebase` (removing it once a rebase makes the PR mergeable again), +# so the label can be used to filter the PR backlog for the ones that need a +# rebase before they can be reviewed/merged. +# +# The action itself always re-checks *every* open PR via GraphQL on each run +# regardless of what triggered it (see eps1lon/actions-label-merge-conflict's +# sources/main.ts) - there's no way to scope it to "just this PR". The +# project's own README suggests triggering on `push` (to the default branch) +# plus `pull_request_target: [synchronize]`, but on a repo with Superset's PR +# volume that combination would re-sweep the entire open-PR list on every +# merge to master *and* every push to *any* open PR - many times an hour. +# A schedule bounds that to a fixed, predictable cadence instead; adjust it +# if 2 hours turns out to be too slow or too chatty in practice. +on: + schedule: + - cron: "0 */2 * * *" + workflow_dispatch: + +# Avoid two full backlog sweeps racing (a manual workflow_dispatch landing +# mid-schedule-tick, say); queue rather than cancel so an in-progress +# paginated sweep always runs to completion. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + label-merge-conflicts: + # Scheduled/dispatch workflows still run on forks that carry this file; + # skip anywhere but the canonical repo. + if: github.repository == 'apache/superset' + name: Label Merge Conflicts + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # to add/remove requires:rebase and need:merge + steps: + # ASF Infra allowlists this whole action via a wildcard + # (eps1lon/actions-label-merge-conflict@*), so any pinned SHA/version + # is already fine here - no Infra ticket needed for future bumps. + - uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 + with: + dirtyLabel: "requires:rebase" + # A conflicting PR isn't actually ready to merge; strip that signal + # if it was previously set so reviewers don't act on a stale one. + removeOnDirtyLabel: "need:merge" + repoToken: ${{ secrets.GITHUB_TOKEN }} + # Intentionally no commentOnDirty/commentOnClean: the label alone is + # the signal (matches the label's existing description, and avoids + # a one-time comment storm across the whole backlog on first run). From c9b159b4e754eb1f31f27159c01828701b2e7c5d Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sat, 1 Aug 2026 10:39:14 -0700 Subject: [PATCH 32/67] docs(db_engine_specs): link upstream Pinot timestamp fix in TODO (#42623) Co-authored-by: Claude Code --- superset/db_engine_specs/pinot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset/db_engine_specs/pinot.py b/superset/db_engine_specs/pinot.py index 7942df79efe..abb61a43df8 100644 --- a/superset/db_engine_specs/pinot.py +++ b/superset/db_engine_specs/pinot.py @@ -113,6 +113,9 @@ class PinotEngineSpec(BaseEngineSpec): ) -> str: # Pinot driver infers TIMESTAMP column as LONG, so make the quick fix. # When the Pinot driver fix this bug, current method could be removed. + # + # TODO: remove this override once startreedata/pinot-dbapi#224 is + # merged and released, and pinotdb is bumped past that version. if isinstance(sqla_column_type, types.TIMESTAMP): return sqla_column_type.compile().upper() From 29ac93862e6ff82f94e3db062f162373ebc14ddb Mon Sep 17 00:00:00 2001 From: 0xdorian-sm <96957695+0xdorian-sm@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:39:59 +0200 Subject: [PATCH 33/67] fix(i18n-fr): translate the 85 remaining untranslated strings (#42577) Signed-off-by: Dorian Saint-Martin Co-authored-by: Evan Rusackas --- .../translations/fr/LC_MESSAGES/messages.po | 200 +++++++++++++----- 1 file changed, 145 insertions(+), 55 deletions(-) diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po index 0418ab2dcb0..2bbbb8bbb99 100644 --- a/superset/translations/fr/LC_MESSAGES/messages.po +++ b/superset/translations/fr/LC_MESSAGES/messages.po @@ -223,7 +223,7 @@ msgstr "% du total" #, python-format msgid "%(alertType)s \"%(alertName)s\" triggered successfully" -msgstr "" +msgstr "%(alertType)s « %(alertName)s » déclenché avec succès" #, python-format msgid "%(dialect)s cannot be used as a data source for security reasons." @@ -1390,10 +1390,10 @@ msgstr "" " ou négative par rapport à la valeur de comparaison." msgid "Adhoc metric SQL expression is invalid" -msgstr "" +msgstr "L'expression SQL de la mesure ad hoc est invalide" msgid "Adhoc metric aggregate is invalid" -msgstr "" +msgstr "L'agrégat de la mesure ad hoc est invalide" msgid "Adjust how this database will interact with SQL Lab." msgstr "Ajuster la façon dont cette base de données interagira avec SQL Lab." @@ -2018,6 +2018,8 @@ msgid "" "Angle at which the first slice begins, in degrees. 90° starts at the top," " 0°/360° at the right, 270° at the bottom, and 180° at the left." msgstr "" +"Angle auquel commence la première part, en degrés. 90° démarre en haut, " +"0°/360° à droite, 270° en bas et 180° à gauche." msgid "Angle at which to end progress axis" msgstr "Angle de fin de l'axe de progression" @@ -2278,7 +2280,7 @@ msgid "Are you sure you want to delete the selected layers?" msgstr "Voulez-vous vraiment supprimer les couches sélectionnées?" msgid "Are you sure you want to delete the selected queries?" -msgstr "" +msgstr "Voulez-vous vraiment supprimer les requêtes sélectionnées ?" msgid "Are you sure you want to delete the selected roles?" msgstr "Voulez-vous vraiment supprimer les rôles sélectionnés ?" @@ -3527,7 +3529,7 @@ msgid "Clear local theme" msgstr "Supprimer le thème local" msgid "Clear search" -msgstr "" +msgstr "Effacer la recherche" msgid "Clear the selection to revert to the system default theme" msgstr "Effacer la sélection pour revenir au thème par défaut du système" @@ -4056,7 +4058,7 @@ msgid "Connection failed, please check your connection settings." msgstr "La connexion a échoué, veuillez vérifier vos paramètres de connexion" msgid "Connection looks good!" -msgstr "" +msgstr "La connexion fonctionne !" msgid "Contains" msgstr "Contient" @@ -4144,7 +4146,7 @@ msgid "Copy query" msgstr "Copier la requête" msgid "Copy query URL" -msgstr "" +msgstr "Copier l'URL de la requête" msgid "Copy query link to your clipboard" msgstr "Copier le lien de la requête vers le presse-papier" @@ -4274,6 +4276,8 @@ msgid "" "Create a new tag and assign it to existing entities like charts or " "dashboards" msgstr "" +"Créer une balise et l'affecter à des entités existantes comme des " +"graphiques ou des tableaux de bord" msgid "Create and explore dataset" msgstr "Créer et explorer un jeu de données" @@ -4302,7 +4306,7 @@ msgstr "Créé par" msgid "Created by me" msgstr "Créé par moi" -, python-format +#, python-format msgid "Created by: %s" msgstr "Créé par : %s" @@ -4597,6 +4601,9 @@ msgid "" "Dashboard cannot be restored because its slug is now used by another " "active dashboard. Rename one of the dashboards and retry." msgstr "" +"Le tableau de bord ne peut pas être restauré car son slug est désormais " +"utilisé par un autre tableau de bord actif. Renommez l'un des deux " +"tableaux de bord et réessayez." msgid "Dashboard cannot be unfavorited." msgstr "Le tableau de bord n'a pas pu être retiré des favoris." @@ -5283,7 +5290,7 @@ msgid "Delete item" msgstr "Supprimer l'élément" msgid "Delete query" -msgstr "" +msgstr "Supprimer la requête" msgid "Delete role" msgstr "Supprimer le rôle" @@ -5595,6 +5602,9 @@ msgid "" "Display charts on a map. For using this plugin, users first have to " "create any other chart that can then be placed on the map." msgstr "" +"Affiche des graphiques sur une carte. Pour utiliser ce module, il faut " +"d'abord créer un autre graphique, qui pourra ensuite être placé sur la " +"carte." msgid "Display column in the chart" msgstr "Afficher la colonne dans le graphique" @@ -5990,7 +6000,7 @@ msgstr "ERREUR" #, python-format msgid "ERROR: %s" -msgstr "" +msgstr "ERREUR : %s" msgid "Edge length" msgstr "Longueur du bord" @@ -6082,7 +6092,7 @@ msgid "Edit properties" msgstr "Modifier les propriétés" msgid "Edit query" -msgstr "" +msgstr "Modifier la requête" msgid "Edit report" msgstr "Modifier le rapport" @@ -6172,7 +6182,7 @@ msgid "Email link" msgstr "Lien par courriel" msgid "Email recipients" -msgstr "" +msgstr "Destinataires du courriel" msgid "Email reports active" msgstr "Rapports par courriel actifs" @@ -6425,7 +6435,7 @@ msgid "Entity" msgstr "Entité" msgid "Entries per page" -msgstr "" +msgstr "Entrées par page" msgid "Equal Date Sizes" msgstr "Taille des dates égales" @@ -6693,7 +6703,7 @@ msgid "Export as Example" msgstr "Exporter comme exemple" msgid "Export as PDF" -msgstr "" +msgstr "Exporter en PDF" msgid "Export cancelled" msgstr "Export annulé" @@ -6712,7 +6722,7 @@ msgid "Export failed: %s" msgstr "Export échoué : %s" msgid "Export query" -msgstr "" +msgstr "Exporter la requête" # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: cs, de, ja, # lv, ro, ru, sk, sr, sr_Latn, tr, uk] @@ -6720,7 +6730,7 @@ msgid "Export screenshot (jpeg)" msgstr "Exporter la capture d'écran (jpeg)" msgid "Export screenshot (png)" -msgstr "" +msgstr "Exporter la capture d'écran (png)" #, python-format msgid "Export successful: %s" @@ -6764,11 +6774,15 @@ msgid "" "Exporting semantic views is not supported yet — %s semantic-view row(s) " "were skipped." msgstr "" +"L'export des vues sémantiques n'est pas encore pris en charge — %s " +"ligne(s) de vue sémantique ont été ignorées." msgid "" "Exporting semantic views is not supported yet. Deselect the semantic-view" " rows and try again." msgstr "" +"L'export des vues sémantiques n'est pas encore pris en charge. " +"Désélectionnez les lignes de vue sémantique et réessayez." msgid "Expose database in SQL Lab" msgstr "Exposer la base de données dans SQL Lab" @@ -6881,6 +6895,8 @@ msgid "" "Failed to export chart data. Please try again or contact your " "administrator." msgstr "" +"Échec de l'export des données du graphique. Réessayez ou contactez votre " +"administrateur." msgid "Failed to fetch API keys" msgstr "Tout Dé-Sélectionner" @@ -6956,7 +6972,7 @@ msgstr "Échec du marquage des éléments" #, python-format msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s" -msgstr "" +msgstr "Échec du déclenchement de %(alertType)s « %(alertName)s » : %(error)s" msgid "Failed to update report" msgstr "Échec de la mise à jour du rapport" @@ -7315,7 +7331,7 @@ msgid "Forecast periods" msgstr "Périodes de prévision" msgid "Forecast requires at least 2 data points" -msgstr "" +msgstr "La prévision nécessite au moins 2 points de données" msgid "Foreign key" msgstr "Clé étrangère" @@ -7371,10 +7387,10 @@ msgstr "" " sont présentes, le formatage revient aux nombres neutres." msgid "Formatted CSV attached in email" -msgstr "CSV formatté attaché dans le courriel" +msgstr "Fichier CSV mis en forme joint au courriel" msgid "Formatted Excel attached in email" -msgstr "" +msgstr "Fichier Excel mis en forme joint au courriel" msgid "Formatted date" msgstr "Date formatée" @@ -8318,7 +8334,7 @@ msgid "Label for your query" msgstr "Label pour votre requête" msgid "Label must not be empty." -msgstr "" +msgstr "L'étiquette ne doit pas être vide." msgid "Label position" msgstr "Position de l'étiquette" @@ -8619,7 +8635,7 @@ msgid "Lines encoding" msgstr "Codage des lignes" msgid "Link Copied!" -msgstr "" +msgstr "Lien copié !" msgid "List" msgstr "Liste" @@ -9556,7 +9572,7 @@ msgstr "" "enregistrement temporel" msgid "No data found" -msgstr "" +msgstr "Aucune donnée trouvée" msgid "No data in file" msgstr "Pas de données dans le fichier" @@ -9972,7 +9988,7 @@ msgstr "Une ou plusieurs mesures n'existent pas" #, python-format msgid "One or more parameters are missing: %(missing)s" -msgstr "" +msgstr "Un ou plusieurs paramètres sont manquants : %(missing)s" msgid "One or more parameters needed to configure a database are missing." msgstr "" @@ -10675,6 +10691,11 @@ msgid "" "period (e.g. today so far) against complete prior periods (e.g. all of " "yesterday)." msgstr "" +"Tracer chaque série décalée dans le temps sur toute sa plage temporelle " +"au lieu de la tronquer à celle de la série principale. Utile pour " +"comparer une période en cours partielle (par exemple aujourd'hui jusqu'à " +"maintenant) à des périodes antérieures complètes (par exemple la journée " +"d'hier entière)." msgid "Plot the distance (like flight paths) between origin and destination." msgstr "" @@ -11135,6 +11156,12 @@ msgid "" "except the subjects defined in the filter, and can be used to define what" " users can see if no RLS filters within a filter group apply to them." msgstr "" +"Les filtres classiques ajoutent des clauses WHERE aux requêtes lorsqu'un " +"utilisateur correspond à un sujet référencé par le filtre. Les filtres de" +" base appliquent des filtres à toutes les requêtes sauf pour les sujets " +"définis dans le filtre ; ils permettent de définir ce que voient les " +"utilisateurs auxquels aucun filtre RLS d'un groupe de filtres ne " +"s'applique." msgid "Relational" msgstr "Relationnel" @@ -11175,7 +11202,7 @@ msgid "Remove customization" msgstr "Supprimer la personnalisation" msgid "Remove dependency" -msgstr "" +msgstr "Supprimer la dépendance" msgid "Remove filter" msgstr "Supprimer le filtre" @@ -11184,13 +11211,13 @@ msgid "Remove item" msgstr "Supprimer l’élément" msgid "Remove notification method" -msgstr "" +msgstr "Supprimer le mode de notification" msgid "Remove query from log" msgstr "Supprimer la requête des journaux" msgid "Remove sheet" -msgstr "" +msgstr "Supprimer la feuille" #, python-format msgid "Removed 1 column from the virtual dataset" @@ -11234,7 +11261,7 @@ msgid "Report Schedule delete failed." msgstr "La planification de rapport n'a pas être supprimée." msgid "Report Schedule execute now failed." -msgstr "" +msgstr "L'exécution immédiate de la planification de rapport a échoué." msgid "Report Schedule execution failed when generating a csv." msgstr "" @@ -11258,6 +11285,8 @@ msgstr "" msgid "Report Schedule execution failed when generating an Excel file." msgstr "" +"L'exécution de la planification de rapport a échoué lors de la génération" +" du fichier Excel." msgid "Report Schedule execution got an unexpected error." msgstr "" @@ -11269,10 +11298,15 @@ msgid "" "Please configure a Celery broker (Redis or RabbitMQ) and worker " "processes." msgstr "" +"L'exécution de la planification de rapport nécessite un backend Celery " +"configuré. Configurez un broker Celery (Redis ou RabbitMQ) et des " +"processus worker." #, python-format msgid "Report Schedule executor user %(username)s was not found." msgstr "" +"L'utilisateur %(username)s exécutant la planification de rapport est " +"introuvable." msgid "Report Schedule is still working, refusing to re-compute." msgstr "" @@ -11981,7 +12015,7 @@ msgid "Search Metrics & Columns" msgstr "Rechercher les mesures et les colonnes" msgid "Search a channel by name, or paste a channel ID" -msgstr "" +msgstr "Rechercher un canal par son nom, ou coller un identifiant de canal" msgid "Search all charts" msgstr "Rechercher tous les graphiques" @@ -12026,7 +12060,7 @@ msgid "Search owners" msgstr "Rechercher des propriétaires" msgid "Search records" -msgstr "" +msgstr "Rechercher des enregistrements" msgid "Search roles" msgstr "Recherche de rôles" @@ -12042,6 +12076,8 @@ msgstr "Rechercher…" msgid "Searches all text fields: Name, Description, Database & Schema" msgstr "" +"Recherche dans tous les champs texte : nom, description, base de données " +"et schéma" msgid "Second" msgstr "Seconde" @@ -12084,7 +12120,7 @@ msgid "See all %(tableName)s" msgstr "Voir tout %(tableName)s" msgid "See all dashboards" -msgstr "" +msgstr "Voir tous les tableaux de bord" msgid "See less" msgstr "Voir moins" @@ -12375,10 +12411,10 @@ msgid "Select operator" msgstr "Sélectionner l'opérateur" msgid "Select or type BCC recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires en Cci" msgid "Select or type CC recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires en Cc" msgid "Select or type a custom value..." msgstr "Sélectionner ou renseigner une valeur personnalisé..." @@ -12390,7 +12426,7 @@ msgid "Select or type dataset name" msgstr "Sélectionner la base de données ou taper le nom du jeu de données" msgid "Select or type email recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires du courriel" msgid "Select page size" msgstr "Sélectionner la taille de la page" @@ -12639,7 +12675,7 @@ msgid "Send as CSV" msgstr "Envoyer comme CSV" msgid "Send as Excel" -msgstr "" +msgstr "Envoyer comme Excel" msgid "Send as PDF" msgstr "Envoyer comme PDF" @@ -12871,7 +12907,7 @@ msgid "Show Metric Names" msgstr "Afficher les noms de mesure" msgid "Show Null Values" -msgstr "" +msgstr "Afficher les valeurs nulles" msgid "Show Range Filter" msgstr "Afficher l'intervalle de filtre" @@ -12912,13 +12948,17 @@ msgstr "" "autrement min/max dans les données." msgid "Show a draggable slider to control the visible range of the Y-axis." -msgstr "" +msgstr "Afficher un curseur déplaçable pour contrôler la plage visible de l'axe Y." msgid "" "Show a summary row of total aggregations: the selected metrics in " "aggregate mode, or the sum of numeric columns in raw records mode. Note " "that row limit does not apply to the result." msgstr "" +"Afficher une ligne de synthèse des agrégats totaux : les mesures " +"sélectionnées en mode agrégé, ou la somme des colonnes numériques en mode" +" enregistrements bruts. Notez que la limite de lignes ne s'applique pas " +"au résultat." msgid "Show all columns" msgstr "Afficher toutes les colonnes" @@ -12959,7 +12999,7 @@ msgid "Show entries per page" msgstr "Afficher le nombre d'éléments par page" msgid "Show full range for time shift" -msgstr "" +msgstr "Afficher la plage complète pour le décalage temporel" msgid "" "Show hierarchical relationships of data, with the value represented by " @@ -13046,6 +13086,8 @@ msgid "" "Showcases a metric along with a comparison of value, change, and percent " "change for a selected time period." msgstr "" +"Met en avant une mesure avec une comparaison de la valeur, de l'écart et " +"de l'écart en pourcentage sur une période sélectionnée." msgid "" "Showcases a single metric front-and-center. Big number is best used to " @@ -13189,7 +13231,7 @@ msgid "Solid" msgstr "Solide" msgid "Solid background" -msgstr "" +msgstr "Fond uni" # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr, # sr_Latn] @@ -13213,12 +13255,14 @@ msgstr "" "seront pas effacés" msgid "Some tables are not shown. Refine your search." -msgstr "" +msgstr "Certaines tables ne sont pas affichées. Affinez votre recherche." msgid "" "Something went wrong loading the dashboard. Check the dev console for " "details." msgstr "" +"Un problème est survenu lors du chargement du tableau de bord. Consultez " +"la console développeur pour plus de détails." msgid "Something went wrong while saving the user info" msgstr "Une erreur s'est produite. Réessayez plus tard." @@ -13628,7 +13672,7 @@ msgid "Success" msgstr "Réussite" msgid "Success message" -msgstr "" +msgstr "Message de succès" #, python-format msgid "Successfully changed %s!" @@ -13702,7 +13746,7 @@ msgid "Swap rows and columns" msgstr "Échanger les rangées et les colonnes" msgid "Sweep angle" -msgstr "" +msgstr "Angle de balayage" msgid "" "Swiss army knife for visualizing data. Choose between step, line, " @@ -13870,7 +13914,7 @@ msgid "Tag created" msgstr "Balise créée" msgid "Tag description" -msgstr "" +msgstr "Description de la balise" msgid "Tag name" msgstr "Nom de la balise" @@ -14000,11 +14044,13 @@ msgid "Text align" msgstr "Alignement du texte" msgid "Text embedded in email" -msgstr "Text encapsulé dans le courriel" +msgstr "Texte encapsulé dans le courriel" #, python-format msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer." msgstr "" +"La valeur de %(key)s dans metadata_cache_timeout doit être un entier " +"positif ou nul." #, python-format msgid "The %s" @@ -14124,6 +14170,9 @@ msgid "" "The chart data is too large to download. Please try reducing the date " "range, limiting rows, or using fewer columns." msgstr "" +"Les données du graphique sont trop volumineuses pour être téléchargées. " +"Réduisez la plage de dates, limitez le nombre de lignes ou utilisez moins" +" de colonnes." # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr, # sr_Latn] @@ -14141,7 +14190,7 @@ msgstr "" " ou mettez à jour le rapport pour pointer vers un graphique actif." msgid "The chat failed to load." -msgstr "" +msgstr "La conversation n'a pas pu être chargée." msgid "" "The classic. Great for showing how much of a company each investor gets, " @@ -14239,6 +14288,8 @@ msgstr "" msgid "The dashboard you are looking for may have been deleted or moved." msgstr "" +"Le tableau de bord que vous recherchez a peut-être été supprimé ou " +"déplacé." msgid "The data source seems to have been deleted" msgstr "La source de données semble avoir été effacée" @@ -14523,6 +14574,8 @@ msgid "" "The metadata_cache_timeout must be a mapping from string keys to non-" "negative integer values." msgstr "" +"metadata_cache_timeout doit être une correspondance entre des clés de " +"type chaîne et des entiers positifs ou nuls." #, python-format msgid "" @@ -14759,6 +14812,8 @@ msgstr "Cette requête contient un ou plusieurs paramètres de modèle malformé msgid "The query context datasource does not match the chart datasource" msgstr "" +"La source de données du contexte de requête ne correspond pas à celle du " +"graphique" msgid "The query couldn't be loaded" msgstr "La requête ne peut pas être chargée" @@ -15200,7 +15255,7 @@ msgid "There was an error fetching the filtered charts and dashboards:" msgstr "Une erreur s’est produite lors de la récupération des graphiques et tableaux de bord filtrés :" msgid "There was an error generating the permalink." -msgstr "" +msgstr "Une erreur s'est produite lors de la génération du lien permanent." msgid "There was an error loading groups." msgstr "Une erreur s'est produite lors du chargement des groupes." @@ -15305,6 +15360,8 @@ msgstr "" #, python-format msgid "There was an issue deleting the selected queries: %s" msgstr "" +"Un problème est survenu lors de la suppression des requêtes sélectionnées" +" : %s" #, python-format msgid "There was an issue deleting the selected templates: %s" @@ -15351,7 +15408,7 @@ msgid "There was an issue exporting the selected dashboards" msgstr "Il y a eu un problème lors de l'export des tableaux de bord sélectionnés" msgid "There was an issue exporting the selected queries" -msgstr "" +msgstr "Un problème est survenu lors de l'export des requêtes sélectionnées" msgid "There was an issue exporting the selected themes" msgstr "Il y a eu un problème lors de l'export des thèmes sélectionnés" @@ -15518,7 +15575,7 @@ msgstr "" "être transmis au graphique contenant les données d'annotation." msgid "This dashboard does not exist" -msgstr "" +msgstr "Ce tableau de bord n'existe pas" msgid "This dashboard is managed externally, and can't be edited in Superset" msgstr "" @@ -15906,6 +15963,8 @@ msgstr "Fragment de temps" msgid "Time Grain must be specified when using Time Comparison." msgstr "" +"Le fragment de temps doit être précisé lors de l'utilisation de la " +"comparaison de temps." msgid "Time Granularity" msgstr "Fragmentation de Temps" @@ -16195,6 +16254,10 @@ msgid "" " angle is a multiple of 90°, the chart is automatically re-centered to " "make use of the empty space." msgstr "" +"Angle total couvert par le graphique, en degrés. 360° dessine un cercle " +"complet et 180° un demi-anneau. Lorsque le balayage est inférieur ou égal" +" à 180° et que l'angle de départ est un multiple de 90°, le graphique est" +" automatiquement recentré pour exploiter l'espace vide." msgid "Total color" msgstr "Couleur du total" @@ -16219,7 +16282,7 @@ msgid "Transparent" msgstr "Transparent" msgid "Transparent background" -msgstr "" +msgstr "Fond transparent" msgid "Transpose pivot" msgstr "Pivot de transposition" @@ -16249,7 +16312,7 @@ msgid "Trigger Alert If..." msgstr "Déclencher une alerte si…" msgid "Trigger now" -msgstr "" +msgstr "Déclencher maintenant" msgid "True" msgstr "Est vrai" @@ -16368,7 +16431,7 @@ msgid "URL parameters" msgstr "Paramètres URL" msgid "UUID to track the execution status" -msgstr "" +msgstr "UUID permettant de suivre l'état de l'exécution" msgid "Unable to calculate such a date delta" msgstr "Impossible de calculer un delta de date comme celui-ci" @@ -16435,7 +16498,7 @@ msgstr "Impossible de générer les données de téléchargement" #, python-format msgid "Unable to generate forecast: %(error)s" -msgstr "" +msgstr "Impossible de générer la prévision : %(error)s" msgid "" "Unable to identify temporal column for date range time comparison.Please " @@ -16450,6 +16513,8 @@ msgid "" "Unable to interpret the time offset: %(offset)s. Use a relative time such" " as \"1 month ago\"." msgstr "" +"Impossible d'interpréter le décalage temporel : %(offset)s. Utilisez une " +"expression relative telle que « 1 month ago »." msgid "" "Unable to load columns for the selected table. Please select a different " @@ -17497,6 +17562,8 @@ msgstr "Affichage ou non des bulles au-dessus des pays" msgid "Whether to display entries with null values in the hierarchy" msgstr "" +"Afficher ou non les entrées dont les valeurs sont nulles dans la " +"hiérarchie" msgid "Whether to display in the chart" msgstr "Afficher ou non dans le graphique" @@ -17632,6 +17699,9 @@ msgid "" "Whether to sort tooltip by the selected metric in descending order. On " "stacked charts, values are shown in ascending order." msgstr "" +"Trier ou non l'infobulle par la mesure sélectionnée dans l'ordre " +"décroissant. Sur les graphiques empilés, les valeurs sont affichées dans " +"l'ordre croissant." msgid "Whether to truncate metrics" msgstr "Tronquer ou non les mesures" @@ -17791,7 +17861,7 @@ msgid "Y-axis bounds" msgstr "Limites de l’axe des ordonnées" msgid "Y-axis range slider" -msgstr "" +msgstr "Curseur de plage de l'axe Y" msgid "Y-scale interval" msgstr "Intervalle d'échelle Y" @@ -18066,26 +18136,41 @@ msgid "" "You must be a chart editor in order to delete. Please reach out to a " "chart editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du graphique pour pouvoir supprimer. Contactez un" +" éditeur du graphique pour demander des modifications ou un accès en " +"modification." msgid "" "You must be a chart editor in order to edit. Please reach out to a chart " "editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du graphique pour pouvoir modifier. Contactez un " +"éditeur du graphique pour demander des modifications ou un accès en " +"modification." msgid "" "You must be a dashboard editor in order to delete. Please reach out to a " "dashboard editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du tableau de bord pour pouvoir supprimer. " +"Contactez un éditeur du tableau de bord pour demander des modifications " +"ou un accès en modification." msgid "" "You must be a dashboard editor in order to edit. Please reach out to a " "dashboard editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du tableau de bord pour pouvoir modifier. " +"Contactez un éditeur du tableau de bord pour demander des modifications " +"ou un accès en modification." msgid "" "You must be a dataset editor in order to delete. Please reach out to a " "dataset editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur de l'ensemble de données pour pouvoir supprimer. " +"Contactez un éditeur de l'ensemble de données pour demander des " +"modifications ou un accès en modification." msgid "" "You must be a dataset editor in order to edit. Please reach out to a " @@ -18172,6 +18257,11 @@ msgid "" "into multiple dashboards) or raise the " "SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting." msgstr "" +"Votre tableau de bord est trop volumineux pour être enregistré : la " +"longueur sérialisée de la disposition est de %s alors que la limite est " +"de %s. Réduisez la taille du tableau de bord (par exemple en le scindant " +"en plusieurs tableaux de bord) ou augmentez le paramètre de configuration" +" SUPERSET_DASHBOARD_POSITION_DATA_LIMIT." msgid "Your query could not be saved" msgstr "Votre requête n'a pas pu être enregistrée" @@ -19026,7 +19116,7 @@ msgid "quarter" msgstr "trimestre" msgid "queries" -msgstr "" +msgstr "requêtes" msgid "query" msgstr "requête" @@ -19072,7 +19162,7 @@ msgid "seconds" msgstr "secondes" msgid "semantic layer" -msgstr "" +msgstr "couche sémantique" msgid "series" msgstr "série" From 9f66cb566bf1f7baf120ccdc8b408e1c518585d6 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Sat, 1 Aug 2026 16:03:20 -0400 Subject: [PATCH 34/67] feat(semantic layers): don't show samples tab in explore (#41509) --- .../DrillDetail/DrillDetailPane.test.tsx | 6 ++ .../Chart/DrillDetail/DrillDetailPane.tsx | 12 +++- .../Chart/useDrillDetailMenuItems/index.tsx | 17 ++++- .../useDrillDetailMenuItems.test.tsx | 42 +++++++++++ superset-frontend/src/dashboard/types.ts | 4 ++ .../DataTablesPane/DataTablesPane.tsx | 53 +++++++++----- .../DataTablesPane/components/SamplesPane.tsx | 12 +++- .../components/useResultsPane.tsx | 12 +++- .../test/DataTablesPane.test.tsx | 49 ++++++++++++- .../DataTablesPane/test/SamplesPane.test.tsx | 6 +- superset-frontend/src/explore/types.ts | 12 ++++ superset/common/query_actions.py | 9 +++ superset/connectors/sqla/models.py | 12 ++++ superset/semantic_layers/models.py | 8 +++ superset/superset_typing.py | 5 ++ superset/views/datasource/views.py | 17 +++++ .../common/test_query_actions_drill_detail.py | 70 +++++++++++++++++++ .../unit_tests/semantic_layers/models_test.py | 14 ++++ .../unit_tests/views/datasource/views_test.py | 63 +++++++++++++++++ 19 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 tests/unit_tests/common/test_query_actions_drill_detail.py diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx index 72637c71084..3bf8fb6b36b 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -216,6 +216,12 @@ test('should render the error', async () => { .spyOn(SupersetClient, 'post') .mockRejectedValue(new Error('Something went wrong')); await waitForRender(); + // The error is wrapped in an Alert component with a stable headline and the + // raw error text in the description — no more bare ``

`` elements.
+  expect(await screen.findByRole('alert')).toBeVisible();
+  expect(
+    await screen.findByText('Failed to load drill-to-detail rows'),
+  ).toBeVisible();
   expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
 });
 
diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
index 98e3b46d9ad..d7378fc3904 100644
--- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
+++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
@@ -42,6 +42,7 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
 import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
 import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
 import { EmptyState, Loading } from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
 import { getDatasourceSamples } from 'src/components/Chart/chartAction';
 import Table, {
   ColumnsType,
@@ -362,13 +363,18 @@ export default function DrillDetailPane({
   if (responseError) {
     // Render error if page download failed
     tableContent = (
-      
-        {responseError}
-      
+ + ); } else if (bootstrapping) { // Render loading if first page hasn't loaded diff --git a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx index 48aeef24dda..d99bc5358ff 100644 --- a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx +++ b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx @@ -50,6 +50,7 @@ const DISABLED_REASONS = { DATABASE: t( 'Drill to detail is disabled for this database. Change the database settings to enable it.', ), + DATASOURCE: t('Drill to detail is not available for this datasource type.'), NO_AGGREGATIONS: t( 'Drill to detail is disabled because this chart does not group data by dimension value.', ), @@ -116,6 +117,17 @@ export const useDrillDetailMenuItems = ({ datasources[formData.datasource]?.database?.disable_drill_to_detail, ); + // Capability flag on the datasource itself. Datasources that don't model + // raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail`` + // in the explore data payload. + const datasourceSupportsDrillToDetail = useSelector< + RootState, + boolean | undefined + >( + ({ datasources }) => + datasources[formData.datasource]?.supports_drill_to_detail, + ); + const openModal = useCallback( (filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => { onClick(event); @@ -158,7 +170,10 @@ export const useDrillDetailMenuItems = ({ let drillDisabled; let drillByDisabled; - if (drillToDetailDisabled) { + if (datasourceSupportsDrillToDetail === false) { + drillDisabled = DISABLED_REASONS.DATASOURCE; + drillByDisabled = DISABLED_REASONS.DATASOURCE; + } else if (drillToDetailDisabled) { drillDisabled = DISABLED_REASONS.DATABASE; drillByDisabled = DISABLED_REASONS.DATABASE; } else if (handlesDimensionContextMenu) { diff --git a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx index 0e7e2a7c5df..8338d3e6bd3 100644 --- a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx @@ -444,3 +444,45 @@ test('context menu renders for null dimension values', async () => { await expectDrillToDetailByEnabled(); await expectDrillToDetailByDimension(filterNull); }); + +const buildStateWithUnsupportedDatasource = () => { + const baseState = getMockStoreWithNativeFilters().getState(); + const datasourceKey = defaultFormData.datasource as string; + return { + ...baseState, + datasources: { + ...baseState.datasources, + [datasourceKey]: { + ...baseState.datasources[datasourceKey], + supports_drill_to_detail: false, + }, + }, + }; +}; + +test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => { + cleanup(); + render(, { + useRouter: true, + useRedux: true, + initialState: buildStateWithUnsupportedDatasource(), + }); + + await expectDrillToDetailDisabled( + 'Drill to detail is not available for this datasource type.', + ); + await expectNoDrillToDetailBy(); +}); + +test('context menu when datasource opts out via supports_drill_to_detail=false', async () => { + cleanup(); + render(, { + useRouter: true, + useRedux: true, + initialState: buildStateWithUnsupportedDatasource(), + }); + + const message = 'Drill to detail is not available for this datasource type.'; + await expectDrillToDetailDisabled(message); + await expectDrillToDetailByDisabled(message); +}); diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 67b7704f8ca..6889d265228 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -232,6 +232,10 @@ export type Datasource = Dataset & { // Populated by the dashboard datasets API alongside ``type``; declared here // so callers can rely on structural typing instead of casting. datasource_type?: DatasourceType; + /** False when the datasource can't return row samples (e.g. semantic views). */ + supports_samples?: boolean; + /** False when the datasource can't answer drill-to-detail requests. */ + supports_drill_to_detail?: boolean; }; export type DatasourcesState = { [key: string]: Datasource; diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx index 4d7cb5c28a7..cb8878e3bf9 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx @@ -241,25 +241,44 @@ export const DataTablesPane = ({ } }, [resultsTabFallback]); + // Hide the Samples tab for datasources that don't expose raw rows + // (e.g. semantic views). The check is intentionally ``=== false`` so that + // datasources from older backends that don't send the flag still show the + // tab and preserve current behavior. + const showSamplesTab = datasource?.supports_samples !== false; + + // If the datasource swaps to one that doesn't support samples while the + // Samples tab is active (e.g. the user picks a semantic view), the tab + // disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back + // to Results so the panel keeps rendering content. + useEffect(() => { + if (!showSamplesTab && activeTabKey === ResultTypes.Samples) { + setActiveTabKey(ResultTypes.Results); + } + }, [showSamplesTab, activeTabKey]); const tabItems = [ ...queryResultsPanes, - { - key: ResultTypes.Samples, - label: t('Samples'), - children: ( - - - - ), - }, + ...(showSamplesTab + ? [ + { + key: ResultTypes.Samples, + label: t('Samples'), + children: ( + + + + ), + }, + ] + : []), ]; return ( diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx index 9e2fa986164..3aa8a16fa71 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation'; import { ensureIsArray } from '@superset-ui/core'; import { datasetLabelLower } from 'src/features/semanticLayers/label'; import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; import { EmptyState, Loading } from '@superset-ui/core/components'; import { GenericDataType } from '@apache-superset/core/common'; import { GridTable } from 'src/components/GridTable'; @@ -35,7 +36,7 @@ import { import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls'; import { SamplesPaneProps } from '../types'; -const Error = styled.pre` +const ErrorAlertWrapper = styled.div` margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`}; `; @@ -155,7 +156,14 @@ export const SamplesPane = ({ rowLimitOptions={ROW_LIMIT_OPTIONS} onRowLimitChange={handleRowLimitChange} /> - {responseError} + + + ); } diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx index 4dcf1b5324a..ede48d3bf25 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx @@ -27,13 +27,14 @@ import { QueryData, } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; import { EmptyState, Loading } from '@superset-ui/core/components'; import { getChartDataRequest } from 'src/components/Chart/chartAction'; import { ResultsPaneProps, QueryResultInterface } from '../types'; import { SingleQueryResultPane } from './SingleQueryResultPane'; import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls'; -const Error = styled.pre` +const ErrorAlertWrapper = styled.div` margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`}; `; @@ -199,7 +200,14 @@ export const useResultsPane = ({ isLoading={false} canDownload={canDownload} /> - {responseError} + + + ); return Array(queryCount).fill(err); diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx index 024602f2126..65b3b8423c2 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx @@ -19,7 +19,12 @@ import fetchMock from 'fetch-mock'; import { FeatureFlag } from '@superset-ui/core'; import * as copyUtils from 'src/utils/copy'; -import { render, screen, userEvent } from 'spec/helpers/testing-library'; +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact'; import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers'; import { DataTablesPane } from '..'; @@ -89,6 +94,48 @@ describe('DataTablesPane', () => { expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); }); + test('Hides Samples tab when datasource opts out via supports_samples=false', async () => { + const props = createDataTablesPaneProps(0); + const propsWithoutSamples = { + ...props, + datasource: { ...props.datasource, supports_samples: false }, + }; + render(, { useRedux: true }); + expect(await screen.findByText('Results')).toBeVisible(); + expect(screen.queryByText('Samples')).not.toBeInTheDocument(); + }); + + test('Falls back to Results when active Samples tab disappears mid-session', async () => { + // Regression for codeant Major finding on PR #41509: a datasource swap + // that hides the Samples tab while it was the active tab used to leave + // ``activeTabKey === 'samples'`` orphaned, rendering a blank panel. + const props = createDataTablesPaneProps(0); + const { rerender } = render(, { + useRedux: true, + }); + + // Open the panel and pick the Samples tab. + userEvent.click(screen.getByLabelText('Expand data panel')); + userEvent.click(await screen.findByText('Samples')); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + + // Swap to a datasource that doesn't support samples (e.g. a semantic + // view). The Samples tab should disappear and the panel should land on + // Results with content still rendered. + rerender( + , + ); + await waitFor(() => { + expect(screen.queryByText('Samples')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Results')).toBeVisible(); + // Panel stays expanded and renders Results content rather than going blank. + expect(screen.getByLabelText('Collapse data panel')).toBeVisible(); + }); + test('Should copy data table content correctly', async () => { fetchMock.post( 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx index 26cff940c26..02b5fed7290 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx @@ -84,10 +84,14 @@ describe('SamplesPane', () => { const props = createSamplesPaneProps({ datasourceId: 36, }); - const { findByText } = render(, { + const { findByText, findByRole } = render(, { useRedux: true, }); + // The error is now rendered inside an Alert component, with a clear + // headline message and the raw error text as the description. + expect(await findByRole('alert')).toBeVisible(); + expect(await findByText('Failed to load samples')).toBeVisible(); expect(await findByText('Error: Bad request')).toBeVisible(); }); diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index cb0ce75c80c..a9c499adcf6 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -74,6 +74,18 @@ export type Datasource = Dataset & { schema?: string; is_sqllab_view?: boolean; extra?: string | object; + /** + * False when the datasource (e.g. a semantic view) doesn't model raw rows + * and therefore can't return a row sample. Defaults to true on the server + * side; missing here means the explore UI keeps current behavior. + */ + supports_samples?: boolean; + /** + * False when the datasource doesn't model raw rows and therefore can't + * answer a drill-to-detail query. Tracked separately from + * ``supports_samples`` so the two capabilities can diverge. + */ + supports_drill_to_detail?: boolean; }; export interface ExplorePageInitialData { diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py index fbb93e5030d..3178ae07510 100644 --- a/superset/common/query_actions.py +++ b/superset/common/query_actions.py @@ -235,6 +235,15 @@ def _get_drill_detail( # todo(yongjie): Remove this function, # when determining whether samples should be applied to the time filter. datasource = _get_datasource(query_context, query_obj) + # Refuse for datasource types that don't model raw rows (e.g. semantic + # views). Mirrors the ``supports_samples`` gate on the ``/samples`` + # endpoint so drill-detail is hard-blocked on the backend, not just + # hidden in the frontend menu. Defaults to ``True`` for any datasource + # class that doesn't explicitly opt out. + if not getattr(datasource, "supports_drill_to_detail", True): + raise QueryObjectValidationError( + _("Drill to detail is not available for this datasource type.") + ) query_obj = copy.copy(query_obj) query_obj.is_timeseries = False query_obj.metrics = None diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 09d9a65a049..72a19a5b434 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -193,6 +193,16 @@ class BaseDatasource( # Only some datasources support Row Level Security is_rls_supported: bool = False + # Datasources that can return raw row samples (anything backed by a SQL + # table can; semantic-layer abstractions cannot, since they only expose + # pre-defined metrics and dimensions). + supports_samples: bool = True + + # Datasources that can answer "drill to detail" requests — i.e. fetch the + # raw rows underlying a chart cell. Conceptually similar to ``samples`` + # but kept as a separate capability so the two can diverge. + supports_drill_to_detail: bool = True + @property def name(self) -> str: # can be a Column or a property pointing to one @@ -486,6 +496,8 @@ class BaseDatasource( "order_by_choices": self.order_by_choices, "verbose_map": self.verbose_map, "select_star": self.select_star, + "supports_samples": self.supports_samples, + "supports_drill_to_detail": self.supports_drill_to_detail, } def data_for_slices( # pylint: disable=too-many-locals # noqa: C901 diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py index ee52e3dc3cf..a4df1054ad1 100644 --- a/superset/semantic_layers/models.py +++ b/superset/semantic_layers/models.py @@ -200,6 +200,12 @@ class SemanticView(AuditMixinNullable, Model): __tablename__ = "semantic_views" + # Semantic views expose pre-defined metrics and dimensions, not raw rows, + # so neither the "Samples" tab in Explore nor the "Drill to detail" + # affordance from the chart 3-dots menu can return anything meaningful. + supports_samples: bool = False + supports_drill_to_detail: bool = False + # Use integer as the primary key for cross-database auto-increment # compatibility (sa.Identity() is not supported in MySQL or SQLite). # The uuid column is a secondary unique identifier used in URLs and perms. @@ -425,6 +431,8 @@ class SemanticView(AuditMixinNullable, Model): "sql": None, "select_star": None, "editors": [], + "supports_samples": self.supports_samples, + "supports_drill_to_detail": self.supports_drill_to_detail, "description": self.description, "table_name": self.name, "column_types": [ diff --git a/superset/superset_typing.py b/superset/superset_typing.py index 099f369047d..d53a1532efd 100644 --- a/superset/superset_typing.py +++ b/superset/superset_typing.py @@ -346,6 +346,11 @@ class ExplorableData(TypedDict, total=False): always_filter_main_dttm: bool normalize_columns: bool rls_filters: list[dict[str, Any]] + # Set by datasources that cannot return raw row samples (e.g. semantic + # views, which only expose pre-defined metrics and dimensions). + supports_samples: bool + # Set by datasources that cannot answer drill-to-detail requests. + supports_drill_to_detail: bool VizData: TypeAlias = list[Any] | dict[Any, Any] | None diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 9d983265de9..7cb96074f3e 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -202,6 +202,23 @@ class Datasource(BaseSupersetView): payload = SamplesPayloadSchema().load(request.json) except ValidationError as err: return json_error_response(err.messages, status=400) + + # Refuse early for datasource types that don't model raw rows + # (e.g. semantic views, which only expose pre-defined metrics and + # dimensions). Without this gate the request would still go through + # the standard query pipeline and fail with an opaque 500. + # ``supports_samples`` defaults to True for any datasource class that + # doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue + # to work without needing the attribute declared on each class. + ds_class = DatasourceDAO.sources.get( + DatasourceType(params["datasource_type"]), + ) + if ds_class is not None and not getattr(ds_class, "supports_samples", True): + return json_error_response( + _("Samples are not available for this datasource type."), + status=400, + ) + dashboard_id = None if security_manager.is_guest_user(): if not params["dashboard_id"]: diff --git a/tests/unit_tests/common/test_query_actions_drill_detail.py b/tests/unit_tests/common/test_query_actions_drill_detail.py new file mode 100644 index 00000000000..0bd95aa8225 --- /dev/null +++ b/tests/unit_tests/common/test_query_actions_drill_detail.py @@ -0,0 +1,70 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from unittest.mock import MagicMock, patch + +import pytest + +from superset.common.query_actions import _get_drill_detail +from superset.exceptions import QueryObjectValidationError + + +def test_get_drill_detail_refuses_datasource_that_opts_out() -> None: + """ + A datasource with ``supports_drill_to_detail = False`` (e.g. semantic + views) must be hard-blocked on the server. Without this gate the request + would fall through to ``_get_full`` and fail with an opaque error, and + the flag would only be enforced by the frontend menu — leaving the + chart-data API endpoint accepting drill-detail requests it shouldn't. + """ + datasource = MagicMock() + datasource.supports_drill_to_detail = False + + query_obj = MagicMock() + query_obj.datasource = datasource + + query_context = MagicMock() + + with pytest.raises( + QueryObjectValidationError, + match="Drill to detail is not available", + ): + _get_drill_detail(query_context, query_obj) + + +def test_get_drill_detail_allows_datasource_without_flag() -> None: + """ + Datasources that don't declare the flag (e.g. legacy ``SqlaTable`` + subclasses via ``getattr`` default) must continue to work — the gate + only fires when the flag is explicitly ``False``. + """ + datasource = MagicMock(spec=["columns"]) + column = MagicMock() + column.column_name = "id" + datasource.columns = [column] + + query_obj = MagicMock() + query_obj.datasource = datasource + query_obj.columns = [] + + query_context = MagicMock() + + expected_payload: dict[str, list[dict[str, str]]] = {"data": []} + with patch( + "superset.common.query_actions._get_full", return_value=expected_payload + ) as mock_get_full: + assert _get_drill_detail(query_context, query_obj) is expected_payload + mock_get_full.assert_called_once() diff --git a/tests/unit_tests/semantic_layers/models_test.py b/tests/unit_tests/semantic_layers/models_test.py index af13d6d11e7..6400676f8b1 100644 --- a/tests/unit_tests/semantic_layers/models_test.py +++ b/tests/unit_tests/semantic_layers/models_test.py @@ -653,6 +653,15 @@ def test_semantic_view_data( assert data["table_name"] == "Orders View" assert data["datasource_name"] == "Orders View" assert data["offset"] == 0 + # Semantic views don't model raw rows, so neither samples nor + # drill-to-detail are available. + assert data["supports_samples"] is False + assert data["supports_drill_to_detail"] is False + + +def test_semantic_view_supports_samples_is_false() -> None: + """The class-level flag opts SemanticView out of the Samples affordance.""" + assert SemanticView.supports_samples is False @pytest.fixture @@ -767,6 +776,11 @@ def test_semantic_view_data_populates_time_grain_sqla( assert grain_durations == sorted(["PT1H", "P1D", "P1M"]) +def test_semantic_view_supports_drill_to_detail_is_false() -> None: + """The class-level flag opts SemanticView out of Drill to detail.""" + assert SemanticView.supports_drill_to_detail is False + + def test_semantic_view_get_query_result( mock_implementation: MagicMock, ) -> None: diff --git a/tests/unit_tests/views/datasource/views_test.py b/tests/unit_tests/views/datasource/views_test.py index 0ca11ed3fff..c1a771035a3 100644 --- a/tests/unit_tests/views/datasource/views_test.py +++ b/tests/unit_tests/views/datasource/views_test.py @@ -310,3 +310,66 @@ def test_save_non_editor_with_editors_field_is_rejected( raw_save(_view_self()) mock_security_manager.raise_for_editorship.assert_called_once_with(mock_orm) + + +# --------------------------------------------------------------------------- +# Datasource.samples +# --------------------------------------------------------------------------- + + +@patch("superset.views.datasource.views._", lambda s: s) +@patch("superset.views.datasource.views.get_samples") +@patch("superset.views.datasource.views.json_error_response") +@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) +def test_samples_returns_400_for_unsupported_datasource_type( + mock_security_manager: MagicMock, + mock_json_error_response: MagicMock, + mock_get_samples: MagicMock, +) -> None: + """Semantic views can't return raw samples — endpoint should refuse with 400.""" + from flask import Flask + + mock_security_manager.is_guest_user.return_value = False + mock_json_error_response.return_value = "error-response" + + raw_samples = _get_view_func("samples") + app = Flask(__name__) + with app.test_request_context( + "/datasource/samples?datasource_type=semantic_view&datasource_id=1", + method="POST", + json={}, + ): + result = raw_samples(_view_self()) + + assert result == "error-response" + mock_json_error_response.assert_called_once() + _, kwargs = mock_json_error_response.call_args + assert kwargs.get("status") == 400 + # The bail-out must happen before any sample fetching is attempted. + mock_get_samples.assert_not_called() + + +@patch("superset.views.datasource.views.get_samples") +@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) +def test_samples_proceeds_for_supported_datasource_type( + mock_security_manager: MagicMock, + mock_get_samples: MagicMock, +) -> None: + """A `query` datasource (supports_samples=True) bypasses the 400 short-circuit.""" + from flask import Flask + + mock_security_manager.is_guest_user.return_value = False + mock_get_samples.return_value = {"rows": []} + + view = _view_self() + raw_samples = _get_view_func("samples") + app = Flask(__name__) + with app.test_request_context( + "/datasource/samples?datasource_type=query&datasource_id=1", + method="POST", + json={}, + ): + raw_samples(view) + + mock_get_samples.assert_called_once() + view.json_response.assert_called_once_with({"result": {"rows": []}}) From ff9bec2b99d32935d42925b2f7ddff1c1fc9f827 Mon Sep 17 00:00:00 2001 From: yousoph Date: Sat, 1 Aug 2026 15:26:31 -0700 Subject: [PATCH 35/67] fix(dashboard): make chart error messages expandable again (#42491) Co-authored-by: Claude Opus 4.8 --- .../gridComponents/Chart/Chart.test.tsx | 28 +++++++++++++++++++ .../components/gridComponents/Chart/Chart.tsx | 1 + 2 files changed, 29 insertions(+) diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index cff80c41c29..ab6b36ef67c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -566,3 +566,31 @@ test('should pass filterState from dataMask to ChartContainer', () => { mockFilterState, ); }); + +test('should pass chartStackTrace to ChartContainer so dashboard chart errors stay expandable', () => { + // Regression guard for #31858: the dashboard chart wrapper stopped forwarding + // the stack trace, so failed charts rendered a flat error with no "See more" + // affordance while the same error in Explore stayed expandable. + const stackTrace = 'Traceback (most recent call last): ValueError: boom'; + + setup( + {}, + { + ...defaultState, + charts: { + ...defaultState.charts, + [queryId]: { + ...defaultState.charts[queryId], + chartStatus: 'failed', + chartAlert: 'Something went wrong', + chartStackTrace: stackTrace, + }, + }, + }, + ); + + expect(capturedChartContainerProps).toHaveProperty( + 'chartStackTrace', + stackTrace, + ); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index 122e29ffce3..516c02f4b7c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -789,6 +789,7 @@ const Chart = (props: ChartProps) => { chartAlert={chart.chartAlert ?? undefined} chartId={props.id} chartStatus={chartStatus ?? undefined} + chartStackTrace={chart.chartStackTrace ?? undefined} datasource={datasource} dashboardId={props.dashboardId} initialValues={EMPTY_OBJECT} From 3d0ee8b4c5e9b4857f5111c3cfaa3801a3f4500e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:05:20 +0700 Subject: [PATCH 36/67] chore(deps-dev): bump prophet from 1.2.0 to 1.3.0 (#42672) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements/development.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b0d2320723..5efbecdab39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,7 +196,7 @@ playwright = ["playwright>=1.61.0, <2"] postgres = ["psycopg2-binary==2.9.12"] presto = ["pyhive[presto]>=0.6.5"] trino = ["trino>=0.338.0"] -prophet = ["prophet>=1.1.6, <2"] +prophet = ["prophet>=1.3.0, <2"] redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"] risingwave = ["sqlalchemy-risingwave"] shillelagh = ["shillelagh[all]>=1.4.4, <2"] diff --git a/requirements/development.txt b/requirements/development.txt index 3cf52d0d88b..301575fb13c 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -703,7 +703,7 @@ prompt-toolkit==3.0.51 # via # -c requirements/base-constraint.txt # click-repl -prophet==1.2.0 +prophet==1.3.0 # via apache-superset proto-plus==1.25.0 # via google-api-core From 5ccccc8c6997b14d58dd504c7228f71173627970 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:05:34 +0700 Subject: [PATCH 37/67] chore(deps): bump greenlet from 3.5.3 to 3.5.4 (#42670) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5efbecdab39..3712d52d69f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "flask-session>=0.4.0, <1.0", "flask-wtf>=1.3.0, <2.0", "geopy", - "greenlet<=3.5.3, >=3.5.3", + "greenlet<=3.5.4, >=3.5.4", "gunicorn>=26.0.0, <27; sys_platform != 'win32'", "hashids>=1.3.1, <2", # holidays>=0.45 required for security fix diff --git a/requirements/base.txt b/requirements/base.txt index 94b95d1effb..48c9412b11e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -166,7 +166,7 @@ google-auth==2.53.0 # via # -r requirements/base.in # shillelagh -greenlet==3.5.3 +greenlet==3.5.4 # via # apache-superset (pyproject.toml) # shillelagh diff --git a/requirements/development.txt b/requirements/development.txt index 301575fb13c..6bef5b7e7a1 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -377,7 +377,7 @@ googleapis-common-protos==1.66.0 # via # google-api-core # grpcio-status -greenlet==3.5.3 +greenlet==3.5.4 # via # -c requirements/base-constraint.txt # apache-superset From 322ae841e5fbcd848e839ac166de0014c95936e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:05:57 +0700 Subject: [PATCH 38/67] chore(deps-dev): update clickhouse-connect requirement from <2.0,>=1.4.2 to >=1.6.0,<2.0 (#42669) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3712d52d69f..fc3fa4061a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ bigquery = [ "sqlalchemy-bigquery>=1.17.0", "google-cloud-bigquery>=3.42.2", ] -clickhouse = ["clickhouse-connect>=1.4.2, <2.0"] +clickhouse = ["clickhouse-connect>=1.6.0, <2.0"] cockroachdb = ["cockroachdb>=0.3.5, <0.4"] crate = ["sqlalchemy-cratedb>=0.41.0, <1"] d1 = [ From cc4cd3e98a9e848f00f064f7145b586d561a626f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:06:14 +0700 Subject: [PATCH 39/67] chore(deps): bump cachetools from 7.1.4 to 7.1.6 (#42663) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc3fa4061a7..454466bebb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ # ``google-auth`` 2.53+ dropped it, so Superset must declare it # explicitly to keep fresh ``pip install apache-superset`` working # without the ``base.txt`` lock file (#40962). - "cachetools>=7.1.4, <8", + "cachetools>=7.1.6, <8", "celery>=5.6.3, <6.0.0", "click>=8.4.2", "click-option-group", diff --git a/requirements/base.txt b/requirements/base.txt index 48c9412b11e..74071b3a344 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -46,7 +46,7 @@ cachelib==0.13.0 # via # flask-caching # flask-session -cachetools==7.1.4 +cachetools==7.1.6 # via apache-superset (pyproject.toml) cattrs==25.1.1 # via requests-cache diff --git a/requirements/development.txt b/requirements/development.txt index 6bef5b7e7a1..1e432a0a122 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -101,7 +101,7 @@ cachelib==0.13.0 # -c requirements/base-constraint.txt # flask-caching # flask-session -cachetools==7.1.4 +cachetools==7.1.6 # via # -c requirements/base-constraint.txt # apache-superset From 3f201c1c77184bcb6ac44ba24f8509cac73c70e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:22:48 +0700 Subject: [PATCH 40/67] chore(deps): bump polyline from 2.0.2 to 2.0.4 (#42666) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 454466bebb6..8ca85e54bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ dependencies = [ "paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel "pgsanity", "Pillow>=11.0.0, <13", - "polyline>=2.0.0, <3.0", + "polyline>=2.0.4, <3.0", "pydantic>=2.8.0", "pyparsing>=3.3.2, <4", "python-dateutil", diff --git a/requirements/base.txt b/requirements/base.txt index 74071b3a344..e59e31f8176 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -291,7 +291,7 @@ pillow==12.3.0 # via apache-superset (pyproject.toml) platformdirs==4.3.8 # via requests-cache -polyline==2.0.2 +polyline==2.0.4 # via apache-superset (pyproject.toml) prison==0.2.1 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index 1e432a0a122..48b0d94f2c0 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -687,7 +687,7 @@ pluggy==1.5.0 # via pytest polib==1.2.0 # via apache-superset -polyline==2.0.2 +polyline==2.0.4 # via # -c requirements/base-constraint.txt # apache-superset From 36e3ebecfa8c24f8226443cb95700030b55aa65f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:22:56 +0700 Subject: [PATCH 41/67] chore(deps): bump croniter from 6.2.2 to 6.2.4 (#42664) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8ca85e54bdf..847c188d27a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "click-option-group", "colorama", "flask-cors>=6.0.5, <7.0", - "croniter>=6.2.2", + "croniter>=6.2.4", "cron-descriptor", "cryptography>=49.0.0, <50.0.0", "deprecation>=2.1.0, <2.2.0", diff --git a/requirements/base.txt b/requirements/base.txt index e59e31f8176..e22e3c7d864 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -86,7 +86,7 @@ colorama==0.4.6 # flask-appbuilder cron-descriptor==1.4.5 # via apache-superset (pyproject.toml) -croniter==6.2.2 +croniter==6.2.4 # via apache-superset (pyproject.toml) cryptography==49.0.0 # via diff --git a/requirements/development.txt b/requirements/development.txt index 48b0d94f2c0..54caccc08c6 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -178,7 +178,7 @@ cron-descriptor==1.4.5 # via # -c requirements/base-constraint.txt # apache-superset -croniter==6.2.2 +croniter==6.2.4 # via # -c requirements/base-constraint.txt # apache-superset From af7472fd7e601b8ed7566c86e81352888dafec43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:36:20 +0700 Subject: [PATCH 42/67] chore(deps-dev): update databricks-sql-connector requirement from <4.4.0,>=4.2.6 to >=4.4.0,<4.5.0 (#42668) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 847c188d27a..29e64f4af1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,7 +139,7 @@ d1 = [ ] databend = ["databend-sqlalchemy>=0.5.5, <1.0"] databricks = [ - "databricks-sql-connector>=4.2.6, <4.4.0", + "databricks-sql-connector>=4.4.0, <4.5.0", "databricks-sqlalchemy==1.0.5", ] datafusion = ["flightsql-dbapi>=0.2.2, <0.3"] From 378634cceb7d53ce04aff1d69d186efd30116287 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:44:48 +0700 Subject: [PATCH 43/67] chore(deps-dev): update pyathena requirement from <4,>=2 to >=3.35.2,<4 (#42665) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 29e64f4af1f..ec202d76bfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ dependencies = [ [project.optional-dependencies] -athena = ["pyathena[pandas]>=2, <4"] +athena = ["pyathena[pandas]>=3.35.2, <4"] aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"] bigquery = [ "pandas-gbq>=0.35.0", From 0628b0b813877d08dc16fa550e35d90f09e75786 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:45:06 +0700 Subject: [PATCH 44/67] chore(deps-dev): update thrift requirement from <1.0.0,>=0.23.0 to >=0.24.0,<1.0.0 (#42667) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec202d76bfa..118a1fe2dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,7 +173,7 @@ hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"] hive = [ "pyhive[hive_pure_sasl]>=0.7.0", "tableschema", - "thrift>=0.23.0, <1.0.0", + "thrift>=0.24.0, <1.0.0", "thrift_sasl>=0.4.3, < 1.0.0", ] impala = ["impyla>=0.24.0, <0.25"] @@ -206,7 +206,7 @@ sqlite = ["syntaqlite>=0.7.0,<0.8.0"] spark = [ "pyhive[hive_pure_sasl]>=0.7", "tableschema", - "thrift>=0.23.0, <1", + "thrift>=0.24.0, <1", ] tdengine = [ "taospy>=2.8.9", From 120b4420b92918b8002d676921a3d085f27e709d Mon Sep 17 00:00:00 2001 From: Joe Li Date: Sun, 2 Aug 2026 09:54:30 -0700 Subject: [PATCH 45/67] fix(versioning): narrow UUIDs in restore tests (#42654) --- .../charts/version_restore_tests.py | 13 ++++++++++--- .../dashboards/version_restore_tests.py | 14 +++++++++++--- .../datasets/version_restore_tests.py | 5 +++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/integration_tests/charts/version_restore_tests.py b/tests/integration_tests/charts/version_restore_tests.py index 3c60bfbb173..46a6772c3ea 100644 --- a/tests/integration_tests/charts/version_restore_tests.py +++ b/tests/integration_tests/charts/version_restore_tests.py @@ -224,6 +224,7 @@ class TestChartRestoreApi(SupersetTestCase): chart_id = chart.id chart_uuid = str(chart.uuid) entity_uuid = chart.uuid + assert entity_uuid is not None original_name = chart.slice_name original_created_by = chart.created_by_fk before_changed_on = chart.changed_on @@ -329,6 +330,8 @@ class TestChartRestoreApi(SupersetTestCase): assert alpha not in chart.editors ver_cls = version_class(Slice) + entity_uuid = chart.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == chart.id) @@ -337,7 +340,7 @@ class TestChartRestoreApi(SupersetTestCase): .scalar() ) assert first_tx is not None - target_uuid = str(derive_version_uuid(chart.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ALPHA_USERNAME) rv = self._restore(str(chart.uuid), target_uuid) @@ -387,6 +390,8 @@ class TestChartRestoreApi(SupersetTestCase): assert boys is not None ver_cls = version_class(Slice) + boys_uuid = boys.uuid + assert boys_uuid is not None boys_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == boys.id) @@ -395,7 +400,7 @@ class TestChartRestoreApi(SupersetTestCase): .scalar() ) assert boys_tx is not None - boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx)) + boys_version_uuid = str(derive_version_uuid(boys_uuid, boys_tx)) self.login(ADMIN_USERNAME) rv = self._restore(str(girls.uuid), boys_version_uuid) @@ -421,6 +426,8 @@ class TestChartRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Slice) + entity_uuid = chart.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == chart_id) @@ -428,7 +435,7 @@ class TestChartRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(chart.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ADMIN_USERNAME) rv = self._restore(str(chart.uuid), target_uuid) diff --git a/tests/integration_tests/dashboards/version_restore_tests.py b/tests/integration_tests/dashboards/version_restore_tests.py index 93bc71fdef6..78c57e87f9d 100644 --- a/tests/integration_tests/dashboards/version_restore_tests.py +++ b/tests/integration_tests/dashboards/version_restore_tests.py @@ -90,6 +90,7 @@ class TestDashboardRestoreApi(SupersetTestCase): original_title = dashboard.dashboard_title dashboard_id = dashboard.id entity_uuid = dashboard.uuid + assert entity_uuid is not None # Make two more edits so we have a known non-trivial history to # navigate: [initial, v1, v2]. @@ -151,6 +152,7 @@ class TestDashboardRestoreApi(SupersetTestCase): dashboard_uuid = str(dashboard.uuid) dashboard_id = dashboard.id entity_uuid = dashboard.uuid + assert entity_uuid is not None original_slice_ids = sorted(s.id for s in dashboard.slices) assert len(original_slice_ids) >= 2, ( @@ -225,6 +227,8 @@ class TestDashboardRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None target_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard_id) @@ -232,7 +236,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, target_tx)) # Edit the member chart AFTER the snapshot. member = db.session.query(Slice).filter(Slice.id == member_id).one() @@ -282,6 +286,8 @@ class TestDashboardRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None target_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard_id) @@ -289,7 +295,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, target_tx)) # Detach, then hard-delete the victim via raw SQL so no live row # remains (bypasses the soft-delete listener deliberately — the @@ -340,6 +346,8 @@ class TestDashboardRestoreApi(SupersetTestCase): assert alpha not in dashboard.editors ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard.id) @@ -348,7 +356,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .scalar() ) assert first_tx is not None - target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ALPHA_USERNAME) rv = self._restore(str(dashboard.uuid), target_uuid) diff --git a/tests/integration_tests/datasets/version_restore_tests.py b/tests/integration_tests/datasets/version_restore_tests.py index f8e82baddae..236de2f5098 100644 --- a/tests/integration_tests/datasets/version_restore_tests.py +++ b/tests/integration_tests/datasets/version_restore_tests.py @@ -109,6 +109,7 @@ class TestDatasetRestoreApi(SupersetTestCase): assert table is not None table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None table_id = table.id original_description = table.description @@ -164,6 +165,7 @@ class TestDatasetRestoreApi(SupersetTestCase): assert table is not None table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None table_id = table.id col = table.columns[0] @@ -219,6 +221,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None original_col_names = sorted(c.column_name for c in table.columns) removed_name = table.columns[0].column_name @@ -285,6 +288,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None removed_name = table.columns[0].column_name added_name = "__restore_full_diff_test__" @@ -399,6 +403,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None original_description = table.description original_col_names = sorted(c.column_name for c in table.columns) From 4e3ff371379a31c1fc4a369c413b231286191b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:09:04 +0700 Subject: [PATCH 46/67] chore(ci): resolve shellcheck-flagged errors (#42430) Signed-off-by: hainenber --- ...b_migration_confict.yml => check-db-migration-confict.yml} | 0 .github/workflows/{issue_creation.yml => issue-creation.yml} | 0 RELEASING/validate_this_release.sh | 4 ++-- docker/apt-install.sh | 2 +- docker/tag_latest_release.sh | 4 ++-- scripts/check_license.sh | 4 ++-- scripts/tag_latest_release.sh | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) rename .github/workflows/{check_db_migration_confict.yml => check-db-migration-confict.yml} (100%) rename .github/workflows/{issue_creation.yml => issue-creation.yml} (100%) diff --git a/.github/workflows/check_db_migration_confict.yml b/.github/workflows/check-db-migration-confict.yml similarity index 100% rename from .github/workflows/check_db_migration_confict.yml rename to .github/workflows/check-db-migration-confict.yml diff --git a/.github/workflows/issue_creation.yml b/.github/workflows/issue-creation.yml similarity index 100% rename from .github/workflows/issue_creation.yml rename to .github/workflows/issue-creation.yml diff --git a/RELEASING/validate_this_release.sh b/RELEASING/validate_this_release.sh index 4942803702c..53673ffc287 100755 --- a/RELEASING/validate_this_release.sh +++ b/RELEASING/validate_this_release.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information @@ -15,8 +17,6 @@ # specific language governing permissions and limitations # under the License. -#!/bin/bash - # Function to determine Python command get_python_command() { if command -v python3 &>/dev/null; then diff --git a/docker/apt-install.sh b/docker/apt-install.sh index 1c36353199e..c1423364c38 100755 --- a/docker/apt-install.sh +++ b/docker/apt-install.sh @@ -38,7 +38,7 @@ RESET='\033[0m' echo -e "${GREEN}Updating package lists...${RESET}" apt-get update -qq -echo -e "${GREEN}Installing packages: $@${RESET}" +echo -e "${GREEN}Installing packages: $*${RESET}" apt-get install -yqq --no-install-recommends "$@" echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}" diff --git a/docker/tag_latest_release.sh b/docker/tag_latest_release.sh index b57c67e25f6..362ce4f47ee 100755 --- a/docker/tag_latest_release.sh +++ b/docker/tag_latest_release.sh @@ -163,10 +163,10 @@ do # Iterate through the components of the version strings for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}" - if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="greater" break - elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="lesser" break fi diff --git a/scripts/check_license.sh b/scripts/check_license.sh index 28f2ddec319..9a00b07dec4 100755 --- a/scripts/check_license.sh +++ b/scripts/check_license.sh @@ -35,7 +35,7 @@ acquire_rat_jar () { wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR" else printf "You do not have curl or wget installed, please install rat manually.\n" - exit -1 + exit 255 fi fi @@ -44,7 +44,7 @@ acquire_rat_jar () { # We failed to download rm "$JAR" printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n" - exit -1 + exit 255 fi printf "Done downloading.\n" } diff --git a/scripts/tag_latest_release.sh b/scripts/tag_latest_release.sh index b57c67e25f6..362ce4f47ee 100755 --- a/scripts/tag_latest_release.sh +++ b/scripts/tag_latest_release.sh @@ -163,10 +163,10 @@ do # Iterate through the components of the version strings for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}" - if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="greater" break - elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="lesser" break fi From b6d8c830e7fb9723f77d5d953f9653137caf3048 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sun, 2 Aug 2026 14:44:38 -0700 Subject: [PATCH 47/67] test(jinja): pin filter_values() drill-to-detail native-filter fallback for virtual datasets (#35263) (#42595) Co-authored-by: Claude Code --- tests/unit_tests/models/helpers_test.py | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 579dd1beb0c..747527536f7 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -2405,6 +2405,76 @@ def test_get_sqla_query_allows_jinja_templated_custom_sql_metric_with_columns( assert "{{" not in sql +def test_get_sqla_query_virtual_dataset_filter_values_drill_to_detail( + database: Database, +) -> None: + """ + Regression for #35263: a Jinja-templated virtual dataset that calls + ``filter_values()`` in its own SQL must see filters sent in the native + ``{col, op, val}`` format that Drill to Detail/Drill by use, not just + the ``adhoc_filters`` format used by ordinary chart/explore requests. + Without this, Jinja-based datasets return zero rows when drilled into, + even though the parent chart shows data for the selected value. + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + sql=( + "SELECT a, b FROM t WHERE 1=1 " + "{% if filter_values('b') %} " + "AND b IN {{ filter_values('b') | where_in }} " + "{% endif %}" + ), + columns=[ + TableColumn(column_name="a", type="INTEGER"), + TableColumn(column_name="b", type="TEXT"), + ], + ) + + result = table.get_sqla_query( + columns=["a", "b"], + metrics=[], + extras={}, + filter=[{"col": "b", "op": "IN", "val": ["Alice"]}], + granularity=None, + is_timeseries=False, + ) + assert result is not None + + with database.get_sqla_engine() as engine: + sql = str( + result.sqla_query.compile( + dialect=engine.dialect, + compile_kwargs={"literal_binds": True}, + ) + ) + + assert "'Alice'" in sql, ( + "filter_values() should resolve native drill-to-detail-style " + f"filters inside a virtual dataset's own SQL. Generated SQL: {sql}" + ) + + # The assertion above can pass even when filter_values() itself is + # broken, because get_sqla_query() independently applies the native + # filter as an outer WHERE predicate on top of whatever the virtual + # dataset's own SQL renders to. Pull the virtual dataset's own rendered + # SQL directly out of the compiled query (rather than re-rendering it + # via a separately constructed template processor, which would not + # catch get_sqla_query() failing to forward the filter to the template + # processor it builds internally) to confirm filter_values() actually + # resolved the native filter *inside* the templated subquery. + virtual_table_from = result.sqla_query.get_final_froms()[0] + rendered_inner_sql = virtual_table_from.element.element.text + assert "'Alice'" in rendered_inner_sql, ( + "filter_values() should render the native drill-to-detail-style " + "filter directly into the virtual dataset's own templated SQL, " + f"not just the outer query. Rendered SQL: {rendered_inner_sql}" + ) + + def test_extras_where_is_parenthesized( database: Database, ) -> None: From 7ce56d369aa72a95b704d77d2a4fe56bfcbcef41 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sun, 2 Aug 2026 14:53:27 -0700 Subject: [PATCH 48/67] ci: add Kesin11/actions-timeline to the heavy CI workflows (#42507) Co-authored-by: Claude Opus 4.8 --- .github/workflows/check-python-deps.yml | 5 +++++ .github/workflows/dependency-review.yml | 5 +++++ .github/workflows/docker.yml | 11 +++++++++++ .github/workflows/pre-commit.yml | 10 ++++++++++ .github/workflows/superset-app-cli.yml | 5 +++++ .github/workflows/superset-docs-deploy.yml | 5 +++++ .github/workflows/superset-e2e.yml | 11 +++++++++++ .github/workflows/superset-extensions-cli.yml | 10 ++++++++++ .github/workflows/superset-frontend.yml | 11 +++++++++++ .github/workflows/superset-helm-lint-test.yml | 5 +++++ .github/workflows/superset-playwright.yml | 11 +++++++++++ .github/workflows/superset-python-integrationtest.yml | 11 +++++++++++ .github/workflows/superset-python-presto-hive.yml | 11 +++++++++++ .github/workflows/superset-python-unittest.yml | 7 +++++++ .github/workflows/superset-translations.yml | 11 +++++++++++ 15 files changed, 129 insertions(+) diff --git a/.github/workflows/check-python-deps.yml b/.github/workflows/check-python-deps.yml index 54e25176e4d..d97ac9884f5 100644 --- a/.github/workflows/check-python-deps.yml +++ b/.github/workflows/check-python-deps.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -21,6 +22,10 @@ jobs: check-python-deps: runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 23889b41be1..8bc09eeebcb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,6 +20,7 @@ concurrency: permissions: contents: read + actions: read jobs: dependency-review: @@ -49,6 +50,10 @@ jobs: # You cannot use a liccheck.ini file in this workflow. runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout Repository" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index db9bb93bdae..e6d10e8270b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -213,3 +213,14 @@ jobs: shell: bash run: | docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init + + actions-timeline: + needs: [docker-build, docker-compose-image-tag] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 7b93d78f04b..ce6ec11f258 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,6 +15,7 @@ on: permissions: contents: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -188,3 +189,12 @@ jobs: echo "📖 More details here: https://superset.apache.org/docs/contributing/development#git-hooks" exit 1 fi + + actions-timeline: + needs: pre-commit + if: always() + runs-on: ubuntu-26.04 + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-app-cli.yml b/.github/workflows/superset-app-cli.yml index 70d012760ce..298cef8a2ba 100644 --- a/.github/workflows/superset-app-cli.yml +++ b/.github/workflows/superset-app-cli.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -40,6 +41,10 @@ jobs: ports: - 16379:6379 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/superset-docs-deploy.yml b/.github/workflows/superset-docs-deploy.yml index 8b5f4287cfb..b55f3bd66cc 100644 --- a/.github/workflows/superset-docs-deploy.yml +++ b/.github/workflows/superset-docs-deploy.yml @@ -30,6 +30,7 @@ concurrency: permissions: contents: read + actions: read jobs: config: @@ -59,6 +60,10 @@ jobs: name: Build & Deploy runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml index ca94f2c68e5..4df02fd0984 100644 --- a/.github/workflows/superset-e2e.yml +++ b/.github/workflows/superset-e2e.yml @@ -340,3 +340,14 @@ jobs: exit 1 fi echo "playwright-tests result: $RESULT (changes: $CHANGES)" + + actions-timeline: + needs: [cypress-matrix, playwright-tests, cypress-matrix-required, playwright-tests-required] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-extensions-cli.yml b/.github/workflows/superset-extensions-cli.yml index 0ee6eb3505c..60df7c0a3bc 100644 --- a/.github/workflows/superset-extensions-cli.yml +++ b/.github/workflows/superset-extensions-cli.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -69,3 +70,12 @@ jobs: with: name: superset-extensions-cli-coverage-html path: htmlcov/ + + actions-timeline: + needs: test-superset-extensions-cli-package + if: always() + runs-on: ubuntu-26.04 + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index a7e20ab8235..923ec8ed8f5 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -201,3 +201,14 @@ jobs: run: | docker run --rm $TAG bash -c \ "npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci" + + actions-timeline: + needs: [report-coverage, lint-frontend, validate-frontend, test-storybook] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-helm-lint-test.yml b/.github/workflows/superset-helm-lint-test.yml index 18d18ff4ef2..dcec680e4c1 100644 --- a/.github/workflows/superset-helm-lint-test.yml +++ b/.github/workflows/superset-helm-lint-test.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + actions: read # Serialize runs per PR without cancelling: when a first-time contributor's # queued runs are approved together, cancel-in-progress lets an older run @@ -21,6 +22,10 @@ jobs: lint-test: runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml index 944a3eabcb0..fcdeff265a4 100644 --- a/.github/workflows/superset-playwright.yml +++ b/.github/workflows/superset-playwright.yml @@ -170,3 +170,14 @@ jobs: ${{ github.workspace }}/superset-frontend/playwright-results/ ${{ github.workspace }}/superset-frontend/test-results/ name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }} + + actions-timeline: + needs: playwright-tests-experimental + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-integrationtest.yml b/.github/workflows/superset-python-integrationtest.yml index fd8c4a39b6d..1291ae9c7d8 100644 --- a/.github/workflows/superset-python-integrationtest.yml +++ b/.github/workflows/superset-python-integrationtest.yml @@ -255,3 +255,14 @@ jobs: exit 1 fi echo "test-postgres result: $RESULT" + + actions-timeline: + needs: [test-mysql, test-postgres, test-sqlite, test-postgres-required] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-presto-hive.yml b/.github/workflows/superset-python-presto-hive.yml index 0eb264e83f1..60dea43d0e9 100644 --- a/.github/workflows/superset-python-presto-hive.yml +++ b/.github/workflows/superset-python-presto-hive.yml @@ -158,3 +158,14 @@ jobs: verbose: true use_oidc: true slug: apache/superset + + actions-timeline: + needs: [test-postgres-presto, test-postgres-hive] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index 64f3c683ae6..7e5d8833547 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -101,7 +101,14 @@ jobs: if: always() runs-on: ubuntu-26.04 timeout-minutes: 5 + permissions: + contents: read + actions: read steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: Check unit-tests result env: RESULT: ${{ needs.unit-tests.result }} diff --git a/.github/workflows/superset-translations.yml b/.github/workflows/superset-translations.yml index d0a3eb69006..8ccc6030331 100644 --- a/.github/workflows/superset-translations.yml +++ b/.github/workflows/superset-translations.yml @@ -153,3 +153,14 @@ jobs: - name: Fail if regression detected if: steps.regression.outcome == 'failure' run: exit 1 + + actions-timeline: + needs: [frontend-check-translations, babel-extract] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true From 4915a3e8c78a956d59b8e3e69669d2f9eefe779e Mon Sep 17 00:00:00 2001 From: Luc Verdier Date: Sun, 2 Aug 2026 23:56:46 +0200 Subject: [PATCH 49/67] fix(mcp): report the committed chart when its instance is detached (#42621) --- superset/mcp_service/auth.py | 2 +- superset/mcp_service/chart/chart_utils.py | 21 ++- .../mcp_service/chart/tool/generate_chart.py | 71 +++++---- .../mcp_service/chart/tool/get_chart_data.py | 4 +- .../mcp_service/chart/tool/get_chart_info.py | 2 +- .../chart/tool/get_chart_preview.py | 4 +- .../mcp_service/chart/tool/get_chart_sql.py | 2 +- .../mcp_service/chart/tool/update_chart.py | 5 +- .../mcp_service/chart/test_chart_utils.py | 21 +-- .../chart/tool/test_generate_chart.py | 150 ++++++++++++++++++ 10 files changed, 218 insertions(+), 64 deletions(-) diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py index 2a781228a55..92df7ce81c5 100644 --- a/superset/mcp_service/auth.py +++ b/superset/mcp_service/auth.py @@ -868,7 +868,7 @@ def check_chart_data_access(chart: Any) -> "DatasetValidationResult": """ from superset.mcp_service.chart.chart_utils import validate_chart_dataset - return validate_chart_dataset(chart, check_access=True) + return validate_chart_dataset(chart.datasource_id, check_access=True) def _log_user_resolution_failure(exc: ValueError | PermissionError) -> None: diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index d02e9aeb9ce..92110d217bc 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -70,7 +70,7 @@ class DatasetValidationResult: def validate_chart_dataset( - chart: Any, + datasource_id: int | None, check_access: bool = True, ) -> DatasetValidationResult: """ @@ -79,8 +79,12 @@ def validate_chart_dataset( This shared utility should be called by MCP tools after creating or retrieving charts to detect issues like missing or deleted datasets early. + Takes the datasource id rather than the chart so that callers holding an ORM + instance read it while that instance is attached; reading it here can raise + ``DetachedInstanceError`` when a concurrent request has torn down the session. + Args: - chart: A chart-like object with datasource_id, datasource_type attributes + datasource_id: The chart's ``datasource_id``, or None if it has none check_access: Whether to also check user permissions (default True) Returns: @@ -92,7 +96,6 @@ def validate_chart_dataset( from superset.mcp_service.auth import has_dataset_access warnings: list[str] = [] - datasource_id = getattr(chart, "datasource_id", None) # Check if chart has a datasource reference if datasource_id is None: @@ -1524,11 +1527,9 @@ def get_table_chart_type_label(viz_type: str | None) -> str | None: return TABLE_VIZ_TYPE_LABELS.get(viz_type) if viz_type is not None else None -def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities: +def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabilities: """Analyze chart capabilities based on type and configuration.""" - if chart: - viz_type = getattr(chart, "viz_type", "unknown") - else: + if not viz_type: viz_type = _resolve_viz_type(config) # Determine interaction capabilities based on chart type @@ -1574,11 +1575,9 @@ def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilit ) -def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: +def analyze_chart_semantics(viz_type: str | None, config: Any) -> ChartSemantics: """Generate semantic understanding of the chart.""" - if chart: - viz_type = getattr(chart, "viz_type", "unknown") - else: + if not viz_type: viz_type = _resolve_viz_type(config) # Generate primary insight based on chart type diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index e84c925b1a7..a000b4e78df 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -317,6 +317,9 @@ async def generate_chart( # noqa: C901 chart = None chart_id = None + chart_slice_name = None + chart_viz_type = None + chart_uuid = None explore_url = None form_data_key = None response_warnings: list[str] = form_data.pop("_mcp_warnings", []) @@ -424,7 +427,6 @@ async def generate_chart( # noqa: C901 ) chart = command.run() - chart_id = chart.id # Ensure chart was created successfully before committing if not chart or not chart.id: @@ -432,6 +434,19 @@ async def generate_chart( # noqa: C901 "Chart creation failed - no chart ID returned" ) + # Snapshot the scalar fields now, while the instance is + # known to be attached. The chart is already committed at + # this point, and every read further down happens after an + # await: under concurrency another in-flight request can + # tear down the shared session in between, which detaches + # this instance and turns any attribute access into a + # DetachedInstanceError. + chart_id = chart.id + chart_slice_name = chart.slice_name + chart_viz_type = chart.viz_type + chart_uuid = str(chart.uuid) if chart.uuid else None + chart_datasource_id = chart.datasource_id + # Reload server-generated timestamps (created_on, # changed_on) so the serializer sees real values. from superset import db @@ -442,20 +457,22 @@ async def generate_chart( # noqa: C901 logger.warning( "Chart %s created but refresh failed; " "continuing with current values", - chart.id, + chart_id, exc_info=True, ) await ctx.info( "Chart created successfully: chart_id=%s, chart_name=%s" % ( - chart.id, - chart.slice_name, + chart_id, + chart_slice_name, ) ) # Post-creation validation: verify the chart's dataset is accessible - dataset_check = validate_chart_dataset(chart, check_access=True) + dataset_check = validate_chart_dataset( + chart_datasource_id, check_access=True + ) if not dataset_check.is_valid: # Dataset validation failed - warn but don't fail the operation await ctx.warning( @@ -464,7 +481,7 @@ async def generate_chart( # noqa: C901 ) logger.warning( "Chart %s created but dataset validation failed: %s", - chart.id, + chart_id, dataset_check.error, ) if dataset_check.error: @@ -482,7 +499,7 @@ async def generate_chart( # noqa: C901 # Query failed — delete the broken chart and return an error logger.warning( "Compile check failed for chart %s: %s", - chart.id, + chart_id, compile_result.error, ) await ctx.warning( @@ -537,7 +554,7 @@ async def generate_chart( # noqa: C901 await ctx.error("Chart creation failed: error=%s" % (str(e),)) raise # Update explore URL to use saved chart - explore_url = f"{get_superset_base_url()}/explore/?slice_id={chart.id}" + explore_url = f"{get_superset_base_url()}/explore/?slice_id={chart_id}" # Generate form_data_key for saved charts (needed for chatbot rendering) try: @@ -561,7 +578,7 @@ async def generate_chart( # noqa: C901 cmd_params = CommandParameters( datasource_type=DatasourceType.TABLE, datasource_id=dataset.id, - chart_id=chart.id, + chart_id=chart_id, tab_id=None, form_data=json.dumps(form_data_with_datasource), ) @@ -666,8 +683,8 @@ async def generate_chart( # noqa: C901 response_warnings.extend(compile_result.warnings) # Generate semantic analysis - capabilities = analyze_chart_capabilities(chart, config) - semantics = analyze_chart_semantics(chart, config) + capabilities = analyze_chart_capabilities(chart_viz_type, config) + semantics = analyze_chart_semantics(chart_viz_type, config) # Create performance metadata execution_time = int((time.time() - start_time) * 1000) @@ -678,11 +695,7 @@ async def generate_chart( # noqa: C901 ) # Create accessibility metadata - chart_name = ( - chart.slice_name - if chart and hasattr(chart, "slice_name") - else generate_chart_name(config) - ) + chart_name = chart_slice_name or generate_chart_name(config) accessibility = AccessibilityMetadata( color_blind_safe=True, # Would need actual analysis alt_text=f"Chart showing {chart_name}", @@ -775,7 +788,7 @@ async def generate_chart( # noqa: C901 # Build chart info using serialize_chart_object for saved charts chart_info = None chart_data = None - if request.save_chart and chart: + if request.save_chart and chart_id: from sqlalchemy.orm import joinedload from superset import db @@ -793,7 +806,7 @@ async def generate_chart( # noqa: C901 try: chart = ( ChartDAO.find_by_id( - chart.id, + chart_id, query_options=[ joinedload(Slice.editors), joinedload(Slice.tags), @@ -804,7 +817,7 @@ async def generate_chart( # noqa: C901 except SQLAlchemyError: logger.warning( "Re-fetch of chart %s failed; returning minimal response", - chart.id, + chart_id, exc_info=True, ) try: @@ -815,11 +828,11 @@ async def generate_chart( # noqa: C901 exc_info=True, ) chart_data = { - "id": chart.id, - "slice_name": chart.slice_name, - "viz_type": chart.viz_type, + "id": chart_id, + "slice_name": chart_slice_name, + "viz_type": chart_viz_type, "url": explore_url, - "uuid": str(chart.uuid) if chart.uuid else None, + "uuid": chart_uuid, } if chart_data is None: @@ -849,14 +862,10 @@ async def generate_chart( # noqa: C901 "form_data": _sanitize_generate_chart_form_data_for_llm_context(form_data), "form_data_key": form_data_key, "api_endpoints": { - "data": f"{get_superset_base_url()}/api/v1/chart/{chart.id}/data/" - if chart - else None, - "export": f"{get_superset_base_url()}/api/v1/chart/{chart.id}/export/" - if chart - else None, + "data": f"{get_superset_base_url()}/api/v1/chart/{chart_id}/data/", + "export": f"{get_superset_base_url()}/api/v1/chart/{chart_id}/export/", } - if chart + if chart_id else {}, "performance": performance.model_dump() if performance else None, "accessibility": accessibility.model_dump() if accessibility else None, @@ -870,7 +879,7 @@ async def generate_chart( # noqa: C901 await ctx.info( "Chart generation completed successfully: chart_id=%s, execution_time_ms=%s" % ( - chart.id if chart else None, + chart_id, int((time.time() - start_time) * 1000), ) ) diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index 75cc3a6404e..0ed596d0c87 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -416,7 +416,9 @@ async def get_chart_data( # noqa: C901 # Skip the dataset RBAC pre-check for guests (see guest_scope.is_guest_read). if not guest_scope.is_guest_read(): - validation_result = validate_chart_dataset(chart, check_access=True) + validation_result = validate_chart_dataset( + chart.datasource_id, check_access=True + ) if not validation_result.is_valid: await ctx.warning( "Chart found but dataset is not accessible: %s" diff --git a/superset/mcp_service/chart/tool/get_chart_info.py b/superset/mcp_service/chart/tool/get_chart_info.py index 7e09984424f..65d4c03a4f8 100644 --- a/superset/mcp_service/chart/tool/get_chart_info.py +++ b/superset/mcp_service/chart/tool/get_chart_info.py @@ -116,7 +116,7 @@ async def _validate_chart_dataset_access( chart = ChartDAO.find_by_id(result.id) if not chart: return None - validation_result = validate_chart_dataset(chart, check_access=True) + validation_result = validate_chart_dataset(chart.datasource_id, check_access=True) if not validation_result.is_valid: await ctx.warning( "Chart found but dataset is not accessible: %s" % (validation_result.error,) diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index 7f3b5f4ea4e..f7e338adc9f 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -1262,7 +1262,9 @@ async def _get_chart_preview_internal( # noqa: C901 from superset.mcp_service import guest_scope if getattr(chart, "id", None) is not None and not guest_scope.is_guest_read(): - validation_result = validate_chart_dataset(chart, check_access=True) + validation_result = validate_chart_dataset( + chart.datasource_id, check_access=True + ) if not validation_result.is_valid: await ctx.warning( "Chart found but dataset is not accessible: %s" diff --git a/superset/mcp_service/chart/tool/get_chart_sql.py b/superset/mcp_service/chart/tool/get_chart_sql.py index c53af04d745..c5f255c74a6 100644 --- a/superset/mcp_service/chart/tool/get_chart_sql.py +++ b/superset/mcp_service/chart/tool/get_chart_sql.py @@ -421,7 +421,7 @@ async def _handle_chart_sql_request( ) # Validate the chart's dataset is accessible - validation_result = validate_chart_dataset(chart, check_access=True) + validation_result = validate_chart_dataset(chart.datasource_id, check_access=True) if not validation_result.is_valid: await ctx.warning( "Chart found but dataset is not accessible: %s" % (validation_result.error,) diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py index b18e8e10f8b..c5070719d99 100644 --- a/superset/mcp_service/chart/tool/update_chart.py +++ b/superset/mcp_service/chart/tool/update_chart.py @@ -579,8 +579,9 @@ async def update_chart( # noqa: C901 ) chart_for_analysis = updated_chart if saved else chart - capabilities = analyze_chart_capabilities(chart_for_analysis, parsed_config) - semantics = analyze_chart_semantics(chart_for_analysis, parsed_config) + viz_type_for_analysis = getattr(chart_for_analysis, "viz_type", None) + capabilities = analyze_chart_capabilities(viz_type_for_analysis, parsed_config) + semantics = analyze_chart_semantics(viz_type_for_analysis, parsed_config) execution_time = int((time.time() - start_time) * 1000) performance = PerformanceMetadata( diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index 256ee7032c4..b9113296143 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -1945,9 +1945,8 @@ class TestValidateChartDataset: def test_validate_chart_dataset_no_datasource_id( self, mock_find: MagicMock, mock_access: MagicMock ) -> None: - """Chart with no datasource_id returns invalid result.""" - chart = MagicMock(spec=[]) # no datasource_id attribute - result = validate_chart_dataset(chart) + """A chart with no datasource_id returns invalid result.""" + result = validate_chart_dataset(None) assert not result.is_valid assert result.dataset_id is None assert "no dataset reference" in (result.error or "").lower() @@ -1959,9 +1958,7 @@ class TestValidateChartDataset: self, mock_find: MagicMock, mock_access: MagicMock ) -> None: """Chart whose dataset was deleted returns invalid result.""" - chart = MagicMock() - chart.datasource_id = 42 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(42) assert not result.is_valid assert result.dataset_id == 42 assert "deleted" in (result.error or "").lower() @@ -1976,9 +1973,7 @@ class TestValidateChartDataset: dataset.table_name = "my_table" dataset.sql = None mock_find.return_value = dataset - chart = MagicMock() - chart.datasource_id = 7 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(7) assert result.is_valid assert result.dataset_id == 7 assert result.dataset_name == "my_table" @@ -1994,9 +1989,7 @@ class TestValidateChartDataset: dataset.table_name = "virt_ds" dataset.sql = "SELECT 1" mock_find.return_value = dataset - chart = MagicMock() - chart.datasource_id = 10 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(10) assert result.is_valid assert len(result.warnings) == 1 assert "virtual" in result.warnings[0].lower() @@ -2010,9 +2003,7 @@ class TestValidateChartDataset: from sqlalchemy.exc import SQLAlchemyError mock_find.side_effect = SQLAlchemyError("connection lost") - chart = MagicMock() - chart.datasource_id = 99 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(99) assert not result.is_valid assert result.dataset_id == 99 assert "error" in (result.error or "").lower() diff --git a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py index 52e59d62597..bc23ae9b5a5 100644 --- a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py +++ b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py @@ -19,9 +19,11 @@ Unit tests for MCP generate_chart tool """ +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.exc import DetachedInstanceError from superset.mcp_service.chart.schemas import ( @@ -438,6 +440,154 @@ def _make_mock_chart(chart_id: int = 42) -> Mock: return chart +class _DetachableSlice: + """A Slice stand-in that starts attached and can be detached at will. + + Once detached, every attribute read raises ``DetachedInstanceError``, which + is what SQLAlchemy does when a concurrent request tears down the session + this instance was loaded in. + """ + + def __init__(self, chart_id: int = 42) -> None: + self._values = { + "id": chart_id, + "slice_name": "Concurrent chart", + "viz_type": "table", + "uuid": "2a0e0e0e-0000-4000-8000-000000000042", + "datasource_id": 1, + } + self._detached = False + + def detach(self) -> None: + self._detached = True + + def __getattr__(self, name: str) -> Any: + if self._detached: + raise DetachedInstanceError( + f"Instance is not bound to a Session; " + f"attribute refresh operation cannot proceed ({name})" + ) + try: + return self._values[name] + except KeyError as ex: + raise AttributeError(name) from ex + + +async def _generate_saved_chart( + refetch: Any, +) -> tuple[Any, _DetachableSlice]: + """Run generate_chart(save_chart=True) with a chart that detaches on commit. + + ``refetch`` is used as the ``ChartDAO.find_by_id`` behaviour of the + serialization path. + """ + request = GenerateChartRequest( + dataset_id="1", + config=TableChartConfig(chart_type="table", columns=[ColumnRef(name="region")]), + save_chart=True, + generate_preview=False, + ) + ctx = MagicMock() + ctx.info = AsyncMock() + ctx.debug = AsyncMock() + ctx.warning = AsyncMock() + ctx.error = AsyncMock() + ctx.report_progress = AsyncMock() + + chart = _DetachableSlice() + dataset = Mock( + id=1, datasource_name="test_table", table_name="test_table", sql=None + ) + validation_result = Mock(is_valid=True, request=request, warnings={}, error=None) + session = MagicMock() + # The instance is detached right after the commit, before any of the reads + # that build the response. + session.refresh.side_effect = lambda _chart: chart.detach() + + with ( + patch( + "superset.mcp_service.auth.get_user_from_request", + return_value=Mock(id=1, username="admin", roles=[], groups=[]), + ), + patch( + "superset.mcp_service.chart.validation.ValidationPipeline." + "validate_request_with_warnings", + return_value=validation_result, + ), + patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset), + patch( + "superset.mcp_service.chart.tool.generate_chart.has_dataset_access", + return_value=True, + ), + # validate_chart_dataset is deliberately not mocked: it runs for real + # against the detached instance, which is where it used to raise. + patch("superset.mcp_service.auth.has_dataset_access", return_value=True), + patch( + "superset.commands.chart.create.CreateChartCommand", + return_value=Mock(run=Mock(return_value=chart)), + ), + patch("superset.db.session", session), + patch( + "superset.mcp_service.chart.tool.generate_chart._compile_chart", + return_value=CompileResult(success=True, warnings=[]), + ), + patch("superset.daos.chart.ChartDAO", Mock(find_by_id=refetch)), + patch( + "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand", + return_value=Mock(run=Mock(return_value="form-data-key")), + ), + patch( + "superset.mcp_service.chart.tool.generate_chart.get_superset_base_url", + return_value="http://localhost:8088", + ), + ): + result = await generate_chart(request, ctx=ctx) + + return result, chart + + +class TestGenerateChartDetachedInstance: + """The committed chart must be reported even if its instance is detached. + + Regression tests for https://github.com/apache/superset/issues/42567: under + concurrency the chart was written to the database and the tool still + returned an error, because the response was built by reading attributes off + an instance another request had detached. + """ + + @pytest.mark.asyncio + async def test_detached_chart_is_reported_as_created(self) -> None: + """A detached instance no longer turns a committed chart into an error.""" + refetched = _make_mock_chart() + + result, chart = await _generate_saved_chart( + refetch=Mock(return_value=refetched) + ) + + assert chart._detached is True + assert result.success is True + assert result.error is None + assert result.chart is not None + assert result.chart.id == 42 + assert result.explore_url == "http://localhost:8088/explore/?slice_id=42" + assert result.api_endpoints["data"].endswith("/api/v1/chart/42/data/") + + @pytest.mark.asyncio + async def test_detached_chart_falls_back_to_captured_scalars(self) -> None: + """The minimal fallback response never reads the detached instance.""" + result, chart = await _generate_saved_chart( + refetch=Mock(side_effect=SQLAlchemyError("session is gone")) + ) + + assert chart._detached is True + assert result.success is True + assert result.error is None + assert result.chart is not None + assert result.chart.id == 42 + assert result.chart.slice_name == "Concurrent chart" + assert result.chart.viz_type == "table" + + class TestChartSerializationEagerLoading: """Tests for eager loading fix in generate_chart serialization path.""" From d64eaf9cdb7f1915d4dc1de4e95ba6e18d928f98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:00:44 +0700 Subject: [PATCH 50/67] chore(deps-dev): bump @testing-library/jest-dom from 6.9.1 to 7.0.0 in /superset-frontend (#42525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Signed-off-by: hainenber Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: hainenber Co-authored-by: rusackas Co-authored-by: Claude Sonnet 5 Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com> --- superset-frontend/package-lock.json | 210 ++++++------------ superset-frontend/package.json | 6 +- .../packages/superset-core/package.json | 4 +- .../superset-ui-chart-controls/package.json | 4 +- .../packages/superset-ui-core/package.json | 4 +- .../package.json | 2 +- .../plugin-chart-ag-grid-table/package.json | 4 +- .../plugin-chart-pivot-table/package.json | 4 +- .../plugins/plugin-chart-table/package.json | 4 +- 9 files changed, 84 insertions(+), 158 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index e52816d84ba..9f94e68739b 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -190,9 +190,9 @@ "@swc/core": "^1.15.46", "@swc/plugin-emotion": "^14.15.0", "@swc/plugin-transform-imports": "^12.5.0", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^14.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^12.8.3", "@types/content-disposition": "^0.5.9", "@types/dom-to-image": "^2.6.7", @@ -11433,28 +11433,28 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", @@ -11465,9 +11465,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -11477,21 +11480,27 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", + "@testing-library/dom": "^10.0.0", "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { + "@types/react": "^18.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { @@ -14434,6 +14443,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -14601,12 +14611,12 @@ } }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "license": "Apache-2.0", "dependencies": { - "deep-equal": "^2.0.5" + "dequal": "^2.0.3" } }, "node_modules/arr-union": { @@ -16177,6 +16187,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -18172,38 +18183,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -19215,26 +19194,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -22777,6 +22736,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -23985,22 +23945,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -31769,22 +31713,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -38501,26 +38429,33 @@ } } }, - "node_modules/storybook/node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/storybook/node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" }, "engines": { - "node": ">=18" + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, + "node_modules/storybook/node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/storybook/node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -38535,16 +38470,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/storybook/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/storybook/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -39311,6 +39236,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -43686,9 +43612,9 @@ "@babel/preset-react": "^7.29.7", "@babel/preset-typescript": "^7.29.7", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/lodash": "^4.17.24", "@types/react": "*", @@ -43728,9 +43654,9 @@ "@ant-design/icons": "^5.6.1 || ^6.0.0", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "ace-builds": "^1.4.14", "brace": "^0.11.1", @@ -43813,9 +43739,9 @@ "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.1", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", @@ -44088,7 +44014,7 @@ "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } @@ -44184,9 +44110,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "react": "^18.3.0", @@ -44326,9 +44252,9 @@ "license": "Apache-2.0", "devDependencies": { "@babel/types": "^7.29.7", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/jest": "^30.0.0", "jest": "^30.4.2" @@ -44384,9 +44310,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "match-sorter": "^8.2.0", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index ba97df83196..2e929803df1 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -275,9 +275,9 @@ "@swc/core": "^1.15.46", "@swc/plugin-emotion": "^14.15.0", "@swc/plugin-transform-imports": "^12.5.0", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^14.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^12.8.3", "@types/content-disposition": "^0.5.9", "@types/dom-to-image": "^2.6.7", diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 41da6db9b1b..fd1759a52da 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -93,9 +93,9 @@ "typescript": "^5.0.0", "@emotion/styled": "^11.14.1", "@types/lodash": "^4.17.24", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", diff --git a/superset-frontend/packages/superset-ui-chart-controls/package.json b/superset-frontend/packages/superset-ui-chart-controls/package.json index 855281d9c6f..559adf3adef 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/package.json +++ b/superset-frontend/packages/superset-ui-chart-controls/package.json @@ -34,9 +34,9 @@ "@ant-design/icons": "^5.6.1 || ^6.0.0", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "ace-builds": "^1.4.14", "brace": "^0.11.1", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index b472607bb23..783b400351f 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -92,9 +92,9 @@ "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.1", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/package.json b/superset-frontend/plugins/legacy-plugin-chart-partition/package.json index 23ef1d9efa0..2ea29442f4f 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/package.json @@ -32,7 +32,7 @@ "@superset-ui/core": "*", "@apache-superset/core": "*", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json index 1424e87008e..b64160601b1 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json @@ -40,9 +40,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "react": "^18.3.0", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index 45a69a4cde5..37b53f6387f 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -38,9 +38,9 @@ }, "devDependencies": { "@babel/types": "^7.29.7", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/jest": "^30.0.0", "jest": "^30.4.2" diff --git a/superset-frontend/plugins/plugin-chart-table/package.json b/superset-frontend/plugins/plugin-chart-table/package.json index 031d1498f52..e192caaa64b 100644 --- a/superset-frontend/plugins/plugin-chart-table/package.json +++ b/superset-frontend/plugins/plugin-chart-table/package.json @@ -40,9 +40,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "match-sorter": "^8.2.0", From 06628bbd68ed5c5822e2877eb545db6d20a8f811 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sun, 2 Aug 2026 21:59:44 -0700 Subject: [PATCH 51/67] feat(i18n): serve language packs as versioned, immutable-cacheable scripts (#41780) Co-authored-by: Claude Fable 5 --- superset-frontend/src/preamble.test.ts | 82 ++++++++++ superset-frontend/src/preamble.ts | 14 +- superset/embedded/view.py | 7 +- superset/templates/superset/spa.html | 60 +++---- superset/translations/utils.py | 53 +++++-- superset/views/base.py | 64 ++++++-- superset/views/core.py | 58 ++++++- tests/integration_tests/security_tests.py | 4 + tests/unit_tests/translations/__init__.py | 16 ++ tests/unit_tests/translations/utils_test.py | 93 +++++++++++ tests/unit_tests/views/test_bootstrap_auth.py | 89 +++++++---- .../views/test_language_pack_script.py | 147 ++++++++++++++++++ 12 files changed, 598 insertions(+), 89 deletions(-) create mode 100644 tests/unit_tests/translations/__init__.py create mode 100644 tests/unit_tests/translations/utils_test.py create mode 100644 tests/unit_tests/views/test_language_pack_script.py diff --git a/superset-frontend/src/preamble.test.ts b/superset-frontend/src/preamble.test.ts index 579b0c97f71..dfbe1e7fd6b 100644 --- a/superset-frontend/src/preamble.test.ts +++ b/superset-frontend/src/preamble.test.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { LanguagePack } from '@apache-superset/core/translation'; + const mockConfigure = jest.fn(); const mockInitFeatureFlags = jest.fn(); const mockMakeApi = jest.fn(() => jest.fn()); @@ -111,3 +113,83 @@ test('falls back to en when passing locale to setupFormatters', async () => { expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en'); }); + +// --- language pack loading semantics (issues #35330, PR #41780) ----------- +// English: nothing is loaded at all. Non-English: the pack is expected to +// already be on window (set by the versioned classic + {% endif %} + {# + Operators can still supply a pack directly in the bootstrap payload + via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330 + workaround). When one is present, stash it on window here instead of + emitting the versioned script tag, so early chunks see the override. + #} + {% if language_pack_inline %} + + })(); + + {% endif %} {% if entry %} {{ js_bundle(assets_prefix, entry) }} {% endif %} diff --git a/superset/translations/utils.py b/superset/translations/utils.py index 36be0331a4d..9f47abf670e 100644 --- a/superset/translations/utils.py +++ b/superset/translations/utils.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import hashlib import json import logging import os @@ -24,31 +25,63 @@ logger = logging.getLogger(__name__) # Global caching for JSON language packs ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}} +# Global caching for language pack content hashes, used to build +# content-addressed (cache-busting) asset URLs. A locale with no pack file +# is cached as None so repeated requests don't re-stat the filesystem or +# re-log the same warning. +ALL_LANGUAGE_PACK_VERSIONS: dict[str, Optional[str]] = {} + DIR = os.path.dirname(os.path.abspath(__file__)) +def get_language_pack_filename(locale: str) -> str: + """Resolve the on-disk JSON pack for a locale (empty pack for English)""" + if not locale or locale == "en": + # Forcing a dummy, quasi-empty language pack for English since the file + # in the en directory contains data with empty mappings + return DIR + "/empty_language_pack.json" + return DIR + f"/{locale}/LC_MESSAGES/messages.json" + + +def get_language_pack_version(locale: str) -> Optional[str]: + """Get/cache a short content hash of the language pack file + + The hash is embedded in the pack's asset URL so browsers can cache it + as immutable and pick up a fresh copy whenever translations change. + Returns None when the pack file cannot be read. + """ + if locale in ALL_LANGUAGE_PACK_VERSIONS: + return ALL_LANGUAGE_PACK_VERSIONS[locale] + try: + with open(get_language_pack_filename(locale), "rb") as f: + version = hashlib.sha256(f.read()).hexdigest()[:12] + except OSError: + logger.warning("No language pack file to version for locale %s", locale) + version = None + ALL_LANGUAGE_PACK_VERSIONS[locale] = version + return version + + def get_language_pack(locale: str) -> Optional[dict[str, Any]]: """Get/cache a language pack - Returns the language pack from cache if it exists, caches otherwise + Returns the language pack from cache if it exists, caches otherwise. + Returns None when the catalog is missing or fails to parse, so callers + can tell a genuine pack apart from a masked failure instead of silently + serving another locale's content under the requested locale's identity + (and, worse, caching it there forever behind a content-addressed URL). >>> get_language_pack('fr')['Dashboards'] "Tableaux de bords" """ pack = ALL_LANGUAGE_PACKS.get(locale) if not pack: - filename = DIR + f"/{locale}/LC_MESSAGES/messages.json" - if not locale or locale == "en": - # Forcing a dummy, quasy-empty language pack for English since the file - # in the en directory is contains data with empty mappings - filename = DIR + "/empty_language_pack.json" + filename = get_language_pack_filename(locale) try: with open(filename, encoding="utf8") as f: pack = json.load(f) ALL_LANGUAGE_PACKS[locale] = pack or {} except Exception: # pylint: disable=broad-except - logger.error( - "Error loading language pack for, falling back on en %s", locale - ) - pack = get_language_pack("en") + logger.error("Error loading language pack for locale %s", locale) + return None return pack diff --git a/superset/views/base.py b/superset/views/base.py index 03912b744fe..88bd93916f0 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -66,7 +66,7 @@ from superset.themes.types import Theme, ThemeMode from superset.themes.utils import ( is_valid_theme, ) -from superset.translations.utils import get_language_pack +from superset.translations.utils import get_language_pack_version from superset.utils import core as utils, json from superset.utils.filters import get_dataset_access_filters from superset.utils.version import get_version_metadata, visible_version_metadata @@ -610,25 +610,54 @@ def common_bootstrap_payload() -> dict[str, Any]: # Convert locale to string for proper cache key hashing locale_str = str(locale) if locale else None payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str)) - # Inject the Jed language pack outside the per-user memoize so the cached - # payload stays small and the pack is shared across users for the same - # locale. The frontend uses it to configure the translator synchronously, - # before any code-split chunk evaluates a module-level `const X = t('...')` - # (upstream issue #35330). - language = payload.get("locale") - if language and language != "en": - # Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC - # (the workaround in #35330 does exactly that), otherwise load the - # shared one. `get_language_pack` returns the empty English pack on a - # miss, which is the right result (English) when no translation file - # exists. - pack = payload.get("language_pack") or get_language_pack(language) - else: - pack = None - payload["language_pack"] = pack + # The language pack itself is NOT embedded in the payload: spa.html loads + # it through the content-addressed /language_pack///script.js + # tag before the entry bundle, keeping HTML small while still configuring + # the translator synchronously (upstream issue #35330). A pack provided via + # COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected + # and takes precedence over the script tag. + payload.setdefault("language_pack", None) return payload +def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]: + """Template vars controlling how spa.html delivers the language pack. + + ``common`` is the already-built common bootstrap payload for the request + (passed in rather than re-derived, so callers that assembled or mocked + their own payload stay consistent with what the template sees). + + Three mutually exclusive outcomes: + - English (or no locale): no script tag, nothing to stash. + - Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script + tag; spa.html stashes the bootstrap pack on window instead. + - Otherwise: emit the content-addressed script URL so the browser can + cache the pack as immutable and cache-bust on translation changes. + """ + language = common.get("locale") + if not language or language == "en": + return {"language_pack_src": None, "language_pack_inline": False} + # Truthiness (not `is not None`) is intentional: an empty `{}` override + # is not a usable pack (the JS-side stash treats `{}` as truthy and would + # crash the translator), so it falls through to the versioned script tag + # like "no override" would. + if common.get("language_pack"): + return {"language_pack_src": None, "language_pack_inline": True} + version = get_language_pack_version(language) + return { + "language_pack_src": ( + url_for( + "Superset.language_pack_script", + lang=language, + version=version, + ) + if version + else None + ), + "language_pack_inline": False, + } + + def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]: """Generate standardized payload for spa.html template rendering. @@ -754,6 +783,7 @@ def get_spa_template_context( "dark_theme_bg": dark_theme_bg, "spinner_svg": spinner_svg, "default_title": default_title, + **get_language_pack_template_context(payload.get("common") or {}), **template_kwargs, } diff --git a/superset/views/core.py b/superset/views/core.py index 980eec9ce33..e9094fc168e 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -22,7 +22,7 @@ import logging import os import re from datetime import datetime -from typing import Any, Callable, cast +from typing import Any, Callable, cast, Optional from urllib import parse from flask import ( @@ -82,6 +82,7 @@ from superset.superset_typing import ( FlaskResponse, ) from superset.tasks.utils import get_current_user +from superset.translations.utils import get_language_pack, get_language_pack_version from superset.utils import core as utils, json from superset.utils.cache import etag_cache from superset.utils.core import ( @@ -129,6 +130,13 @@ PARAMETER_MISSING_ERR = __( SqlResults = dict[str, Any] +# Matches the locale codes Superset actually ships: a 2-3 letter language +# subtag, optionally followed by a 2-letter region subtag ("pt_BR") or a +# 4-letter script subtag ("sr_Latn"). +LANGUAGE_CODE_RE: re.Pattern[str] = re.compile( + r"^[a-z]{2,3}(_[A-Z]{2}|_[A-Z][a-z]{3})?$" +) + class Superset(BaseSupersetView): """The base views for Superset!""" @@ -934,7 +942,7 @@ class Superset(BaseSupersetView): @expose("/language_pack//") def language_pack(self, lang: str) -> FlaskResponse: # Only allow expected language formats like "en", "pt_BR", etc. - if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang): + if not LANGUAGE_CODE_RE.match(lang): abort(400, "Invalid language code") base_dir = os.path.join(os.path.dirname(__file__), "..", "translations") @@ -947,6 +955,52 @@ class Superset(BaseSupersetView): "Language pack doesn't exist on the server", status=404 ) + @expose("/language_pack///script.js") + def language_pack_script(self, lang: str, version: str) -> FlaskResponse: + """Serve the language pack as a content-addressed classic script. + + spa.html loads this BEFORE the entry bundle so translations are + configured synchronously (no race with code-split chunks), while the + versioned URL lets browsers cache the pack as immutable and pick up a + fresh copy whenever translations change. + + Deliberately unauthenticated: translation catalogs are static, public + content shipped in the Superset repo, contain no user or tenant data, + and must load for anonymous principals (login page, embedded). + """ + # Only allow expected language formats like "en", "pt_BR", etc. + if not LANGUAGE_CODE_RE.match(lang): + abort(400, "Invalid language code") + if not re.match(r"^[0-9a-f]{12}$", version): + abort(400, "Invalid language pack version") + + current_version: Optional[str] = get_language_pack_version(lang) + if current_version is None: + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + pack: Optional[dict[str, Any]] = get_language_pack(lang) + if pack is None: + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + + response: Response = Response( + f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};", + mimetype="application/javascript; charset=utf-8", + ) + if version == current_version: + # Content-addressed URL: safe to cache forever. + response.cache_control.public = True + response.cache_control.max_age = 31536000 + response.cache_control.immutable = True + else: + # Stale or unknown version (e.g. HTML rendered before an upgrade): + # serve the current pack but keep caches from pinning it under the + # wrong address. + response.cache_control.no_cache = True + return response + @event_logger.log_this @expose("/welcome/") def welcome(self) -> FlaskResponse: diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index ad205f9d227..42d50140ea1 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1740,6 +1740,10 @@ class TestRolePermission(SupersetTestCase): # Serves the PWA web app manifest unauthenticated (PWA install # fetches have no session); mirrors the RedirectView precedent. ["PwaManifestView", "manifest"], + # Serves translation catalogs (static public repo content, no + # user/tenant data) as content-addressed scripts; must load for + # anonymous principals (login page, embedded dashboards). + ["Superset", "language_pack_script"], ] unsecured_views = [] for view_class in appbuilder.baseviews: diff --git a/tests/unit_tests/translations/__init__.py b/tests/unit_tests/translations/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/tests/unit_tests/translations/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/translations/utils_test.py b/tests/unit_tests/translations/utils_test.py new file mode 100644 index 00000000000..9cc84c5e369 --- /dev/null +++ b/tests/unit_tests/translations/utils_test.py @@ -0,0 +1,93 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import hashlib +from collections.abc import Iterator + +import pytest + +from superset.translations import utils as translations_utils +from superset.translations.utils import ( + get_language_pack, + get_language_pack_filename, + get_language_pack_version, +) + + +@pytest.fixture(autouse=True) +def _clear_version_cache() -> Iterator[None]: + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + yield + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + + +@pytest.fixture(autouse=True) +def _clear_pack_cache() -> Iterator[None]: + saved = dict(translations_utils.ALL_LANGUAGE_PACKS) + yield + translations_utils.ALL_LANGUAGE_PACKS.clear() + translations_utils.ALL_LANGUAGE_PACKS.update(saved) + + +def test_language_pack_filename_resolution() -> None: + assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json") + assert get_language_pack_filename("en").endswith("/empty_language_pack.json") + assert get_language_pack_filename("").endswith("/empty_language_pack.json") + + +def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b'{"domain": "superset"}') + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + version = get_language_pack_version("fr") + + expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12] + assert version == expected + + +def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b"{}") + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + first = get_language_pack_version("fr") + pack_file.write_bytes(b'{"changed": true}') + # Cached within the process lifetime: same version until cache cleared. + assert get_language_pack_version("fr") == first + + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + assert get_language_pack_version("fr") != first + + +def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + assert get_language_pack_version("xx") is None + + +def test_pack_none_when_catalog_fails_to_parse(tmp_path, monkeypatch) -> None: + """A corrupt catalog must surface as None, not be silently masked as + the English fallback under the broken locale's cache key (which would + let a caller cache the wrong content as if it were correct).""" + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b"not valid json") + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + assert get_language_pack("fr") is None + assert "fr" not in translations_utils.ALL_LANGUAGE_PACKS diff --git a/tests/unit_tests/views/test_bootstrap_auth.py b/tests/unit_tests/views/test_bootstrap_auth.py index c49c3eddc0a..af92031537f 100644 --- a/tests/unit_tests/views/test_bootstrap_auth.py +++ b/tests/unit_tests/views/test_bootstrap_auth.py @@ -191,71 +191,108 @@ def test_bootstrap_does_not_crash_without_recaptcha_key( assert "RECAPTCHA_PUBLIC_KEY" not in payload["conf"] -# --- language_pack injection -------------------------------------------- +# --- language pack delivery ---------------------------------------------- # -# The Jed pack is injected by `common_bootstrap_payload` (outside the -# memoized `cached_common_bootstrap_data`) using the shared -# `superset.translations.utils.get_language_pack`. Tests here cover the -# wrapper to confirm the pack lands on the payload for non-English -# locales and is None for English. +# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a +# content-addressed script tag whose URL comes from +# `get_language_pack_template_context`. A pack supplied through +# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the +# script tag. -def test_common_bootstrap_payload_includes_language_pack_for_non_english( +def test_common_bootstrap_payload_does_not_embed_language_pack( app_context: None, ) -> None: - """common.language_pack carries the shared utility's pack for non-en.""" - fake_pack = {"domain": "superset", "locale_data": {"superset": {}}} + """The payload stays small: no full pack even for non-English locales.""" with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value={"locale": "fr"}, ), - patch( - "superset.views.base.get_language_pack", - return_value=fake_pack, - ) as mock_get, patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): - payload = common_bootstrap_payload() + payload: dict[str, Any] = common_bootstrap_payload() - assert payload["language_pack"] == fake_pack - mock_get.assert_called_once_with("fr") + assert payload["language_pack"] is None -def test_common_bootstrap_payload_skips_pack_for_english( +def test_common_bootstrap_payload_preserves_override_pack( app_context: None, ) -> None: - """English short-circuits: pack is None and the utility is not called.""" + """A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through.""" + fake_pack: dict[str, Any] = {"domain": "superset", "locale_data": {"superset": {}}} with ( patch( "superset.views.base.cached_common_bootstrap_data", - return_value={"locale": "en"}, + return_value={"locale": "fr", "language_pack": fake_pack}, ), - patch("superset.views.base.get_language_pack") as mock_get, patch("superset.views.base.utils.get_user_id", return_value=1), - patch("superset.views.base.get_locale", return_value="en"), + patch("superset.views.base.get_locale", return_value="fr"), ): - payload = common_bootstrap_payload() + payload: dict[str, Any] = common_bootstrap_payload() - assert payload["language_pack"] is None - mock_get.assert_not_called() + assert payload["language_pack"] == fake_pack def test_common_bootstrap_payload_does_not_mutate_memoized_dict( app_context: None, ) -> None: - """Injecting language_pack must not write back into the memoize cache.""" + """Defaulting language_pack must not write back into the memoize cache.""" cached: dict[str, Any] = {"locale": "fr"} with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value=cached, ), - patch("superset.views.base.get_language_pack", return_value={"x": 1}), patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): common_bootstrap_payload() assert "language_pack" not in cached + + +def _language_pack_context( + locale: str, payload_extra: dict[str, Any] +) -> dict[str, Any]: + from superset.views.base import get_language_pack_template_context + + with ( + patch( + "superset.views.base.get_language_pack_version", + return_value="abc123def456", + ), + patch( + "superset.views.base.url_for", + return_value="/language_pack/fr/abc123def456/script.js", + ), + ): + return get_language_pack_template_context({"locale": locale, **payload_extra}) + + +def test_language_pack_template_context_versioned_src_for_non_english( + app_context: None, +) -> None: + context: dict[str, Any] = _language_pack_context("fr", {}) + assert context == { + "language_pack_src": "/language_pack/fr/abc123def456/script.js", + "language_pack_inline": False, + } + + +def test_language_pack_template_context_none_for_english( + app_context: None, +) -> None: + context: dict[str, Any] = _language_pack_context("en", {}) + assert context == {"language_pack_src": None, "language_pack_inline": False} + + +def test_language_pack_template_context_inline_for_override_pack( + app_context: None, +) -> None: + """An operator-supplied pack suppresses the script tag; spa.html inlines.""" + context: dict[str, Any] = _language_pack_context( + "fr", {"language_pack": {"domain": "superset"}} + ) + assert context == {"language_pack_src": None, "language_pack_inline": True} diff --git a/tests/unit_tests/views/test_language_pack_script.py b/tests/unit_tests/views/test_language_pack_script.py new file mode 100644 index 00000000000..53eeb36ebd3 --- /dev/null +++ b/tests/unit_tests/views/test_language_pack_script.py @@ -0,0 +1,147 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from pathlib import Path +from typing import Any +from unittest.mock import patch + +FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}} +FAKE_VERSION = "abc123def456" + + +def test_script_served_immutable_when_version_matches(client: Any) -> None: + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js") + + assert response.status_code == 200 + assert response.mimetype == "application/javascript" + body = response.get_data(as_text=True) + assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ") + assert '"domain": "superset"' in body + cache_control = response.headers["Cache-Control"] + assert "immutable" in cache_control + assert "max-age=31536000" in cache_control + assert "public" in cache_control + + +def test_script_not_cacheable_when_version_stale(client: Any) -> None: + """A pre-upgrade HTML page may reference an old version: serve fresh + content, but do not let caches pin it under the stale address.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get("/language_pack/fr/000000000000/script.js") + + assert response.status_code == 200 + assert "no-cache" in response.headers["Cache-Control"] + assert "immutable" not in response.headers["Cache-Control"] + + +def test_script_404_when_pack_missing(client: Any) -> None: + with patch( + "superset.views.core.get_language_pack_version", + return_value=None, + ): + response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js") + + assert response.status_code == 404 + + +def test_script_404_when_pack_fails_to_parse(client: Any) -> None: + """A malformed catalog must not be masked as a success: silently + serving the internal English fallback under the broken locale's + content-addressed URL would cache the wrong translations as + immutable for a year.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=None), + ): + response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js") + + assert response.status_code == 404 + + +def test_script_rejects_malformed_lang_and_version(client: Any) -> None: + assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in ( + 400, + 404, + ) + assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400 + + +def test_script_accepts_script_subtag_locale(client: Any) -> None: + """Script-subtag locales Superset ships (e.g. sr_Latn) must not be + rejected by the language-code validation.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get(f"/language_pack/sr_Latn/{FAKE_VERSION}/script.js") + + assert response.status_code == 200 + + +def test_script_serves_only_the_requested_locale(client: Any) -> None: + """The endpoint resolves exactly one pack: the locale in the URL.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ) as mock_version, + patch( + "superset.views.core.get_language_pack", return_value=FAKE_PACK + ) as mock_pack, + ): + client.get(f"/language_pack/pt_BR/{FAKE_VERSION}/script.js") + + mock_version.assert_called_once_with("pt_BR") + mock_pack.assert_called_once_with("pt_BR") + + +def test_spa_template_loads_pack_before_entry_bundle() -> None: + """Static guard on spa.html: the language pack script tag must precede + the entry bundle and stay a classic script (no async/defer). A deferred + or reordered tag would let code-split chunks evaluate module-level + `t('...')` calls before the translator is configured (issue #35330).""" + import superset + + template: str = ( + Path(superset.__file__).parent / "templates" / "superset" / "spa.html" + ).read_text() + + tag_start: int = template.index('