You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/08/24 19:09:13 UTC
(superset) branch feat/excel-export-gcs-support created (now a42f5a2fa89)
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a change to branch feat/excel-export-gcs-support
in repository https://gitbox.apache.org/repos/asf/superset.git
at a42f5a2fa89 feat(dashboard): pluggable Excel export storage backend, with a GCS implementation
This branch includes the following new commits:
new a42f5a2fa89 feat(dashboard): pluggable Excel export storage backend, with a GCS implementation
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(dashboard): pluggable Excel export storage backend, with a GCS implementation
Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch feat/excel-export-gcs-support
in repository https://gitbox.apache.org/repos/asf/superset.git
commit a42f5a2fa89d31609c66341df98ab2991835c364
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Mon Aug 24 19:08:25 2026 +0000
feat(dashboard): pluggable Excel export storage backend, with a GCS implementation
superset/utils/s3.py is hardcoded to boto3/AWS S3, so a deployment whose
EXCEL_EXPORT_S3_BUCKET names a native Google Cloud Storage bucket (not an
S3-compatible one) has no way to make dashboard Excel export actually reach
it: uploads and pre-signed download URLs both go through boto3 regardless.
Add EXCEL_EXPORT_STORAGE, an optional config hook following the same
"instance in config" idiom as RESULTS_BACKEND/CUSTOM_SECURITY_MANAGER: set it
to an object implementing the small ExportStorage protocol (upload_file,
generate_download_url) to take over both the task's upload and the download
redirect's URL minting. Left unset, behavior is unchanged -- the existing
boto3/S3 helpers run exactly as before.
Ship GCSExportStorage (superset/utils/gcs.py) as a ready-to-use
implementation for GCS-backed deployments, using the native
google-cloud-storage client (Application Default Credentials) rather than
S3-compatible interop credentials. google-cloud-storage is imported lazily
and is only required if GCSExportStorage is actually configured, via a new
excel-export-gcs extra (mirroring the existing excel-export/boto3 extra).
---
pyproject.toml | 6 ++
superset/config.py | 8 +++
superset/dashboards/api.py | 13 +++-
superset/tasks/export_dashboard_excel.py | 13 +++-
superset/utils/export_storage.py | 58 +++++++++++++++
superset/utils/gcs.py | 84 ++++++++++++++++++++++
tests/integration_tests/dashboards/api_tests.py | 41 ++++++++++-
.../tasks/test_export_dashboard_excel.py | 20 ++++++
tests/unit_tests/utils/gcs_tests.py | 73 +++++++++++++++++++
9 files changed, 311 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index d82adc359c5..721d0bb6f88 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -184,6 +184,12 @@ excel = ["xlrd>=2.0.2, <2.1"]
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
# installing this extra is only required to actually run exports.
excel-export = ["boto3"]
+# Alternate dashboard Excel export storage backend (EXCEL_EXPORT_STORAGE =
+# GCSExportStorage()) for a deployment whose export bucket is a native Google
+# Cloud Storage bucket rather than S3. google-cloud-storage is imported lazily
+# by superset.utils.gcs, so this extra is an alternative to excel-export, not
+# an addition to it -- pick whichever matches your bucket's provider.
+excel-export-gcs = ["google-cloud-storage"]
fastmcp = [
"fastmcp>=3.4.6,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
diff --git a/superset/config.py b/superset/config.py
index f7bce229aa6..8ae2b1ab7d2 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -66,6 +66,7 @@ from superset.tasks.types import ExecutorType
from superset.themes.types import Theme
from superset.utils import core as utils
from superset.utils.encrypt import SQLAlchemyUtilsAdapter
+from superset.utils.export_storage import ExportStorage
from superset.utils.log import DBEventLogger
from superset.utils.logging_configurator import DefaultLoggingConfigurator
from superset.utils.version import get_dev_env_label
@@ -1538,6 +1539,13 @@ EXCEL_EXPORT_LINK_TTL_SECONDS = 86400
# endpoint_url for S3-compatible stores (MinIO/LocalStack). Credentials
# otherwise resolve through the standard boto3 chain.
EXCEL_EXPORT_S3_CLIENT_KWARGS: dict[str, Any] = {}
+# Optional pluggable storage backend (an instance implementing
+# superset.utils.export_storage.ExportStorage), the same pattern as
+# RESULTS_BACKEND or CUSTOM_SECURITY_MANAGER. When unset, the built-in
+# superset.utils.s3 (boto3/AWS S3) backend is used. Set this to e.g.
+# GCSExportStorage() (superset.utils.gcs) for a deployment whose
+# EXCEL_EXPORT_S3_BUCKET names a native Google Cloud Storage bucket.
+EXCEL_EXPORT_STORAGE: ExportStorage | None = None
# Viz types treated as tables in the "Export Images to Excel" mode: these charts
# stay tabular (one worksheet of data) while every other viz type is embedded as
# a rendered image. Set to None to fall back to the built-in default.
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 2729ec31070..ee482ed69d8 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -1944,9 +1944,16 @@ class DashboardRestApi(
if resolved is None:
return self.response(410, message="This download link has expired.")
bucket, key = resolved
- return redirect(
- s3.generate_presigned_url(bucket, key, PRESIGNED_URL_TTL_SECONDS)
- )
+ storage = current_app.config.get("EXCEL_EXPORT_STORAGE")
+ if storage is not None:
+ download_url = storage.generate_download_url(
+ bucket, key, PRESIGNED_URL_TTL_SECONDS
+ )
+ else:
+ download_url = s3.generate_presigned_url(
+ bucket, key, PRESIGNED_URL_TTL_SECONDS
+ )
+ return redirect(download_url)
@expose("/<pk>/cache_dashboard_screenshot/", methods=("POST",))
@validate_feature_flags(["THUMBNAILS", "ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS"])
diff --git a/superset/tasks/export_dashboard_excel.py b/superset/tasks/export_dashboard_excel.py
index f2f0d3a97ce..aadc4670b05 100644
--- a/superset/tasks/export_dashboard_excel.py
+++ b/superset/tasks/export_dashboard_excel.py
@@ -449,6 +449,17 @@ def _handle_export_failure(
logger.exception("Failed to record export failure status for %s", job_id)
+def _upload_export_file(tmp_path: str, bucket: str, key: str) -> None:
+ """Upload the generated workbook via EXCEL_EXPORT_STORAGE if configured
+ (e.g. a GCS backend for a deployment whose bucket isn't S3), else the
+ built-in boto3/S3 helper."""
+ storage = current_app.config.get("EXCEL_EXPORT_STORAGE")
+ if storage is not None:
+ storage.upload_file(tmp_path, bucket, key)
+ else:
+ s3.upload_file_to_s3(tmp_path, bucket, key)
+
+
@celery_app.task(
name="export_dashboard_excel",
bind=True,
@@ -521,7 +532,7 @@ def export_dashboard_excel(
f"{dashboard_id}/{job_id}.xlsx"
)
- s3.upload_file_to_s3(tmp_path, bucket, key)
+ _upload_export_file(tmp_path, bucket, key)
expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=ttl)
# KeyValueEntry.expires_on comparisons use naive datetime.now(), so
# the stored expiry must be naive UTC too, not tz-aware.
diff --git a/superset/utils/export_storage.py b/superset/utils/export_storage.py
new file mode 100644
index 00000000000..13225fb72a0
--- /dev/null
+++ b/superset/utils/export_storage.py
@@ -0,0 +1,58 @@
+# 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.
+"""
+Pluggable storage backend interface for dashboard Excel export artifacts.
+
+Defining ``EXCEL_EXPORT_STORAGE`` in ``superset_config.py`` (an instance of a
+class implementing this protocol, the same pattern as ``RESULTS_BACKEND`` or
+``CUSTOM_SECURITY_MANAGER``) swaps out where the export task uploads the
+generated ``.xlsx`` and how the download redirect mints a fresh, time-limited
+URL for it. When unset, ``superset.utils.s3`` (boto3/AWS S3) is used.
+
+This module has no dependency on any storage SDK: it is safe to import (e.g.
+from ``superset/config.py``) regardless of which storage extras, if any, are
+installed. A concrete implementation -- such as a hypothetical
+``GCSExportStorage`` for deployments where the export bucket is a native
+Google Cloud Storage bucket rather than S3 -- imports its own SDK lazily, the
+same way ``superset.utils.s3`` only imports ``boto3`` inside the functions
+that need it.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class ExportStorage(Protocol):
+ """Where the export task uploads a file, and how a download link resolves
+ it back to a fresh, time-limited URL at click time.
+
+ The two operations run at very different times against the same object:
+ ``upload_file`` once, when the export finishes; ``generate_download_url``
+ every time the (possibly long-lived) download link is clicked, so its
+ credentials never need to outlive the link itself. See
+ ``superset.dashboards.excel_export.download_link`` for why the link is a
+ Superset redirect rather than a raw storage URL.
+ """
+
+ def upload_file(self, local_path: str, bucket: str, key: str) -> None:
+ """Upload a local file to ``bucket``/``key``."""
+
+ def generate_download_url(self, bucket: str, key: str, expires_in: int) -> str:
+ """A time-limited URL for downloading ``bucket``/``key``, valid for
+ ``expires_in`` seconds from now."""
diff --git a/superset/utils/gcs.py b/superset/utils/gcs.py
new file mode 100644
index 00000000000..4050837037c
--- /dev/null
+++ b/superset/utils/gcs.py
@@ -0,0 +1,84 @@
+# 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.
+"""
+``ExportStorage`` implementation backed by Google Cloud Storage, for
+deployments where the dashboard Excel export bucket is a native GCS bucket
+rather than S3.
+
+Set ``EXCEL_EXPORT_STORAGE = GCSExportStorage()`` in ``superset_config.py`` to
+use this instead of the default ``superset.utils.s3`` (boto3/AWS) backend.
+Authentication uses Application Default Credentials (a service account key,
+workload identity, etc.) via the standard ``google-cloud-storage`` resolution
+chain -- there is no separate credential config here.
+"""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Any
+
+
+def _get_client() -> Any:
+ """Build a GCS client using Application Default Credentials."""
+ # Imported lazily, mirroring superset.utils.s3._get_s3_client: importing
+ # this module (which happens at config-load time if EXCEL_EXPORT_STORAGE
+ # is set) should not require google-cloud-storage unless an export
+ # actually runs.
+ try:
+ from google.cloud import storage # pylint: disable=import-outside-toplevel
+ except ImportError as ex:
+ raise ImportError(
+ "google-cloud-storage is required for GCSExportStorage but is not "
+ "installed. Install it with "
+ "`pip install apache-superset[excel-export-gcs]`."
+ ) from ex
+
+ return storage.Client()
+
+
+class GCSExportStorage:
+ """``ExportStorage`` backed by Google Cloud Storage.
+
+ See ``superset.utils.export_storage.ExportStorage`` for the interface this
+ implements.
+ """
+
+ def upload_file(self, local_path: str, bucket: str, key: str) -> None:
+ """
+ Upload a local file to GCS.
+
+ :param local_path: Path to the file on local disk
+ :param bucket: Destination GCS bucket
+ :param key: Destination GCS blob name
+ """
+ _get_client().bucket(bucket).blob(key).upload_from_filename(local_path)
+
+ def generate_download_url(self, bucket: str, key: str, expires_in: int) -> str:
+ """
+ Generate a time-limited signed URL for downloading a GCS object.
+
+ :param bucket: The GCS bucket
+ :param key: The GCS blob name
+ :param expires_in: URL lifetime in seconds
+ :returns: A v4 signed URL
+ """
+ blob = _get_client().bucket(bucket).blob(key)
+ return blob.generate_signed_url(
+ version="v4",
+ expiration=timedelta(seconds=expires_in),
+ method="GET",
+ )
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index afee8190fd4..9841758a16f 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -21,9 +21,11 @@ import uuid
from datetime import datetime, timedelta
from io import BytesIO
from time import sleep
-from unittest.mock import ANY, patch
+from unittest.mock import ANY, MagicMock, patch
from zipfile import is_zipfile, ZipFile
+from flask import current_app
+
from tests.integration_tests.insert_chart_mixin import InsertChartMixin
import pytest
@@ -3582,6 +3584,43 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
"exports", "dashboard-exports/1/job.xlsx", ANY
)
+ def test_download_xlsx_uses_configured_storage_backend(self):
+ """Dashboard API: EXCEL_EXPORT_STORAGE (e.g. GCSExportStorage()) takes
+ over minting the download URL entirely; the default boto3/S3 helper
+ must not also run."""
+ from superset.dashboards.excel_export.download_link import (
+ create_download_link,
+ )
+
+ job_id = uuid.uuid4()
+ create_download_link(
+ job_id,
+ "exports",
+ "dashboard-exports/1/job.xlsx",
+ datetime.now() + timedelta(hours=1),
+ )
+ db.session.commit()
+ mock_storage = MagicMock()
+ mock_storage.generate_download_url.return_value = (
+ "https://storage.googleapis.com/signed"
+ )
+ current_app.config["EXCEL_EXPORT_STORAGE"] = mock_storage
+ try:
+ with patch(
+ "superset.dashboards.api.s3.generate_presigned_url"
+ ) as mock_sign:
+ rv = self.client.get(
+ f"/api/v1/dashboard/export_xlsx/download/{job_id}/"
+ )
+ finally:
+ current_app.config["EXCEL_EXPORT_STORAGE"] = None
+ assert rv.status_code == 302
+ assert rv.headers["Location"] == "https://storage.googleapis.com/signed"
+ mock_storage.generate_download_url.assert_called_once_with(
+ "exports", "dashboard-exports/1/job.xlsx", ANY
+ )
+ mock_sign.assert_not_called()
+
def test_download_xlsx_410_for_unknown_key(self):
rv = self.client.get(f"/api/v1/dashboard/export_xlsx/download/{uuid.uuid4()}/")
assert rv.status_code == 410
diff --git a/tests/unit_tests/tasks/test_export_dashboard_excel.py b/tests/unit_tests/tasks/test_export_dashboard_excel.py
index 829c55a0c0b..15e12294d22 100644
--- a/tests/unit_tests/tasks/test_export_dashboard_excel.py
+++ b/tests/unit_tests/tasks/test_export_dashboard_excel.py
@@ -176,6 +176,26 @@ def test_happy_path_uploads_and_emails(mocks: dict[str, Any]) -> None:
assert _no_temp_files_left(JOB_ID)
+def test_upload_uses_configured_storage_backend(mocks: dict[str, Any]) -> None:
+ # EXCEL_EXPORT_STORAGE (e.g. GCSExportStorage()) takes over the upload
+ # entirely; the default boto3/S3 helper must not also run.
+ from flask import current_app
+
+ mocks["get_charts_in_layout_order"].return_value = [_chart(10, "Good")]
+ mocks["ChartDataCommand"].return_value.run.return_value = {
+ "queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
+ }
+ mock_storage = mock.MagicMock()
+ current_app.config["EXCEL_EXPORT_STORAGE"] = mock_storage
+ try:
+ _run()
+ finally:
+ current_app.config["EXCEL_EXPORT_STORAGE"] = None
+
+ mock_storage.upload_file.assert_called_once()
+ mocks["s3"].upload_file_to_s3.assert_not_called()
+
+
def test_chart_without_query_context_is_skipped(mocks: dict[str, Any]) -> None:
mocks["get_charts_in_layout_order"].return_value = [
_chart(10, "Good"),
diff --git a/tests/unit_tests/utils/gcs_tests.py b/tests/unit_tests/utils/gcs_tests.py
new file mode 100644
index 00000000000..af241626fb5
--- /dev/null
+++ b/tests/unit_tests/utils/gcs_tests.py
@@ -0,0 +1,73 @@
+# 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 __future__ import annotations
+
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.utils.export_storage import ExportStorage
+from superset.utils.gcs import GCSExportStorage
+
+
+@patch("superset.utils.gcs._get_client")
+def test_upload_file(mock_get_client: MagicMock) -> None:
+ blob = mock_get_client.return_value.bucket.return_value.blob.return_value
+
+ GCSExportStorage().upload_file(
+ "exports/out.xlsx", "my-bucket", "exports/1/abc.xlsx"
+ )
+
+ mock_get_client.return_value.bucket.assert_called_once_with("my-bucket")
+ mock_get_client.return_value.bucket.return_value.blob.assert_called_once_with(
+ "exports/1/abc.xlsx"
+ )
+ blob.upload_from_filename.assert_called_once_with("exports/out.xlsx")
+
+
+@patch("superset.utils.gcs._get_client")
+def test_generate_download_url(mock_get_client: MagicMock) -> None:
+ blob = mock_get_client.return_value.bucket.return_value.blob.return_value
+ blob.generate_signed_url.return_value = "https://storage.googleapis.com/signed"
+
+ url = GCSExportStorage().generate_download_url(
+ "my-bucket", "exports/1/abc.xlsx", 300
+ )
+
+ assert url == "https://storage.googleapis.com/signed"
+ blob.generate_signed_url.assert_called_once_with(
+ version="v4",
+ expiration=timedelta(seconds=300),
+ method="GET",
+ )
+
+
+def test_implements_export_storage_protocol() -> None:
+ assert isinstance(GCSExportStorage(), ExportStorage)
+
+
+def test_get_client_missing_dependency_raises_actionable_error() -> None:
+ # Regression: without google-cloud-storage installed (it is only an
+ # optional install), calling into GCSExportStorage must surface an
+ # actionable hint rather than a bare ModuleNotFoundError. This is the
+ # real behavior in this test environment, where the dependency isn't
+ # installed -- no mocking needed to exercise it.
+ from superset.utils import gcs
+
+ with pytest.raises(ImportError, match="excel-export-gcs"):
+ gcs._get_client()