Files
superset2/tests/unit_tests/utils/s3_tests.py
Hugh A Miles II eac1480eb6 fix(dashboard): address Excel export review feedback (timeouts, lock, boto3, image gating)
Addresses @EnxDev's review on #41133:

- Soft timeouts now abort the export instead of being caught per chart: the
  per-chart SoftTimeLimitExceeded handler (and screenshot.py's broad except)
  re-raise, so the outer handler emails a failure and runs cleanup rather than
  running to the hard limit (leaking temp files, holding the lock). Removes the
  now-dead ERROR_TIMEOUT reason.
- Concurrency guard uses a shared, atomic DistributedLock (Redis when
  configured, metadata DB otherwise) instead of cache_manager.cache, which is a
  no-op under the default NullCache and process-local under SimpleCache. The
  lock is released if apply_async fails so a broker outage can't block exports
  until the TTL expires.
- boto3 is declared via a new `excel-export` optional extra; superset.utils.s3
  raises an actionable install hint when it is missing.
- "Export Images to Excel" is gated on the webdriver screenshot feature flags
  (it renders via the headless webdriver); documents EXCEL_EXPORT_TABLE_VIZ_TYPES
  and the image mode in the config docs and UPDATING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:28:20 -04:00

96 lines
3.3 KiB
Python

# 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
import sys
from unittest.mock import MagicMock, patch
import pytest
from superset.utils import s3
@patch("boto3.client")
@patch("superset.utils.s3.current_app")
def test_upload_file_to_s3(mock_app: MagicMock, mock_client_fn: MagicMock) -> None:
mock_app.config = {"EXCEL_EXPORT_S3_CLIENT_KWARGS": {}}
client = mock_client_fn.return_value
s3.upload_file_to_s3("exports/out.xlsx", "my-bucket", "exports/1/abc.xlsx")
mock_client_fn.assert_called_once_with("s3")
client.upload_file.assert_called_once_with(
"exports/out.xlsx", "my-bucket", "exports/1/abc.xlsx"
)
@patch("boto3.client")
@patch("superset.utils.s3.current_app")
def test_client_kwargs_passthrough(
mock_app: MagicMock, mock_client_fn: MagicMock
) -> None:
mock_app.config = {
"EXCEL_EXPORT_S3_CLIENT_KWARGS": {
"endpoint_url": "http://minio:9000",
"region_name": "us-east-1",
}
}
s3.upload_file_to_s3("exports/out.xlsx", "my-bucket", "k")
mock_client_fn.assert_called_once_with(
"s3", endpoint_url="http://minio:9000", region_name="us-east-1"
)
def test_importing_module_does_not_require_boto3() -> None:
# Regression: importing this module (which app startup does via the dashboard
# API) must not require boto3, since it is only an optional install.
import importlib
with patch.dict(sys.modules, {"boto3": None}):
importlib.reload(s3)
# Reload again with boto3 available so later tests see the normal module.
importlib.reload(s3)
def test_get_s3_client_missing_boto3_raises_actionable_error() -> None:
# Simulate a production install without boto3: the lazy import fails and we
# surface an install hint instead of a bare ModuleNotFoundError.
with (
patch.dict(sys.modules, {"boto3": None}),
pytest.raises(ImportError, match="excel-export"),
):
s3._get_s3_client()
@patch("boto3.client")
@patch("superset.utils.s3.current_app")
def test_generate_presigned_url(mock_app: MagicMock, mock_client_fn: MagicMock) -> None:
mock_app.config = {"EXCEL_EXPORT_S3_CLIENT_KWARGS": {}}
client = mock_client_fn.return_value
client.generate_presigned_url.return_value = "https://signed.example/abc"
url = s3.generate_presigned_url("my-bucket", "exports/1/abc.xlsx", 86400)
assert url == "https://signed.example/abc"
client.generate_presigned_url.assert_called_once_with(
"get_object",
Params={"Bucket": "my-bucket", "Key": "exports/1/abc.xlsx"},
ExpiresIn=86400,
)