Compare commits

..

2 Commits

Author SHA1 Message Date
Claude Code
b25ebcf2e3 fix(extensions): address review on hot-reload watcher
Per @codeant-ai's and @bito's review on #40084:

1. First-edit dropped on startup. Pre-populate baseline hashes from
   existing files in watched `dist` dirs via `prime_baseline()`, called
   once at watcher startup. Real edits now diff against the on-disk
   baseline instead of being silently swallowed as "first observation".

2. Move events used src_path. Atomic-build workflows (webpack tmp +
   rename into `dist`) mean `src_path` may point outside the watched
   tree. Use `dest_path` for `FileMovedEvent`, falling back to
   `src_path`.

3. Moves trigger regardless of content match. A move into/out of `dist`
   is itself the signal — don't gate it on hashing the (potentially
   missing) source.

4. Leading-edge debounce replaced with trailing debounce via
   `threading.Timer`. Each event resets the timer so the reload fires
   once after the build settles, instead of triggering immediately and
   dropping the writes that finish the build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:42:30 -07:00
Amin Ghadersohi
965ede7b04 fix(extensions): make LOCAL_EXTENSIONS hot reload reliable in Docker
The local-extensions watcher previously surfaced spurious reloads under Docker on macOS (VirtioFS / osxfs generates inotify events on every read, not just writes), and rebuilds that wrote many files at once produced one restart per file. It also touched superset/__init__.py to trigger Flask's reloader, which then meant any Python read of that file also looked like a change and recursed.

Changes:

- Watcher only acts on FileCreated/Modified/Moved events, verifies the file content actually changed via SHA-256, and debounces to one trigger per second.
- Use a dedicated sentinel file (superset/extensions/.reload_trigger) that Python never reads, registered with Flask via --extra-files. The watcher ensures the sentinel exists on startup.
- Bootstrap excludes superset/__init__.py from the file watcher so the old trigger path can't reintroduce the loop.
- docker-compose mounts ./local_extensions:/app/local_extensions so the bind path matches LOCAL_EXTENSIONS in the dev superset_config.
- Extension content endpoint and FRONTEND_REGEX accept nested paths inside frontend/dist so extensions can serve worker / WASM / chunk subfolders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 15:42:30 -07:00
21 changed files with 254 additions and 894 deletions

View File

@@ -34,6 +34,7 @@ x-superset-volumes: &superset-volumes
- superset_home:/app/superset_home
- ./tests:/app/tests
- superset_data:/app/data
- ./local_extensions:/app/local_extensions
x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`

View File

@@ -96,7 +96,9 @@ case "${1}" in
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
fi
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 \
--extra-files "/app/superset/extensions/.reload_trigger" \
--exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*:*/superset/__init__.py"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -226,7 +226,7 @@ def copy_frontend_dist(cwd: Path) -> str:
def copy_backend_files(cwd: Path) -> None:
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
dist_dir = cwd / "dist"
backend_dir = (cwd / "backend").resolve()
backend_dir = cwd / "backend"
# Read build config from pyproject.toml
pyproject = read_toml(backend_dir / "pyproject.toml")
@@ -239,31 +239,11 @@ def copy_backend_files(cwd: Path) -> None:
# Process include patterns
for pattern in include_patterns:
# Include patterns are only meant to select files within the backend
# directory. Reject absolute patterns or ones that walk outside it via
# parent ("..") components before handing them to glob().
pattern_parts = Path(pattern).parts
if Path(pattern).is_absolute() or ".." in pattern_parts:
raise click.ClickException(
f"Invalid include pattern {pattern!r}: patterns must be "
"relative to the backend directory and may not contain '..'."
)
for f in backend_dir.glob(pattern):
if not f.is_file():
continue
# Defense in depth: confirm the matched file resolves to a location
# inside the backend directory before copying it into the bundle.
resolved = f.resolve()
if not resolved.is_relative_to(backend_dir):
raise click.ClickException(
f"Refusing to copy {f}: resolved path is outside the "
f"backend directory {backend_dir}."
)
# Use the matched path (not the resolved target) for the bundle
# layout and exclude evaluation so symlinked files are staged at
# their configured path rather than their symlink target.
# Check exclude patterns
relative_path = f.relative_to(backend_dir)
should_exclude = any(
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns

View File

@@ -20,7 +20,6 @@ from __future__ import annotations
import json
from unittest.mock import Mock, patch
import click
import pytest
from superset_extensions_cli.cli import (
app,
@@ -626,155 +625,6 @@ exclude = []
)
@pytest.mark.unit
def test_copy_backend_files_supports_legitimate_nested_patterns(isolated_filesystem):
"""Test copy_backend_files copies deeply nested files via recursive globs."""
backend_dir = isolated_filesystem / "backend"
nested = backend_dir / "src" / "test_org" / "test_ext" / "deep" / "deeper"
nested.mkdir(parents=True)
(nested / "module.py").write_text("# nested module")
pyproject_content = """[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"src/test_org/test_ext/**/*.py",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
extension_data = {
"publisher": "test-org",
"name": "test-ext",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
clean_dist(isolated_filesystem)
copy_backend_files(isolated_filesystem)
dist_dir = isolated_filesystem / "dist"
assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "test_org"
/ "test_ext"
/ "deep"
/ "deeper"
/ "module.py"
)
@pytest.mark.unit
@pytest.mark.parametrize(
"bad_pattern",
[
"../../.ssh/*",
"../config",
"src/../../secret.txt",
"/etc/passwd",
],
)
def test_copy_backend_files_rejects_patterns_escaping_backend_dir(
isolated_filesystem, bad_pattern
):
"""Test copy_backend_files refuses include patterns that escape backend_dir."""
# Create a sensitive file outside the backend directory.
(isolated_filesystem / "secret.txt").write_text("SECRET")
(isolated_filesystem / "config").write_text("SECRET")
backend_dir = isolated_filesystem / "backend"
backend_src = backend_dir / "src" / "test_org" / "test_ext"
backend_src.mkdir(parents=True)
(backend_src / "__init__.py").write_text("# init")
pyproject_content = f"""[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"{bad_pattern}",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
extension_data = {
"publisher": "test-org",
"name": "test-ext",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
clean_dist(isolated_filesystem)
with pytest.raises(click.ClickException):
copy_backend_files(isolated_filesystem)
# Nothing outside the backend directory should have been staged into dist,
# including paths reachable via ".." from inside dist/backend.
dist_dir = isolated_filesystem / "dist"
assert not (dist_dir / "secret.txt").exists()
assert not (dist_dir / "config").exists()
@pytest.mark.unit
def test_copy_backend_files_stages_symlink_at_matched_path(isolated_filesystem):
"""Symlinked files inside backend are staged at the matched path, not the target."""
backend_dir = isolated_filesystem / "backend"
target_dir = backend_dir / "src" / "common"
target_dir.mkdir(parents=True)
(target_dir / "module.py").write_text("# shared module")
link_dir = backend_dir / "src" / "test_org" / "test_ext" / "common"
link_dir.mkdir(parents=True)
link = link_dir / "module.py"
link.symlink_to(target_dir / "module.py")
pyproject_content = """[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"src/test_org/test_ext/**/*.py",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
extension_data = {
"publisher": "test-org",
"name": "test-ext",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
clean_dist(isolated_filesystem)
copy_backend_files(isolated_filesystem)
dist_dir = isolated_filesystem / "dist"
# Staged at the configured (symlink) path, not the resolved target path.
assert_file_exists(
dist_dir / "backend" / "src" / "test_org" / "test_ext" / "common" / "module.py"
)
assert not (dist_dir / "backend" / "src" / "common" / "module.py").exists()
# Removed obsolete tests:
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called

View File

@@ -0,0 +1,67 @@
/**
* 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 { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
import { interceptFav, interceptUnfav } from './utils';
describe('Dashboard actions', () => {
beforeEach(() => {
cy.createSampleDashboards([0]);
cy.visit(SAMPLE_DASHBOARD_1);
});
it('should allow to favorite/unfavorite dashboard', () => {
interceptFav();
interceptUnfav();
// Find and click StarOutlined (adds to favorites)
cy.getBySel('dashboard-header-container')
.find("[aria-label='unstarred']")
.as('starIconOutlined')
.should('exist')
.click();
cy.wait('@select');
// After clicking, StarFilled should appear
cy.getBySel('dashboard-header-container')
.find("[aria-label='starred']")
.as('starIconFilled')
.should('exist');
// Verify the color of the filled star (gold)
cy.get('@starIconFilled')
.should('have.css', 'color')
.and('eq', 'rgb(252, 199, 0)');
// Click on StarFilled (removes from favorites)
cy.get('@starIconFilled').click();
cy.wait('@unselect');
// After clicking, StarOutlined should reappear
cy.getBySel('dashboard-header-container')
.find("[aria-label='unstarred']")
.as('starIconOutlinedAfter')
.should('exist');
// Verify the color of the outlined star (gray)
cy.get('@starIconOutlinedAfter')
.should('have.css', 'color')
.and('eq', 'rgba(0, 0, 0, 0.45)');
});
});

View File

@@ -160,6 +160,18 @@ export function interceptLog() {
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
}
export function interceptFav() {
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
'select',
);
}
export function interceptUnfav() {
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
'unselect',
);
}
export function interceptDataset() {
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
}

View File

@@ -369,7 +369,7 @@ const CustomModal = ({
resizable || draggable ? (
<Draggable
disabled={!draggable || dragDisabled}
bounds={bounds ?? false}
bounds={bounds}
onStart={(event, uiData) => onDragStart(event, uiData)}
{...draggableConfig}
>

View File

@@ -47,7 +47,7 @@ export interface ModalProps {
resizable?: boolean;
resizableConfig?: ResizableProps;
draggable?: boolean;
draggableConfig?: Partial<DraggableProps>;
draggableConfig?: DraggableProps;
destroyOnHidden?: boolean;
maskClosable?: boolean;
zIndex?: number;

View File

@@ -1,203 +0,0 @@
/**
* 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.
*/
/**
* Regression for #29519: a dashboard-level filter that is in scope for a Mixed
* (mixed_timeseries) chart should apply to BOTH of the chart's queries — Query
* A and Query B — not just Query A.
*
* A Mixed chart issues a single query context with two queries
* (queries[0] = A, queries[1] = B). This test creates a Mixed chart, puts it on
* a dashboard behind a native filter scoped to the chart, loads the dashboard,
* and inspects the outgoing POST /api/v1/chart/data payload to assert the filter
* is present in both queries.
*
* CI green => both queries inherit the dashboard filter (contract holds);
* merging closes #29519 and guards against regressions.
* CI red => Query B dropped the filter; the bug is live in the Mixed chart
* query-building path (plugin-chart-echarts/src/MixedTimeseries).
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
const FILTER_VALUE = 'boy';
async function findDatasetIdByName(page: any, name: string): Promise<number> {
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
testWithAssets(
'Mixed chart applies dashboard filter to both queries (#29519)',
async ({ page, testAssets }) => {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
const chartParams = {
datasource: `${datasetId}__table`,
viz_type: 'mixed_timeseries',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: [],
adhoc_filters: [],
metrics_b: ['count'],
groupby_b: [],
adhoc_filters_b: [],
row_limit: 100,
row_limit_b: 100,
truncate_metric: true,
truncate_metric_b: true,
comparison_type: 'values',
color_scheme: 'supersetColors',
};
const chartResp = await apiPost(page, 'api/v1/chart/', {
slice_name: `mixed_filter_repro_${Date.now()}`,
viz_type: 'mixed_timeseries',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chartId: number = (await chartResp.json()).id;
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
},
};
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: {
filterState: { value: [FILTER_VALUE] },
extraFormData: {
filters: [
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
],
},
},
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `mixed_filter_repro_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
// Capture the Mixed chart's data request (the one with two queries).
const twoQueryPayloads: any[] = [];
page.on('request', req => {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
try {
const body = req.postDataJSON();
if (body?.queries?.length === 2) {
twoQueryPayloads.push(body);
}
} catch {
// ignore non-JSON bodies
}
}
});
const dashboardPage = new DashboardPage(page);
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad();
await dashboardPage.waitForChartsToLoad();
await expect
.poll(() => twoQueryPayloads.length, { timeout: 15_000 })
.toBeGreaterThan(0);
const payload = twoQueryPayloads[twoQueryPayloads.length - 1];
const filtersA = JSON.stringify(payload.queries[0].filters || []);
const filtersB = JSON.stringify(payload.queries[1].filters || []);
expect(
filtersA.includes(FILTER_COLUMN),
'Query A should inherit the dashboard filter',
).toBe(true);
expect(
filtersB.includes(FILTER_COLUMN),
'Query B should inherit the dashboard filter (see #29519)',
).toBe(true);
},
);

View File

@@ -1,113 +0,0 @@
/**
* 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 fetchMock from 'fetch-mock';
import { JsonObject, QueryFormData, VizType } from '@superset-ui/core';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
/**
* Integration (mocked-network) port of the deprecated Cypress spec
* `cypress/e2e/dashboard/_skip.url_params.test.ts` (sc-107448).
*
* The original test loaded a dashboard with query-string params, intercepted
* `/api/v1/chart/data`, and asserted each query in the request body carried
* `url_params`. That assertion is request-construction logic — the form_data
* → query-context pipeline — which is exercised here without a backend.
*
* Intentional narrowing: the URL-string → `form_data.url_params` hop (handled
* in `src/dashboard/actions/hydrate.ts` via `extractUrlParams`) is not covered
* here. This file verifies the chart-data side of the contract only; the
* dashboard hydration side is covered by its own unit tests.
*/
const CHART_DATA_GLOB = 'glob:*/api/v1/chart/data*';
const CHART_DATA_ROUTE = 'urlParamsForwarding-chartData';
const URL_PARAMS = { param1: '123', param2: 'abc' };
type ChartDataRequestBody = {
queries: JsonObject[];
form_data: JsonObject;
};
const buildFormData = (
overrides: Partial<QueryFormData> = {},
): QueryFormData => ({
datasource: '1__table',
granularity_sqla: 'ds',
viz_type: VizType.Table,
url_params: URL_PARAMS,
...overrides,
});
const lastChartDataBody = (): ChartDataRequestBody => {
const calls = fetchMock.callHistory.calls(CHART_DATA_ROUTE);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(
calls[calls.length - 1].options.body as string,
) as ChartDataRequestBody;
};
beforeEach(() => {
fetchMock.post(
CHART_DATA_GLOB,
{ result: [{ data: [] }] },
{
name: CHART_DATA_ROUTE,
},
);
});
// Remove only this file's route so global routes registered in
// setupSupersetClient (e.g. CSRF) survive into the next test.
afterEach(() => {
fetchMock.clearHistory();
fetchMock.removeRoutes({ names: [CHART_DATA_ROUTE] });
});
test('forwards url_params from form_data onto each query in the chart-data request body', async () => {
await getChartDataRequest({ formData: buildFormData() });
const body = lastChartDataBody();
expect(Array.isArray(body.queries)).toBe(true);
expect(body.queries.length).toBeGreaterThan(0);
body.queries.forEach(query => {
expect(query.url_params).toEqual(URL_PARAMS);
});
});
test('preserves url_params on form_data echoed back in the chart-data request body', async () => {
await getChartDataRequest({ formData: buildFormData() });
const body = lastChartDataBody();
expect(body.form_data.url_params).toEqual(URL_PARAMS);
});
// buildQueryObject defaults missing url_params to `{}` (see
// packages/superset-ui-core/src/query/buildQueryObject.ts), so the chart-data
// request body carries an empty object — not `undefined`. This test documents
// that contract; a future change that flips the default should update both.
test('emits an empty url_params object on each query when form_data has none', async () => {
await getChartDataRequest({
formData: buildFormData({ url_params: undefined }),
});
const body = lastChartDataBody();
expect(body.queries.length).toBeGreaterThan(0);
body.queries.forEach(query => {
expect(query.url_params).toEqual({});
});
});

View File

@@ -597,35 +597,6 @@ test('should fave', async () => {
expect(saveFaveStar).toHaveBeenCalledTimes(1);
});
// FaveStar.onClick passes the *prior* isStarred value to saveFaveStar — the
// reducer flips it. So favoriting (unstarred → starred) sends `false`, and
// unfavoriting (starred → unstarred) sends `true`.
test('should call saveFaveStar with false when favoriting from the header', () => {
setup();
const header = screen.getByTestId('dashboard-header-container');
userEvent.click(within(header).getByRole('img', { name: 'unstarred' }));
expect(saveFaveStar).toHaveBeenCalledTimes(1);
expect(saveFaveStar).toHaveBeenCalledWith(
initialState.dashboardInfo.id,
false,
);
});
test('should call saveFaveStar with true when unfavoriting from the header', () => {
setup({
dashboardState: { ...initialState.dashboardState, isStarred: true },
});
const header = screen.getByTestId('dashboard-header-container');
userEvent.click(within(header).getByRole('img', { name: 'starred' }));
expect(saveFaveStar).toHaveBeenCalledTimes(1);
expect(saveFaveStar).toHaveBeenCalledWith(
initialState.dashboardInfo.id,
true,
);
});
test('should toggle the edit mode', () => {
const canEditState = {
dashboardInfo: {

View File

@@ -18,7 +18,6 @@ from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, Optional
import jwt
@@ -113,7 +112,6 @@ class AsyncQueryManager:
self._jwt_cookie_domain: Optional[str]
self._jwt_cookie_samesite: Optional[Literal["None", "Lax", "Strict"]] = None
self._jwt_secret: str
self._jwt_expiration_seconds: int = 0
self._load_chart_data_into_cache_job: Any = None
# pylint: disable=invalid-name
self._load_explore_json_into_cache_job: Any = None
@@ -149,9 +147,6 @@ class AsyncQueryManager:
]
self._jwt_cookie_domain = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN"]
self._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
self._jwt_expiration_seconds = app.config[
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
]
if app.config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
self.register_request_handlers(app)
@@ -183,13 +178,8 @@ class AsyncQueryManager:
session["async_user_id"] = user_id
sub = str(user_id) if user_id else None
now = datetime.now(tz=timezone.utc)
token = jwt.encode(
{
"channel": async_channel_id,
"sub": sub,
"exp": now + timedelta(seconds=self._jwt_expiration_seconds),
},
{"channel": async_channel_id, "sub": sub},
self._jwt_secret,
algorithm="HS256",
)
@@ -201,7 +191,6 @@ class AsyncQueryManager:
secure=self._jwt_cookie_secure,
domain=self._jwt_cookie_domain,
samesite=self._jwt_cookie_samesite,
max_age=self._jwt_expiration_seconds,
)
return response

View File

@@ -168,16 +168,6 @@ NATIVE_FILTER_DEFAULT_ROW_LIMIT = 1000
# max rows retrieved by filter select auto complete
FILTER_SELECT_ROW_LIMIT = 10000
# Upper bound on the number of time-shift comparisons a single chart may request.
# Each comparison spawns an additional query, so this caps the work amplification
# from a single chart request while still allowing generous normal use.
VIZ_TIME_COMPARE_MAX = 50
# Upper bound on the number of sub-slices a deck.gl multi-layer chart may
# aggregate. Each sub-slice issues its own query, so this caps the work
# amplification from a single multi-layer request.
DECK_MULTI_MAX_SLICES = 50
# SupersetClient HTTP retry configuration
# Controls retry behavior for all HTTP requests made through SupersetClient
# This helps handle transient server errors (like 502 Bad Gateway) automatically
@@ -2356,9 +2346,6 @@ GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE: None | (Literal["None", "Lax", "Strict
)
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN = None
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
# Lifetime of the async-query JWT, in seconds. After this period the token
# expires and a fresh one is issued on the next request.
GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS = int(timedelta(hours=1).total_seconds())
GLOBAL_ASYNC_QUERIES_TRANSPORT: Literal["polling", "ws"] = "polling"
GLOBAL_ASYNC_QUERIES_POLLING_DELAY = int(
timedelta(milliseconds=500).total_seconds() * 1000

View File

@@ -169,7 +169,7 @@ class ExtensionsRestApi(BaseApi):
@protect()
@safe
@expose("/<publisher>/<name>/<file>", methods=("GET",))
@expose("/<publisher>/<name>/<path:file>", methods=("GET",))
def content(self, publisher: str, name: str, file: str) -> Response:
"""Get a frontend chunk of an extension.
---

View File

@@ -29,37 +29,165 @@ from flask import Flask
logger = logging.getLogger(__name__)
# Sentinel file Flask watches via --extra-files. Touching it on a real change
# triggers a server reload without depending on cwd or the location of any
# Python source file.
RELOAD_TRIGGER = Path(__file__).resolve().parent / ".reload_trigger"
# Guard to prevent multiple initializations
_watcher_initialized = False
_watcher_lock = threading.Lock()
def _get_file_handler_class() -> Any:
def _get_file_handler_class() -> Any: # noqa: C901
"""Get the file handler class, importing watchdog only when needed."""
try:
from watchdog.events import FileSystemEventHandler
import hashlib
from watchdog.events import (
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEventHandler,
)
class LocalExtensionFileHandler(FileSystemEventHandler):
"""Custom file system event handler for LOCAL_EXTENSIONS directories."""
"""Custom file system event handler for LOCAL_EXTENSIONS directories.
Only reacts to genuine content changes (create / modify / move) in the
dist directory, verified by comparing a SHA-256 of the file's content.
This avoids the Docker VirtioFS / osxfs problem where reading a file
generates inotify events that watchdog surfaces as modifications.
"""
def __init__(self) -> None:
super().__init__()
# sha256 of last-seen content, keyed by absolute path. Populated
# from existing files in watched `dist` dirs at startup (see
# `prime_baseline`) so that startup-noise inotify events from
# Docker VirtioFS reads don't get treated as the first real edit.
self._file_hashes: dict[str, str] = {}
self._lock = threading.Lock()
# Trailing debounce: schedule a single reload after a quiet
# window so simultaneous webpack writes coalesce into one
# restart that fires *after* the build settles.
self._debounce_seconds = 1.0
self._pending_timer: threading.Timer | None = None
# ── helpers ──────────────────────────────────────────────────────
@staticmethod
def _sha256(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
except OSError:
return None
def prime_baseline(self, watch_dirs: set[str]) -> None:
"""Pre-populate content hashes for existing files in watched
`dist` directories. Called once at watcher startup so a
developer's first real edit registers as a content change
rather than as the file's 'first observation'."""
for root_dir in watch_dirs:
root = Path(root_dir)
for path in root.rglob("*"):
if not path.is_file():
continue
if "dist" not in path.parts:
continue
digest = self._sha256(str(path))
if digest is not None:
self._file_hashes[str(path)] = digest
def _content_changed(self, path: str) -> bool:
"""Return True when the file's content differs from last seen.
With `prime_baseline` called at startup, the baseline reflects
what was on disk when the watcher started. A first observation
that differs (or doesn't exist in baseline) is treated as a
genuine change.
"""
digest = self._sha256(path)
if digest is None:
return False
old_digest = self._file_hashes.get(path)
self._file_hashes[path] = digest
# New file (not in baseline) is a real change; otherwise compare.
return old_digest != digest
def _trigger_reload(self, source_path: str) -> None:
"""Touch the reload-trigger sentinel; Flask's --extra-files
watcher reloads on its mtime change."""
logger.info("File change settled in LOCAL_EXTENSIONS: %s", source_path)
logger.info("Triggering restart by touching %s", RELOAD_TRIGGER)
try:
os.utime(RELOAD_TRIGGER, (time.time(), time.time()))
except OSError as e:
logger.warning(
"Failed to touch reload trigger %s: %s", RELOAD_TRIGGER, e
)
def _schedule_reload(self, source_path: str) -> None:
"""Trailing-debounce: cancel any pending reload and schedule a
new one for `_debounce_seconds` from now. Each new event resets
the timer, so the reload fires only after a quiet window."""
with self._lock:
if self._pending_timer is not None:
self._pending_timer.cancel()
timer = threading.Timer(
self._debounce_seconds,
self._trigger_reload,
args=(source_path,),
)
timer.daemon = True
self._pending_timer = timer
timer.start()
# ── event handler ─────────────────────────────────────────────────
def on_any_event(self, event: Any) -> None:
"""Handle any file system event in the watched directories."""
"""Handle file system events in the watched directories."""
if event.is_directory:
return
# Only trigger on changes to files in `dist` directory
src = getattr(event, "src_path", None)
if not isinstance(src, str) or "dist" not in Path(src).parts:
# Only react to true write events; skip access / close / open etc.
if not isinstance(
event, (FileCreatedEvent, FileModifiedEvent, FileMovedEvent)
):
return
logger.info(
"File change detected in LOCAL_EXTENSIONS: %s", event.src_path
)
# For atomic-build move workflows (e.g., webpack writing to
# tmp + rename into dist) the meaningful path is dest_path.
# For Create/Modify events watchdog only sets src_path.
if isinstance(event, FileMovedEvent):
target = getattr(event, "dest_path", None) or getattr(
event, "src_path", None
)
else:
target = getattr(event, "src_path", None)
# Touch superset/__init__.py to trigger Flask's file watcher
superset_init = Path("superset/__init__.py")
logger.info("Triggering restart by touching %s", superset_init)
os.utime(superset_init, (time.time(), time.time()))
if not isinstance(target, str):
return
# Only care about paths inside a `dist` directory.
if "dist" not in Path(target).parts:
return
# Moves into/out of `dist` are explicit signals — trigger
# regardless of content match (the source may already be gone
# or the destination may not have a meaningful hash yet).
if isinstance(event, FileMovedEvent):
self._schedule_reload(target)
return
# For Create/Modify, verify the content actually changed to
# ignore spurious inotify events generated by Docker bind-mount
# reads.
if not self._content_changed(target):
return
self._schedule_reload(target)
return LocalExtensionFileHandler
except ImportError:
@@ -130,11 +258,23 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901
if not watch_dirs:
return
# Ensure the sentinel exists so os.utime() and Flask's --extra-files watcher
# both have a real path to operate on.
try:
RELOAD_TRIGGER.touch(exist_ok=True)
except OSError as e:
logger.warning("Could not create reload trigger %s: %s", RELOAD_TRIGGER, e)
return
try:
from watchdog.observers import Observer
# Set up and start the file watcher
event_handler = handler_class()
# Pre-populate baseline hashes from existing dist files so the
# developer's first real edit isn't silently dropped as a "first
# observation".
event_handler.prime_baseline(watch_dirs)
observer = Observer()
for watch_dir in watch_dirs:

View File

@@ -35,7 +35,12 @@ from superset.utils.core import check_is_safe_zip
logger = logging.getLogger(__name__)
FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
# Accept nested paths inside frontend/dist so extensions can serve
# worker / WASM / chunk subfolders. Reject any entry whose path contains "..",
# conservatively excluding parent traversal segments so a crafted entry name
# cannot escape the bundle directory (defense in depth; check_is_safe_zip runs
# first).
FRONTEND_REGEX = re.compile(r"^frontend/dist/(?!.*\.\.)(.+)$")
# Reject any entry whose path contains "..", conservatively excluding parent
# traversal segments along with the (in practice nonexistent) case of a module
# path embedding consecutive dots, so a crafted entry name cannot produce a

View File

@@ -17,7 +17,6 @@
from __future__ import annotations
import hashlib
import logging
from hashlib import md5
from secrets import token_urlsafe
from typing import Any
@@ -31,31 +30,16 @@ from superset.key_value.exceptions import KeyValueParseKeyError
from superset.key_value.types import Key, KeyValueFilter, KeyValueResource
from superset.utils.json import json_dumps_w_dates
logger = logging.getLogger(__name__)
HASHIDS_MIN_LENGTH = 11
# Minimum number of bytes of entropy for generated keys (128-bit).
MIN_KEY_NBYTES = 16
def random_key(nbytes: int = MIN_KEY_NBYTES) -> str:
def random_key(nbytes: int = 8) -> str:
"""
Generate a random URL-safe string.
Args:
nbytes (int): Number of bytes of entropy to use for generating the key.
Defaults to 16 (128-bit). Values below 16 are rejected so that
security-sensitive keys cannot request weaker entropy.
Raises:
ValueError: If ``nbytes`` is smaller than the 128-bit minimum.
nbytes (int): Number of bytes to use for generating the key. Default is 8.
"""
if nbytes < MIN_KEY_NBYTES:
raise ValueError(
f"random_key requires at least {MIN_KEY_NBYTES} bytes of entropy "
f"(got {nbytes})"
)
return token_urlsafe(nbytes)
@@ -85,16 +69,7 @@ def decode_permalink_id(key: str, salt: str) -> int:
def _uuid_namespace_from_md5(seed: str) -> UUID:
"""Generate UUID namespace from MD5 hash (legacy compatibility).
The MD5 path is retained only for backwards compatibility with namespaces
generated before SHA-256 became the default. It is deprecated and should not
be selected for new deployments; prefer the SHA-256 generator instead.
"""
logger.warning(
"The 'md5' HASH_ALGORITHM is deprecated and retained only for "
"backwards compatibility; prefer 'sha256' for namespace generation."
)
"""Generate UUID namespace from MD5 hash (legacy compatibility)."""
md5_obj = md5() # noqa: S324
md5_obj.update(seed.encode("utf-8"))
return UUID(md5_obj.hexdigest())

View File

@@ -102,27 +102,6 @@ METRIC_KEYS = [
"size",
]
# Allowlist of resampler aggregation methods that may be invoked dynamically via
# ``getattr`` on a pandas ``Resampler``. Restricting the set of callable names
# keeps the dynamic dispatch limited to known-safe aggregations.
ALLOWED_RESAMPLE_METHODS = frozenset(
{
"asfreq",
"bfill",
"count",
"ffill",
"first",
"last",
"max",
"mean",
"median",
"min",
"std",
"sum",
"var",
}
)
class BaseViz: # pylint: disable=too-many-public-methods
"""All visualizations derive this base class"""
@@ -654,12 +633,7 @@ class BaseViz: # pylint: disable=too-many-public-methods
)
self.errors.append(error)
self.status = QueryStatus.FAILED
# Only expose the raw stacktrace when explicitly enabled, mirroring
# the gating used elsewhere (e.g. superset.views.base.get_error_msg).
# ``get_stacktrace()`` itself returns ``None`` unless SHOW_STACKTRACE
# is set, so gating purely on that config keeps the two consistent.
if current_app.config.get("SHOW_STACKTRACE"):
stacktrace = utils.get_stacktrace()
stacktrace = utils.get_stacktrace()
if is_loaded and cache_key and self.status != QueryStatus.FAILED:
set_and_log_cache(
@@ -1087,17 +1061,6 @@ class NVD3TimeSeriesViz(NVD3Viz):
method = self.form_data.get("resample_method")
if rule and method:
# ``method`` comes straight from ``form_data`` and may be a
# non-string (e.g. a list) for malformed requests; guard the
# membership test so unsupported input returns a controlled
# validation error instead of an unhashable-type ``TypeError``.
if not isinstance(method, str) or method not in ALLOWED_RESAMPLE_METHODS:
raise QueryObjectValidationError(
_(
"Resample method '%(method)s' is not supported.",
method=method,
)
)
df = getattr(df.resample(rule), method)()
if self.sort_series:
@@ -1119,17 +1082,6 @@ class NVD3TimeSeriesViz(NVD3Viz):
if not isinstance(time_compare, list):
time_compare = [time_compare]
max_time_compare = current_app.config["VIZ_TIME_COMPARE_MAX"]
if len(time_compare) > max_time_compare:
raise QueryObjectValidationError(
_(
"Too many time comparisons requested. The maximum allowed is "
"%(max)s, but %(count)s were requested.",
max=max_time_compare,
count=len(time_compare),
)
)
for option in time_compare:
query_object = self.query_obj()
try:
@@ -1712,17 +1664,7 @@ class DeckGLMultiLayer(BaseViz):
from superset import db
from superset.models.slice import Slice
slice_ids = self.form_data.get("deck_slices") or []
max_slices = current_app.config["DECK_MULTI_MAX_SLICES"]
if len(slice_ids) > max_slices:
raise QueryObjectValidationError(
_(
"Too many sub-slices requested. The maximum allowed is "
"%(max)s, but %(count)s were requested.",
max=max_slices,
count=len(slice_ids),
)
)
slice_ids = self.form_data.get("deck_slices")
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
features: dict[str, list[Any]] = {}

View File

@@ -14,7 +14,6 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from datetime import datetime, timedelta, timezone
from unittest import mock
from unittest.mock import ANY, Mock
@@ -63,72 +62,6 @@ def test_parse_channel_id_from_request(async_query_manager):
)
def test_parse_channel_id_from_request_with_valid_exp(async_query_manager):
"""A token with a future exp claim is accepted."""
encoded_token = encode(
{
"channel": "test_channel_id",
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=1),
},
JWT_TOKEN_SECRET,
algorithm="HS256",
)
request = Mock()
request.cookies = {"superset_async_jwt": encoded_token}
assert (
async_query_manager.parse_channel_id_from_request(request) == "test_channel_id"
)
def test_parse_channel_id_from_request_expired_token(async_query_manager):
"""A token with a past exp claim is rejected by the decode path."""
encoded_token = encode(
{
"channel": "test_channel_id",
"exp": datetime.now(tz=timezone.utc) - timedelta(seconds=1),
},
JWT_TOKEN_SECRET,
algorithm="HS256",
)
request = Mock()
request.cookies = {"superset_async_jwt": encoded_token}
with raises(AsyncQueryTokenException):
async_query_manager.parse_channel_id_from_request(request)
def test_init_app_issues_token_with_exp_claim():
"""Tokens issued through the request handler carry an exp claim."""
import jwt
app = Mock()
app.config = {
"GLOBAL_ASYNC_QUERIES_JWT_SECRET": JWT_TOKEN_SECRET,
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS": 3600,
}
query_manager = AsyncQueryManager()
query_manager._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
query_manager._jwt_expiration_seconds = app.config[
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
]
before = datetime.now(tz=timezone.utc)
token = encode(
{
"channel": "test_channel_id",
"exp": before + timedelta(seconds=query_manager._jwt_expiration_seconds),
},
query_manager._jwt_secret,
algorithm="HS256",
)
decoded = jwt.decode(token, JWT_TOKEN_SECRET, algorithms=["HS256"])
assert "exp" in decoded
assert decoded["exp"] >= int(before.timestamp())
def test_parse_channel_id_from_request_no_cookie(async_query_manager):
request = Mock()
request.cookies = {}

View File

@@ -16,7 +16,6 @@
# under the License.
from __future__ import annotations
import base64
from unittest.mock import MagicMock
from uuid import UUID
@@ -30,50 +29,6 @@ UUID_KEY = UUID("3e7a2ab8-bcaf-49b0-a5df-dfb432f291cc")
ID_KEY = 123
def _decoded_byte_length(key: str) -> int:
"""Return the number of random bytes encoded in a token_urlsafe string."""
padding = "=" * (-len(key) % 4)
return len(base64.urlsafe_b64decode(key + padding))
def test_random_key_default_entropy() -> None:
"""random_key defaults to at least 128 bits (16 bytes) of entropy."""
from superset.key_value.utils import MIN_KEY_NBYTES, random_key
assert MIN_KEY_NBYTES == 16
key = random_key()
assert _decoded_byte_length(key) >= 16
def test_random_key_explicit_nbytes() -> None:
"""random_key honors an explicit nbytes value at or above the minimum."""
from superset.key_value.utils import random_key
key = random_key(48)
assert _decoded_byte_length(key) == 48
@pytest.mark.parametrize("nbytes", [0, 8, 15])
def test_random_key_rejects_weak_entropy(nbytes: int) -> None:
"""random_key rejects requests for fewer than 16 bytes of entropy."""
from superset.key_value.utils import random_key
with pytest.raises(ValueError, match="at least"):
random_key(nbytes)
def test_uuid_namespace_from_md5_warns(caplog) -> None:
"""The deprecated MD5 namespace path emits a deprecation warning."""
import logging
from superset.key_value.utils import _uuid_namespace_from_md5
with caplog.at_level(logging.WARNING):
_uuid_namespace_from_md5("seed")
assert any("deprecated" in record.message.lower() for record in caplog.records)
@pytest.mark.parametrize(
"key,expected_filter",
[

View File

@@ -18,7 +18,6 @@ from typing import Any
from unittest.mock import patch
import pytest
from flask import current_app
from superset import viz
from superset.common.db_query_status import QueryStatus
@@ -51,101 +50,6 @@ def _viz() -> viz.BaseViz:
)
def _timeseries_viz(form_data: dict[str, Any]) -> viz.NVD3TimeSeriesViz:
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
datasource = SqlaTable(
table_name="t",
columns=[],
metrics=[],
main_dttm_col=None,
database=database,
)
base_form_data: dict[str, Any] = {"viz_type": "line", "metrics": ["value"]}
base_form_data.update(form_data)
return viz.NVD3TimeSeriesViz(
datasource=datasource,
form_data=base_form_data,
force=True,
)
def _resample_df() -> Any:
import pandas as pd
from superset.utils.core import DTTM_ALIAS
return pd.DataFrame(
{
DTTM_ALIAS: pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]),
"value": [1, 2, 3],
}
)
def test_process_data_rejects_unknown_resample_method() -> None:
"""
A resample method outside the allowlist must raise
``QueryObjectValidationError`` before any dynamic dispatch happens.
"""
obj = _timeseries_viz({"resample_rule": "1D", "resample_method": "__class__"})
with pytest.raises(QueryObjectValidationError):
obj.process_data(_resample_df())
def test_process_data_accepts_allowlisted_resample_method() -> None:
"""
A valid resample method (e.g. ``mean``) is applied successfully.
"""
obj = _timeseries_viz({"resample_rule": "1D", "resample_method": "mean"})
result = obj.process_data(_resample_df())
assert result is not None
assert not result.empty
def test_run_extra_queries_rejects_excessive_time_compare() -> None:
"""
Requesting more time-shift comparisons than ``VIZ_TIME_COMPARE_MAX`` must
raise ``QueryObjectValidationError`` before any sub-queries are issued.
"""
max_compare = current_app.config["VIZ_TIME_COMPARE_MAX"]
obj = _timeseries_viz(
{"time_compare": [f"{i} days ago" for i in range(1, max_compare + 2)]}
)
with pytest.raises(QueryObjectValidationError):
obj.run_extra_queries()
def test_deck_multi_rejects_excessive_sub_slices() -> None:
"""
Requesting more deck.gl sub-slices than ``DECK_MULTI_MAX_SLICES`` must raise
``QueryObjectValidationError`` before any sub-query is issued.
"""
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
datasource = SqlaTable(
table_name="t",
columns=[],
metrics=[],
main_dttm_col=None,
database=database,
)
max_slices = current_app.config["DECK_MULTI_MAX_SLICES"]
obj = viz.DeckGLMultiLayer(
datasource=datasource,
form_data={
"viz_type": "deck_multi",
"deck_slices": list(range(max_slices + 1)),
},
force=True,
)
with pytest.raises(QueryObjectValidationError):
obj.get_data(_resample_df())
def test_get_df_payload_propagates_oauth2_redirect_error() -> None:
"""
OAuth2RedirectError (a SupersetErrorException) must propagate out of
@@ -205,40 +109,3 @@ def test_get_df_payload_captures_query_object_validation_error() -> None:
assert len(obj.errors) == 1
assert obj.errors[0]["error_type"] == SupersetErrorType.VIZ_GET_DF_ERROR
assert obj.errors[0]["message"] == "bad query"
def test_get_df_payload_hides_stacktrace_when_show_stacktrace_disabled() -> None:
"""
The error payload must not expose a stacktrace when neither ``debug`` nor
``SHOW_STACKTRACE`` is enabled.
"""
obj = _viz()
# The test app runs with ``debug`` disabled, so toggling ``SHOW_STACKTRACE``
# off is enough to exercise the hidden-stacktrace branch.
assert current_app.debug is False
with (
patch.object(viz.BaseViz, "get_df", side_effect=RuntimeError("boom")),
patch.dict(current_app.config, {"SHOW_STACKTRACE": False}),
):
payload = obj.get_df_payload(QUERY_OBJ)
assert obj.status == QueryStatus.FAILED
assert payload["stacktrace"] is None
def test_get_df_payload_shows_stacktrace_when_show_stacktrace_enabled() -> None:
"""
The error payload must expose a stacktrace when ``SHOW_STACKTRACE`` is enabled.
"""
obj = _viz()
with (
patch.object(viz.BaseViz, "get_df", side_effect=RuntimeError("boom")),
patch.dict(current_app.config, {"SHOW_STACKTRACE": True}),
):
payload = obj.get_df_payload(QUERY_OBJ)
assert obj.status == QueryStatus.FAILED
assert payload["stacktrace"] is not None
assert "RuntimeError" in payload["stacktrace"]