Compare commits

..

1 Commits

Author SHA1 Message Date
Evan Rusackas
014d6acd9a docs(installation): fix PyPI install Python version and OS deps
The PyPI install page claimed Python 3.12 (Ubuntu 24.04 default) was
unsupported and steered users to a deadsnakes 3.11 workaround. pyproject.toml
declares support for 3.10-3.12 (requires-python >=3.10), so the claim was
wrong and the workaround unnecessary.

- Drop the "3.12 not supported" claim and the deadsnakes/python3.11 steps
- Collapse the redundant Ubuntu blocks into one (tested 20.04/22.04/24.04),
  removing the EOL "before 20.04" block with its py2-era package names
- Point at pyproject.toml for supported versions (consistent with macOS section)
- Align OS deps with the repo Dockerfile: add python3-venv (needed by
  `python3 -m venv`), pkg-config (mysqlclient build), and libpq-dev (Postgres)
- Fix stale py2 package names in the yum block (python-devel ->
  python3-devel, etc.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:11:24 -07:00
23 changed files with 1122 additions and 3201 deletions

View File

@@ -47,7 +47,6 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: read
security-events: write
strategy:

View File

@@ -297,7 +297,7 @@ pre-commit run eslint # Frontend linting
## Platform-Specific Instructions
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor

View File

@@ -22,31 +22,24 @@ level dependencies.
**Debian and Ubuntu**
Ubuntu **24.04** uses python 3.12 per default, which currently is not supported by Superset. You need to add a second python installation of 3.11 and install the required additional dependencies.
```bash
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.11 python3.11-dev python3.11-venv build-essential libssl-dev libffi-dev libsasl2-dev libldap2-dev default-libmysqlclient-dev
```
In Ubuntu **20.04 and 22.04** the following command will ensure that the required dependencies are installed:
The following command will ensure that the required dependencies are installed (tested on Ubuntu 20.04, 22.04, and 24.04):
```bash
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip python3-venv libsasl2-dev libldap2-dev libpq-dev default-libmysqlclient-dev pkg-config
```
In Ubuntu **before 20.04** the following command will ensure that the required dependencies are installed:
```bash
sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
```
Refer to the
[pyproject.toml](https://github.com/apache/superset/blob/master/pyproject.toml) file for the list of
Python versions officially supported by Superset, and install a matching `python3` interpreter for
your distribution. The `libpq-dev` package is only needed if you intend to connect to (or use) a
PostgreSQL database; you can omit it otherwise.
**Fedora and RHEL-derivative Linux distributions**
Install the following packages using the `yum` package manager:
```bash
sudo yum install gcc gcc-c++ libffi-devel python-devel python-pip python-wheel openssl-devel cyrus-sasl-devel openldap-devel
sudo yum install gcc gcc-c++ libffi-devel python3-devel python3-pip python3-wheel openssl-devel cyrus-sasl-devel openldap-devel
```
In more recent versions of CentOS and Fedora, you may need to install a slightly different set of packages using `dnf`:

View File

@@ -14,7 +14,7 @@
"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
under the License.
-->
# Change Log

View File

@@ -221,7 +221,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.35",
"baseline-browser-mapping": "^2.10.34",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -14474,9 +14474,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
"version": "2.10.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -304,7 +304,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.35",
"baseline-browser-mapping": "^2.10.34",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",

View File

@@ -77,7 +77,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.3",
"@types/node": "^25.9.2",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -52,13 +52,6 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
get guestTokenHeaderName() {
try {
return getInstance().guestTokenHeaderName;
} catch {
return 'X-GuestToken';
}
},
};
export default SupersetClient;

View File

@@ -163,7 +163,6 @@ export interface SupersetClientInterface extends Pick<
configure: (config?: ClientConfig) => SupersetClientInterface;
reset: () => void;
getCSRFToken: () => CsrfPromise;
guestTokenHeaderName?: string;
}
export type SupersetClientResponse = Response | JsonResponse | TextResponse;

View File

@@ -172,15 +172,4 @@ describe('SupersetClient', () => {
const token = await SupersetClient.getCSRFToken();
expect(token).toBe('my_token');
});
test('guestTokenHeaderName returns the configured header name when instance exists', () => {
SupersetClient.configure({ guestTokenHeaderName: 'X-Custom-Guest' });
expect(SupersetClient.guestTokenHeaderName).toBe('X-Custom-Guest');
});
test('guestTokenHeaderName returns default X-GuestToken when instance is not configured', () => {
// Ensure instance is reset (afterEach calls SupersetClient.reset())
// Access the property without calling configure() first
expect(SupersetClient.guestTokenHeaderName).toBe('X-GuestToken');
});
});

File diff suppressed because one or more lines are too long

View File

@@ -1420,6 +1420,39 @@ describe('async actions', () => {
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('syncTable', () => {
test('updates the table schema state in the backend', () => {
expect.assertions(4);
const tableName = 'table';
const schemaName = 'schema';
const store = mockStore(initialState);
const expectedActionTypes = [
actions.MERGE_TABLE, // syncTable
];
const request = actions.syncTable(
query as any,
tableName as any,
schemaName,
);
return request(store.dispatch, store.getState, undefined).then(() => {
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
expect(store.getActions()[0].prepend).toBeFalsy();
expect(
fetchMock.callHistory.calls(updateTableSchemaEndpoint),
).toHaveLength(1);
// tab state is not updated, since no query was run
expect(
fetchMock.callHistory.calls(updateTabStateEndpoint),
).toHaveLength(0);
});
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('runTablePreviewQuery', () => {
const results = {

View File

@@ -1346,6 +1346,52 @@ export function runTablePreviewQuery(
};
}
export interface TableMetaData {
columns?: unknown[];
selectStar?: string;
primaryKey?: unknown;
foreignKeys?: unknown[];
indexes?: unknown[];
}
export function syncTable(
table: Table,
tableMetadata: TableMetaData,
finalQueryEditorId?: string,
): SqlLabThunkAction<Promise<unknown>> {
return function (dispatch: AppDispatch) {
const finalTable = { ...table, queryEditorId: finalQueryEditorId };
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? SupersetClient.post({
endpoint: encodeURI('/tableschemaview/'),
postPayload: { table: { ...tableMetadata, ...finalTable } },
})
: Promise.resolve({ json: { id: table.id } });
return sync
.then(({ json: resultJson }) => {
const newTable = { ...table, id: `${resultJson.id}` };
dispatch(
mergeTable({
...newTable,
expanded: true,
initialized: true,
}),
);
})
.catch(() =>
dispatch(
addDangerToast(
t(
'An error occurred while fetching table metadata. ' +
'Please contact your administrator.',
),
),
),
);
};
}
export function changeDataPreviewId(
oldQueryId: string,
newQuery: Query,

View File

@@ -310,7 +310,12 @@ test('ignores non-array fontUrls in theme config without throwing', () => {
});
test('skips the dashboard theme when an SDK theme config override is active', () => {
const themeConfig = { token: { colorPrimary: '#ff0000' } };
const themeConfig = {
token: {
colorPrimary: '#ff0000',
fontUrls: ['https://fonts.example.com/dashboard.css'],
},
};
render(
<ThemeContext.Provider
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
@@ -332,6 +337,8 @@ test('skips the dashboard theme when an SDK theme config override is active', ()
expect(
screen.queryByTestId('dashboard-theme-provider'),
).not.toBeInTheDocument();
// The override fully owns theming, so dashboard fonts must not be injected.
expect(document.querySelector('style[data-superset-fonts]')).toBeNull();
});
test('applies the dashboard theme when no SDK theme config override is active', () => {
@@ -372,52 +379,3 @@ test('does not inject font style element when no fontUrls in config', () => {
const fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).toBeNull();
});
test('prevents font injection and cleans up fonts when hasThemeConfigOverride becomes active', () => {
const fontUrl = 'https://fonts.example.com/custom.css';
const themeConfig = {
token: { colorPrimary: '#ff0000', fontUrls: [fontUrl] },
};
const { rerender } = render(
<ThemeContext.Provider
value={{ hasThemeConfigOverride: false } as unknown as ThemeContextType}
>
<CrudThemeProvider
theme={{
id: 1,
theme_name: 'Font Theme',
json_data: JSON.stringify(themeConfig),
}}
>
<div>Dashboard Content</div>
</CrudThemeProvider>
</ThemeContext.Provider>,
);
// Assert font is injected
let fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).not.toBeNull();
expect(fontStyle?.textContent).toContain(`@import url("${fontUrl}")`);
// Switch hasThemeConfigOverride dynamically to true
rerender(
<ThemeContext.Provider
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
>
<CrudThemeProvider
theme={{
id: 1,
theme_name: 'Font Theme',
json_data: JSON.stringify(themeConfig),
}}
>
<div>Dashboard Content</div>
</CrudThemeProvider>
</ThemeContext.Provider>,
);
// Assert font is cleaned up and removed
fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).toBeNull();
});

View File

@@ -78,9 +78,7 @@ export default function CrudThemeProvider({
}, [theme?.json_data, hasThemeConfigOverride]);
useEffect(() => {
if (hasThemeConfigOverride || !dashboardTheme || !fontUrls?.length) {
return undefined;
}
if (!dashboardTheme || !fontUrls?.length) return undefined;
// JSON.stringify provides safe escaping to prevent CSS injection
const css = fontUrls
@@ -94,7 +92,7 @@ export default function CrudThemeProvider({
return () => {
style.remove();
};
}, [dashboardTheme, fontUrls, hasThemeConfigOverride]);
}, [dashboardTheme, fontUrls]);
if (!dashboardTheme || hasThemeConfigOverride) {
return <>{children}</>;

View File

@@ -27,7 +27,7 @@ import {
themeObject as supersetThemeObject,
normalizeThemeConfig,
} from '@apache-superset/core/theme';
import { makeApi, SupersetClient } from '@superset-ui/core';
import { makeApi } from '@superset-ui/core';
import type {
BootstrapThemeData,
BootstrapThemeDataConfig,
@@ -188,12 +188,6 @@ export class ThemeController {
);
this.onChangeCallbacks.clear();
// Clean up injected font styles
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
this.loadedFontUrls.clear();
}
/**
@@ -453,7 +447,6 @@ export class ThemeController {
this.devThemeOverride = null;
this.crudThemeId = null;
this.dashboardCrudTheme = null;
this.themeConfigOverride = false;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
@@ -1025,105 +1018,44 @@ export class ThemeController {
}
}
/**
* Constructs the guest token authorization header using the configured
* header name from SupersetClient or bootstrap config, falling back to 'X-GuestToken'.
*/
private getGuestTokenHeader(): Record<string, string> {
const headers: Record<string, string> = {};
try {
const guestToken = SupersetClient.getGuestToken();
if (guestToken) {
let headerName = 'X-GuestToken';
try {
if (SupersetClient.guestTokenHeaderName) {
headerName = SupersetClient.guestTokenHeaderName;
}
} catch {
const bootstrapData = getBootstrapData();
headerName =
bootstrapData.config?.GUEST_TOKEN_HEADER_NAME || 'X-GuestToken';
}
headers[headerName] = guestToken;
}
} catch (tokenError) {
// Ignore token retrieval error
}
return headers;
}
/**
* Fetches a fresh system default theme from the API for runtime recovery.
* Tries multiple fallback strategies to find a valid theme.
*
* Note: First tries to use SupersetClient. If SupersetClient is not yet
* fully configured/initialized or if the request fails (e.g. in embedded
* guest-token environments where SupersetClient bootstrap is still in progress),
* it falls back to using raw fetch() with custom guest token headers.
* Note: Uses raw fetch() instead of SupersetClient because ThemeController
* initializes early in the app lifecycle, before SupersetClient is fully
* configured. This avoids boot-time circular dependencies.
*
* @returns The system default theme configuration or null if not found
*/
private async fetchSystemDefaultTheme(): Promise<AnyThemeConfig | null> {
try {
// Try to use SupersetClient first if it has been configured
try {
const response = await SupersetClient.get({
endpoint:
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
});
if (response.json?.result?.length > 0) {
const themeConfig = JSON.parse(response.json.result[0].json_data);
// Try to fetch theme marked as system default (is_system_default=true)
const defaultResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
);
if (defaultResponse.ok) {
const data = await defaultResponse.json();
if (data.result?.length > 0) {
const themeConfig = JSON.parse(data.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
} catch (clientError) {
// If SupersetClient is not configured yet or request fails, fall back to native fetch
const headers = this.getGuestTokenHeader();
const defaultResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
{ headers },
);
if (defaultResponse.ok) {
const data = await defaultResponse.json();
if (data.result?.length > 0) {
const themeConfig = JSON.parse(data.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
}
// Fallback: Try to fetch system theme named 'THEME_DEFAULT'
try {
const response = await SupersetClient.get({
endpoint:
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
});
if (response.json?.result?.length > 0) {
const themeConfig = JSON.parse(response.json.result[0].json_data);
const fallbackResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.result?.length > 0) {
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
} catch (clientError) {
const headers = this.getGuestTokenHeader();
const fallbackResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
{ headers },
);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.result?.length > 0) {
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
}
} catch (error) {
// Log for debugging but don't fail - fallback to cached theme will be used

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { theme as antdThemeImport } from 'antd';
import { SupersetClient } from '@superset-ui/core';
import {} from '@superset-ui/core';
import {
type AnyThemeConfig,
type SupersetThemeConfig,
@@ -915,7 +915,6 @@ test('recovery flow: fetchSystemDefaultTheme returns theme → applies fetched t
// Verify API was called to fetch system default theme
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.any(Object),
);
// Verify the fetched theme was applied via applyThemeWithRecovery
@@ -1855,263 +1854,3 @@ test('getResolvedThemeMode returns dark when default theme is dark but mode is D
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(controller.getCurrentModeResolved()).toBe('dark');
});
test('fallback fetch: uses custom guest token header from SupersetClient when client.get fails', async () => {
const originalFetch = global.fetch;
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('custom-guest-token-123');
// Define getter for guestTokenHeaderName
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: 'X-Custom-Guest-Header',
configurable: true,
});
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#custom-header-theme' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
expect(mockGet).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Guest-Header': 'custom-guest-token-123',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#custom-header-theme' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('fallback fetch: uses bootstrap config for guest token header when SupersetClient is not configured', async () => {
const originalFetch = global.fetch;
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('bootstrap-guest-token');
// Ensure SupersetClient.guestTokenHeaderName is undefined or throws
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
get: () => {
throw new Error('Not configured');
},
configurable: true,
});
// Mock bootstrapData.config?.GUEST_TOKEN_HEADER_NAME
mockGetBootstrapData.mockReturnValue({
...createMockBootstrapData(),
config: {
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Custom-Header',
},
} as any);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#bootstrap-theme' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Bootstrap-Custom-Header': 'bootstrap-guest-token',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-theme' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('SDK override toggling and dynamic transitions', () => {
const controller = createController();
expect(controller.hasThemeConfigOverride()).toBe(false);
// Set theme config override
const sdkThemeConfig: SupersetThemeConfig = {
theme_default: { token: { colorPrimary: '#sdk-default' } },
theme_dark: { token: { colorPrimary: '#sdk-dark' } },
};
controller.setThemeConfig(sdkThemeConfig);
expect(controller.hasThemeConfigOverride()).toBe(true);
// Clear local overrides (which should reset override flag)
controller.clearLocalOverrides();
expect(controller.hasThemeConfigOverride()).toBe(false);
});
test('ThemeController cleans up injected fonts on destroy', () => {
const controller = createController();
// Inject some fonts
(controller as any).loadFonts(['https://fonts.example.com/font-test.css']);
let fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).not.toBeNull();
controller.destroy();
fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).toBeNull();
});
test('fallback fetch: uses bootstrap GUEST_TOKEN_HEADER_NAME when guestTokenHeaderName getter throws', async () => {
const originalFetch = global.fetch;
// SupersetClient.get throws so we fall through to native fetch
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
// getGuestToken succeeds (we have a guest token)
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('my-guest-token');
// guestTokenHeaderName getter throws → should fall back to bootstrap config
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
get: () => {
throw new Error('Not configured');
},
configurable: true,
});
// Return a bootstrap config with a custom header name
mockGetBootstrapData.mockReturnValue({
...createMockBootstrapData(),
config: {
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Header',
},
} as any);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#bootstrap-fallback' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
// Verify the bootstrap header was used instead of SupersetClient.guestTokenHeaderName
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Bootstrap-Header': 'my-guest-token',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-fallback' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('fetchSystemDefaultTheme: second named-theme fallback fetch succeeds when first API calls fail', async () => {
const originalFetch = global.fetch;
// SupersetClient.get always throws (not configured)
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const namedTheme = { token: { colorPrimary: '#named-theme' } };
// First fetch call (is_system_default) returns empty result; second (THEME_DEFAULT name) succeeds
const mockFetch = jest
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: [] }), // first path: no results
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(namedTheme) }],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
// Both fetches should have been called
expect(mockFetch).toHaveBeenCalledTimes(2);
// The result should be from the second (named-theme) fallback fetch
expect(result).toEqual(namedTheme);
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
}
});

View File

@@ -23,7 +23,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.3",
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
@@ -1798,9 +1798,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7883,9 +7883,9 @@
"dev": true
},
"@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"dev": true,
"requires": {
"undici-types": ">=7.24.0 <7.24.7"

View File

@@ -31,7 +31,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.3",
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",

View File

@@ -147,24 +147,6 @@ class Theme(AuditMixinNullable, ImportExportMixin, Model):
export_fields = ["theme_name", "json_data"]
# Event listeners to clear the memoized bootstrap data cache when a theme is modified
@sqla.event.listens_for(Theme, "after_insert")
@sqla.event.listens_for(Theme, "after_update")
@sqla.event.listens_for(Theme, "after_delete")
def clear_bootstrap_cache(
_mapper: sqla.orm.Mapper,
_connection: sqla.engine.Connection,
_target: Theme,
) -> None:
from superset.extensions import cache_manager
from superset.views.base import cached_common_bootstrap_data
try:
cache_manager.cache.delete_memoized(cached_common_bootstrap_data)
except Exception as ex: # pylint: disable=broad-except
logger.warning("Failed to clear theme bootstrap cache: %s", ex)
class ConfigurationMethod(StrEnum):
SQLALCHEMY_FORM = "sqlalchemy_form"
DYNAMIC_FORM = "dynamic_form"

View File

@@ -1556,44 +1556,3 @@ def test_database_execute_async_without_options(mocker: MockerFixture) -> None:
mock_executor_class.assert_called_once_with(database)
mock_executor.execute_async.assert_called_once_with("SELECT 1", None)
assert result == mock_handle
def test_clear_bootstrap_cache_logs_warning_on_failure(
mocker: MockerFixture,
) -> None:
"""
Test that clear_bootstrap_cache logs a warning when cache invalidation fails.
Exercises the ``except Exception`` branch in the event listener so that
Codecov registers it as covered. The function must not re-raise the
exception — callers (SQLAlchemy event dispatch) should be unaffected.
"""
from superset.models.core import clear_bootstrap_cache
# Patch cache_manager so delete_memoized raises
mock_cache = mocker.MagicMock()
mock_cache.delete_memoized.side_effect = RuntimeError("Redis unavailable")
mock_cache_manager = mocker.patch("superset.models.core.cache_manager")
mock_cache_manager.cache = mock_cache
# Patch cached_common_bootstrap_data so the local import inside
# clear_bootstrap_cache resolves to our mock.
mocker.patch(
"superset.views.base.cached_common_bootstrap_data",
new=mocker.MagicMock(__name__="cached_common_bootstrap_data"),
)
mock_logger = mocker.patch("superset.models.core.logger")
# Should not raise even though delete_memoized raises
clear_bootstrap_cache(
_mapper=mocker.MagicMock(),
_connection=mocker.MagicMock(),
_target=mocker.MagicMock(),
)
# Verify logger.warning was called with the correct message format
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert call_args[0][0] == "Failed to clear theme bootstrap cache: %s"

View File

@@ -886,32 +886,3 @@ class TestGetDefaultSpinnerSvg:
assert result is not None
assert "<svg" in result
assert "morphPath" in result
class TestThemeCacheInvalidation:
"""Test theme cache invalidation event listeners"""
@patch("superset.extensions.cache_manager.cache.delete_memoized")
def test_clear_bootstrap_cache_event(self, mock_delete_memoized):
"""Test that the event listener triggers delete_memoized"""
from superset.models.core import clear_bootstrap_cache
from superset.views.base import cached_common_bootstrap_data
# Call clear_bootstrap_cache with dummy mapper, connection, and Theme
clear_bootstrap_cache(MagicMock(), MagicMock(), MagicMock())
mock_delete_memoized.assert_called_once_with(cached_common_bootstrap_data)
@patch("superset.extensions.cache_manager.cache.delete_memoized")
@patch("superset.models.core.logger")
def test_clear_bootstrap_cache_event_error(self, mock_logger, mock_delete_memoized):
"""Test that the event listener handles errors gracefully and logs them"""
from superset.models.core import clear_bootstrap_cache
mock_delete_memoized.side_effect = Exception("Cache error")
clear_bootstrap_cache(MagicMock(), MagicMock(), MagicMock())
mock_logger.warning.assert_called_once_with(
"Failed to clear theme bootstrap cache: %s",
mock_delete_memoized.side_effect,
)