This commit is contained in:
Maxime Beauchemin
2025-07-17 13:43:38 -07:00
parent 29b4c480f3
commit 92bf3b9d4e
11 changed files with 1452 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Export configuration metadata to JSON for documentation generation.
This script extracts configuration metadata from SupersetConfig and generates
JSON files that can be imported into the documentation site.
"""
import sys
from pathlib import Path
from typing import Any, Dict, List
# Add the superset directory to Python path
superset_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(superset_root))
from superset.config_extensions import SupersetConfig # noqa: E402
from superset.utils import json # noqa: E402
def export_config_metadata() -> List[Dict[str, Any]]:
"""Export configuration metadata as JSON."""
config = SupersetConfig()
# Get all settings metadata
settings_metadata = config.DATABASE_SETTINGS_SCHEMA
# Transform metadata for documentation
docs_metadata = []
for key, schema in settings_metadata.items():
# Skip readonly settings for user documentation
if schema.get("readonly", False):
continue
# Build environment variable name
env_var = f"SUPERSET__{key}"
# Extract nested example if available
nested_example = None
if schema.get("type") == "object" and "example" in schema:
nested_example = f"SUPERSET__{key}__example__nested_key=value"
# Format type information
type_info = str(schema.get("type", "unknown"))
if type_info == "integer":
min_val = schema.get("minimum")
max_val = schema.get("maximum")
if min_val is not None or max_val is not None:
min_str = str(min_val) if min_val is not None else "N/A"
max_str = str(max_val) if max_val is not None else "N/A"
type_info += f" ({min_str} - {max_str})"
doc_entry = {
"key": key,
"title": schema.get("title", key),
"description": schema.get("description", ""),
"type": type_info,
"category": schema.get("category", "general"),
"impact": schema.get("impact", "medium"),
"requires_restart": schema.get("requires_restart", True),
"default": schema.get("default"),
"env_var": env_var,
"nested_example": nested_example,
"documentation_url": schema.get("documentation_url"),
}
docs_metadata.append(doc_entry)
# Group by category
categories: Dict[str, List[Dict[str, Any]]] = {}
for entry in docs_metadata:
category = str(entry["category"])
if category not in categories:
categories[category] = []
categories[category].append(entry)
# Sort entries within each category
for category in categories:
categories[category].sort(key=lambda x: x["key"])
# Export as JSON
output_dir = Path(__file__).parent.parent / "src" / "resources"
output_dir.mkdir(exist_ok=True)
# Export all settings
with open(output_dir / "config_metadata.json", "w") as f:
f.write(
json.dumps(
{
"all_settings": docs_metadata,
"by_category": categories,
"categories": list(categories.keys()),
},
indent=2,
)
)
output_file = output_dir / "config_metadata.json"
print(f"Exported {len(docs_metadata)} configuration settings to {output_file}")
return docs_metadata
if __name__ == "__main__":
export_config_metadata()

40
docs/scripts/update_docs.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# 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.
# Update documentation with latest configuration metadata
# This script should be run before building the documentation
set -e
echo "Updating configuration metadata for documentation..."
# Navigate to the docs directory
cd "$(dirname "$0")/.."
# Export configuration metadata
echo "Exporting configuration metadata..."
python scripts/export_config_metadata.py
echo "Configuration metadata updated successfully!"
echo "The following files were updated:"
echo "- src/resources/config_metadata.json"
echo ""
echo "To build the documentation with the latest metadata:"
echo " npm install"
echo " npm run build"

View File

@@ -0,0 +1,331 @@
/**
* 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 React, { useState } from 'react';
import configMetadata from '../resources/config_metadata.json';
interface ConfigSetting {
key: string;
title: string;
description: string;
type: string;
category: string;
impact: string;
requires_restart: boolean;
default: any;
env_var: string;
nested_example: string | null;
documentation_url: string | null;
}
interface ConfigurationTableProps {
category?: string;
showEnvironmentVariables?: boolean;
}
const ImpactBadge: React.FC<{ impact: string }> = ({ impact }) => {
const colors = {
low: '#52c41a',
medium: '#faad14',
high: '#ff4d4f',
};
return (
<span
style={{
backgroundColor: colors[impact] || '#d9d9d9',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
}}
>
{impact.toUpperCase()}
</span>
);
};
const RestartBadge: React.FC<{ requiresRestart: boolean }> = ({
requiresRestart,
}) => {
if (!requiresRestart) return null;
return (
<span
style={{
backgroundColor: '#ff7875',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
marginLeft: '8px',
}}
>
RESTART
</span>
);
};
const ConfigurationTable: React.FC<ConfigurationTableProps> = ({
category,
showEnvironmentVariables = false,
}) => {
const [selectedCategory, setSelectedCategory] = useState<string>(
category || 'all',
);
// Get settings based on selected category
const getSettings = (): ConfigSetting[] => {
if (selectedCategory === 'all') {
return configMetadata.all_settings;
}
return configMetadata.by_category[selectedCategory] || [];
};
const settings = getSettings();
const formatDefault = (value: any): string => {
if (value === null || value === undefined) return 'None';
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<div style={{ margin: '20px 0' }}>
{/* Category selector */}
{!category && (
<div style={{ marginBottom: '20px' }}>
<label style={{ marginRight: '10px', fontWeight: 'bold' }}>
Category:
</label>
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
style={{ padding: '5px', marginRight: '10px' }}
>
<option value="all">All Categories</option>
{configMetadata.categories.map(cat => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<span style={{ fontSize: '14px', color: '#666' }}>
Showing {settings.length} configuration settings
</span>
</div>
)}
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
border: '1px solid #ddd',
}}
>
<thead>
<tr style={{ backgroundColor: '#f5f5f5' }}>
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Setting
</th>
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Description
</th>
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Type
</th>
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Default
</th>
{showEnvironmentVariables && (
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Environment Variable
</th>
)}
<th
style={{
padding: '12px',
border: '1px solid #ddd',
textAlign: 'left',
}}
>
Impact
</th>
</tr>
</thead>
<tbody>
{settings.map((setting: ConfigSetting) => (
<tr key={setting.key}>
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
<div>
<strong>{setting.title}</strong>
<br />
<code style={{ fontSize: '12px', color: '#666' }}>
{setting.key}
</code>
{setting.documentation_url && (
<div style={{ marginTop: '4px' }}>
<a
href={setting.documentation_url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px' }}
>
📖 Docs
</a>
</div>
)}
</div>
</td>
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
{setting.description}
</td>
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
<code>{setting.type}</code>
</td>
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
<code style={{ fontSize: '12px' }}>
{formatDefault(setting.default)}
</code>
</td>
{showEnvironmentVariables && (
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<code style={{ fontSize: '12px', marginRight: '8px' }}>
{setting.env_var}
</code>
<button
onClick={() => copyToClipboard(setting.env_var)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '12px',
color: '#1890ff',
}}
title="Copy to clipboard"
>
📋
</button>
</div>
{setting.nested_example && (
<div style={{ marginTop: '4px' }}>
<code style={{ fontSize: '10px', color: '#666' }}>
{setting.nested_example}
</code>
</div>
)}
</td>
)}
<td
style={{
padding: '12px',
border: '1px solid #ddd',
verticalAlign: 'top',
}}
>
<ImpactBadge impact={setting.impact} />
<RestartBadge requiresRestart={setting.requires_restart} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{settings.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>
No configuration settings found for the selected category.
</div>
)}
</div>
);
};
export default ConfigurationTable;

View File

@@ -0,0 +1,181 @@
/**
* 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 React, { useState } from 'react';
import configMetadata from '../resources/config_metadata.json';
interface EnvironmentVariablesExampleProps {
category?: string;
}
const EnvironmentVariablesExample: React.FC<
EnvironmentVariablesExampleProps
> = ({ category }) => {
const [showAll, setShowAll] = useState(false);
// Get settings based on category
const getSettings = () => {
if (category && configMetadata.by_category[category]) {
return configMetadata.by_category[category];
}
return configMetadata.all_settings;
};
const settings = getSettings();
const displaySettings = showAll ? settings : settings.slice(0, 5);
const formatDefaultForEnv = (value: any): string => {
if (value === null || value === undefined) return '""';
if (typeof value === 'object') {
return `'${JSON.stringify(value)}'`;
}
if (typeof value === 'string') {
return `"${value}"`;
}
return String(value);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const generateEnvExample = (setting: any): string => {
const example = formatDefaultForEnv(setting.default);
return `export ${setting.env_var}=${example}`;
};
const generateAllEnvVars = (): string => {
return [
'# Superset Configuration Environment Variables',
'# Generated from configuration metadata',
'',
...displaySettings.map(setting =>
[
`# ${setting.title}`,
`# ${setting.description}`,
`# Type: ${setting.type}`,
`# Impact: ${setting.impact}${
setting.requires_restart ? ' (requires restart)' : ''
}`,
generateEnvExample(setting),
'',
].join('\n'),
),
].join('\n');
};
return (
<div style={{ margin: '20px 0' }}>
<div
style={{
backgroundColor: '#f6f8fa',
border: '1px solid #e1e4e8',
borderRadius: '6px',
padding: '16px',
position: 'relative',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '10px',
}}
>
<h4 style={{ margin: 0, color: '#24292e' }}>
Environment Variables {category && `(${category})`}
</h4>
<button
onClick={() => copyToClipboard(generateAllEnvVars())}
style={{
backgroundColor: '#0366d6',
color: 'white',
border: 'none',
padding: '6px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
title="Copy all environment variables"
>
📋 Copy All
</button>
</div>
<pre
style={{
backgroundColor: '#f6f8fa',
border: 'none',
padding: '0',
margin: '0',
fontFamily:
'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace',
fontSize: '12px',
lineHeight: '1.45',
overflow: 'auto',
maxHeight: '400px',
}}
>
<code>{generateAllEnvVars()}</code>
</pre>
{!showAll && settings.length > 5 && (
<div
style={{
textAlign: 'center',
marginTop: '10px',
borderTop: '1px solid #e1e4e8',
paddingTop: '10px',
}}
>
<button
onClick={() => setShowAll(true)}
style={{
backgroundColor: 'transparent',
border: '1px solid #0366d6',
color: '#0366d6',
padding: '6px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
>
Show all {settings.length} settings
</button>
</div>
)}
</div>
<div
style={{
marginTop: '10px',
fontSize: '14px',
color: '#586069',
}}
>
<strong>Usage:</strong> Save to a <code>.env</code> file or export
directly in your shell.
{category && ` Showing ${settings.length} ${category} settings.`}
</div>
</div>
);
};
export default EnvironmentVariablesExample;

View File

@@ -0,0 +1,225 @@
{
"all_settings": [
{
"key": "ROW_LIMIT",
"title": "Row Limit",
"description": "Maximum number of rows returned from queries",
"type": "integer (1 - 1000000)",
"category": "performance",
"impact": "medium",
"requires_restart": false,
"default": 50000,
"env_var": "SUPERSET__ROW_LIMIT",
"nested_example": null,
"documentation_url": "https://superset.apache.org/docs/configuration/databases"
},
{
"key": "SAMPLES_ROW_LIMIT",
"title": "Samples Row Limit",
"description": "Default row limit when requesting samples from datasource",
"type": "integer (1 - 10000)",
"category": "performance",
"impact": "low",
"requires_restart": false,
"default": 1000,
"env_var": "SUPERSET__SAMPLES_ROW_LIMIT",
"nested_example": null,
"documentation_url": null
},
{
"key": "NATIVE_FILTER_DEFAULT_ROW_LIMIT",
"title": "Native Filter Default Row Limit",
"description": "Default row limit for native filters",
"type": "integer (1 - 10000)",
"category": "performance",
"impact": "low",
"requires_restart": false,
"default": 1000,
"env_var": "SUPERSET__NATIVE_FILTER_DEFAULT_ROW_LIMIT",
"nested_example": null,
"documentation_url": null
},
{
"key": "SQLLAB_TIMEOUT",
"title": "SQL Lab Timeout",
"description": "Timeout duration for SQL Lab synchronous queries (seconds)",
"type": "integer (1 - 3600)",
"category": "performance",
"impact": "high",
"requires_restart": false,
"default": 30,
"env_var": "SUPERSET__SQLLAB_TIMEOUT",
"nested_example": null,
"documentation_url": null
},
{
"key": "FEATURE_FLAGS",
"title": "Feature Flags",
"description": "Feature flags to enable/disable functionality",
"type": "object",
"category": "features",
"impact": "high",
"requires_restart": true,
"default": {},
"env_var": "SUPERSET__FEATURE_FLAGS",
"nested_example": null,
"documentation_url": null
},
{
"key": "THEME_DEFAULT",
"title": "Default Theme",
"description": "Default theme configuration (Ant Design format)",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_DEFAULT",
"nested_example": null,
"documentation_url": null
},
{
"key": "THEME_DARK",
"title": "Dark Theme",
"description": "Dark theme configuration (Ant Design format)",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_DARK",
"nested_example": null,
"documentation_url": null
},
{
"key": "THEME_SETTINGS",
"title": "Theme Settings",
"description": "Theme behavior and user preference settings",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_SETTINGS",
"nested_example": null,
"documentation_url": null
}
],
"by_category": {
"performance": [
{
"key": "NATIVE_FILTER_DEFAULT_ROW_LIMIT",
"title": "Native Filter Default Row Limit",
"description": "Default row limit for native filters",
"type": "integer (1 - 10000)",
"category": "performance",
"impact": "low",
"requires_restart": false,
"default": 1000,
"env_var": "SUPERSET__NATIVE_FILTER_DEFAULT_ROW_LIMIT",
"nested_example": null,
"documentation_url": null
},
{
"key": "ROW_LIMIT",
"title": "Row Limit",
"description": "Maximum number of rows returned from queries",
"type": "integer (1 - 1000000)",
"category": "performance",
"impact": "medium",
"requires_restart": false,
"default": 50000,
"env_var": "SUPERSET__ROW_LIMIT",
"nested_example": null,
"documentation_url": "https://superset.apache.org/docs/configuration/databases"
},
{
"key": "SAMPLES_ROW_LIMIT",
"title": "Samples Row Limit",
"description": "Default row limit when requesting samples from datasource",
"type": "integer (1 - 10000)",
"category": "performance",
"impact": "low",
"requires_restart": false,
"default": 1000,
"env_var": "SUPERSET__SAMPLES_ROW_LIMIT",
"nested_example": null,
"documentation_url": null
},
{
"key": "SQLLAB_TIMEOUT",
"title": "SQL Lab Timeout",
"description": "Timeout duration for SQL Lab synchronous queries (seconds)",
"type": "integer (1 - 3600)",
"category": "performance",
"impact": "high",
"requires_restart": false,
"default": 30,
"env_var": "SUPERSET__SQLLAB_TIMEOUT",
"nested_example": null,
"documentation_url": null
}
],
"features": [
{
"key": "FEATURE_FLAGS",
"title": "Feature Flags",
"description": "Feature flags to enable/disable functionality",
"type": "object",
"category": "features",
"impact": "high",
"requires_restart": true,
"default": {},
"env_var": "SUPERSET__FEATURE_FLAGS",
"nested_example": null,
"documentation_url": null
}
],
"ui": [
{
"key": "THEME_DARK",
"title": "Dark Theme",
"description": "Dark theme configuration (Ant Design format)",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_DARK",
"nested_example": null,
"documentation_url": null
},
{
"key": "THEME_DEFAULT",
"title": "Default Theme",
"description": "Default theme configuration (Ant Design format)",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_DEFAULT",
"nested_example": null,
"documentation_url": null
},
{
"key": "THEME_SETTINGS",
"title": "Theme Settings",
"description": "Theme behavior and user preference settings",
"type": "object",
"category": "ui",
"impact": "medium",
"requires_restart": false,
"default": {},
"env_var": "SUPERSET__THEME_SETTINGS",
"nested_example": null,
"documentation_url": null
}
]
},
"categories": [
"performance",
"features",
"ui"
]
}

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -0,0 +1,47 @@
# 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 superset.commands.exceptions import CommandException
class SettingsCommandException(CommandException):
"""Base exception for settings commands."""
pass
class SettingNotFoundError(SettingsCommandException):
"""Exception raised when a setting is not found."""
pass
class SettingNotAllowedInDatabaseError(SettingsCommandException):
"""Exception raised when a setting cannot be stored in the database."""
pass
class SettingValidationError(SettingsCommandException):
"""Exception raised when a setting value is invalid."""
pass
class SettingAlreadyExistsError(SettingsCommandException):
"""Exception raised when trying to create a setting that already exists."""
pass

View File

@@ -160,6 +160,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
SecurityRestApi,
UserRegistrationsRestAPI,
)
from superset.settings.api import SettingsRestApi
from superset.sqllab.api import SqlLabRestApi
from superset.sqllab.permalink.api import SqlLabPermalinkRestApi
from superset.tags.api import TagRestApi
@@ -242,6 +243,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(ReportExecutionLogRestApi)
appbuilder.add_api(RLSRestApi)
appbuilder.add_api(SavedQueryRestApi)
appbuilder.add_api(SettingsRestApi)
appbuilder.add_api(TagRestApi)
appbuilder.add_api(SqlLabRestApi)
appbuilder.add_api(SqlLabPermalinkRestApi)

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

379
superset/settings/api.py Normal file
View File

@@ -0,0 +1,379 @@
# 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 logging
from typing import Any, Dict, Optional
from flask import current_app, request
from flask_appbuilder.api import expose, protect, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
from superset.config_extensions import SupersetConfig
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.daos.settings import SettingsDAO
from superset.models.core import Settings
from superset.settings.schemas import (
SettingCreateSchema,
SettingListResponseSchema,
SettingResponseSchema,
SettingUpdateSchema,
)
from superset.views.base_api import BaseSupersetModelRestApi
logger = logging.getLogger(__name__)
class SettingsRestApi(BaseSupersetModelRestApi):
"""REST API for configuration settings management."""
datamodel = SQLAInterface(Settings)
resource_name = "settings"
allow_browser_login = True
# Only allow specific methods
include_route_methods = {
"get_list",
"get",
"post",
"put",
"delete",
"info",
}
# Schemas for serialization/deserialization
list_schema = SettingListResponseSchema()
show_schema = SettingResponseSchema()
add_schema = SettingCreateSchema()
edit_schema = SettingUpdateSchema()
# Permissions - only admins can read/write settings
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP.copy()
method_permission_name.update(
{
"get_list": "can_read",
"get": "can_read",
"post": "can_write",
"put": "can_write",
"delete": "can_write",
"validate": "can_read",
"metadata": "can_read",
"effective_config": "can_read",
}
)
# Only Admin role can access settings
class_permission_name = "Settings"
def _get_setting_metadata(self, key: str) -> Optional[Dict[str, Any]]:
"""Get metadata for a configuration setting."""
if isinstance(current_app.config, SupersetConfig):
return current_app.config.get_setting_metadata(key)
return None
def _get_setting_source(self, key: str) -> str:
"""Determine where a setting value comes from."""
import os
# Check if it's from environment variables
env_key = f"SUPERSET__{key}"
if env_key in os.environ:
return f"environment ({env_key})"
# Check if it's from superset_config.py
try:
import superset_config
if hasattr(superset_config, key):
return "superset_config.py"
except ImportError:
pass
# Check if it's in database
if SettingsDAO.find_by_key(key):
return "database"
# Otherwise it's from defaults
return "config_defaults.py"
def _is_setting_allowed_in_database(self, key: str) -> bool:
"""Check if a setting is allowed to be stored in the database."""
metadata = self._get_setting_metadata(key)
if not metadata:
return False
# Only allow settings that don't require restart and aren't readonly
return not metadata.get("requires_restart", True) and not metadata.get(
"readonly", False
)
def _validate_setting_value(self, key: str, value: Any) -> tuple[bool, list[str]]:
"""Validate a setting value against its metadata."""
metadata = self._get_setting_metadata(key)
if not metadata:
return True, []
if isinstance(current_app.config, SupersetConfig):
if current_app.config.validate_setting(key, value):
return True, []
else:
return False, [
f"Value does not match expected type or constraints for {key}"
]
return True, []
@expose("/", methods=["GET"])
@protect()
@safe
def get_list(self) -> str:
"""Get list of all database settings."""
# Parse query parameters
args = request.args
namespace = args.get("namespace")
category = args.get("category")
include_metadata = args.get("include_metadata", "false").lower() == "true"
include_source = args.get("include_source", "false").lower() == "true"
# Get settings from database
if namespace:
settings = SettingsDAO.get_by_namespace(namespace)
else:
settings = SettingsDAO.get_all_as_dict()
# Build response
result = []
for key, value in settings.items():
setting_data = {
"key": key,
"value": value,
"namespace": namespace, # This would need to be fetched from the model
}
if include_metadata:
setting_data["metadata"] = self._get_setting_metadata(key)
if include_source:
setting_data["source"] = self._get_setting_source(key)
# Filter by category if specified
if category:
metadata = self._get_setting_metadata(key)
if not metadata or metadata.get("category") != category:
continue
result.append(setting_data)
return self.response(200, result=result, count=len(result))
@expose("/<pk>", methods=["GET"])
@protect()
@safe
def get(self, pk: str) -> str:
"""Get a specific setting by key."""
# Parse query parameters
args = request.args
include_metadata = args.get("include_metadata", "false").lower() == "true"
include_source = args.get("include_source", "false").lower() == "true"
# Get setting value
value = SettingsDAO.get_value(pk)
if value is None:
return self.response_404()
# Build response
setting_data = {
"key": pk,
"value": value,
}
if include_metadata:
setting_data["metadata"] = self._get_setting_metadata(pk)
if include_source:
setting_data["source"] = self._get_setting_source(pk)
return self.response(200, **setting_data)
@expose("/", methods=["POST"])
@protect()
@safe
def post(self) -> str:
"""Create a new setting."""
try:
item = self.add_schema.load(request.json)
except ValidationError as error:
return self.response_422(message=error.messages)
key = item["key"]
value = item["value"]
namespace = item.get("namespace")
# Check if setting is allowed in database
if not self._is_setting_allowed_in_database(key):
return self.response_422(
message=f"Setting '{key}' cannot be stored in database. "
f"It may require a restart or be read-only."
)
# Validate value
is_valid, errors = self._validate_setting_value(key, value)
if not is_valid:
return self.response_422(message={"validation_errors": errors})
# Check if setting already exists
if SettingsDAO.find_by_key(key):
return self.response_422(
message=f"Setting '{key}' already exists. Use PUT to update."
)
# Create setting
try:
SettingsDAO.set_value(key, value, namespace)
return self.response(201, key=key, value=value)
except Exception as ex:
logger.exception("Error creating setting")
return self.response_422(message=str(ex))
@expose("/<pk>", methods=["PUT"])
@protect()
@safe
def put(self, pk: str) -> str:
"""Update an existing setting."""
try:
item = self.edit_schema.load(request.json)
except ValidationError as error:
return self.response_422(message=error.messages)
value = item["value"]
namespace = item.get("namespace")
# Check if setting is allowed in database
if not self._is_setting_allowed_in_database(pk):
return self.response_422(
message=f"Setting '{pk}' cannot be stored in database. "
f"It may require a restart or be read-only."
)
# Validate value
is_valid, errors = self._validate_setting_value(pk, value)
if not is_valid:
return self.response_422(message={"validation_errors": errors})
# Update setting
try:
SettingsDAO.set_value(pk, value, namespace)
return self.response(200, key=pk, value=value)
except Exception as ex:
logger.exception("Error updating setting")
return self.response_422(message=str(ex))
@expose("/<pk>", methods=["DELETE"])
@protect()
@safe
def delete(self, pk: str) -> str:
"""Delete a setting."""
# Check if setting exists
if not SettingsDAO.find_by_key(pk):
return self.response_404()
# Delete setting
try:
SettingsDAO.delete_by_key(pk)
return self.response(200, message=f"Setting '{pk}' deleted")
except Exception as ex:
logger.exception("Error deleting setting")
return self.response_422(message=str(ex))
@expose("/validate", methods=["POST"])
@protect()
@safe
def validate(self) -> str:
"""Validate a setting value without saving it."""
if not request.json:
return self.response_400()
key = request.json.get("key")
value = request.json.get("value")
if not key:
return self.response_422(message="Key is required")
# Get metadata
metadata = self._get_setting_metadata(key)
# Validate value
is_valid, errors = self._validate_setting_value(key, value)
# Check if allowed in database
allowed_in_db = self._is_setting_allowed_in_database(key)
response_data = {
"key": key,
"value": value,
"valid": is_valid,
"errors": errors,
"metadata": metadata,
"allowed_in_database": allowed_in_db,
}
return self.response(200, **response_data)
@expose("/metadata", methods=["GET"])
@protect()
@safe
def metadata(self) -> str:
"""Get metadata for all documented settings."""
if not isinstance(current_app.config, SupersetConfig):
return self.response(200, metadata={})
metadata = current_app.config.DATABASE_SETTINGS_SCHEMA
# Filter by category if specified
if category := request.args.get("category"):
metadata = current_app.config.get_settings_by_category(category)
return self.response(200, metadata=metadata)
@expose("/effective_config", methods=["GET"])
@protect()
@safe
def effective_config(self) -> str:
"""Get effective configuration (database + env + defaults)."""
# Get current config values
config_dict = {}
# Get documented settings
if isinstance(current_app.config, SupersetConfig):
for key in current_app.config.DATABASE_SETTINGS_SCHEMA:
if key in current_app.config:
config_dict[key] = {
"value": current_app.config[key],
"source": self._get_setting_source(key),
"metadata": self._get_setting_metadata(key),
}
# Add database settings
db_settings = SettingsDAO.get_all_as_dict()
for key, value in db_settings.items():
if key not in config_dict:
config_dict[key] = {
"value": value,
"source": "database",
"metadata": self._get_setting_metadata(key),
}
return self.response(200, config=config_dict)

View File

@@ -0,0 +1,110 @@
# 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 marshmallow import fields, Schema, validate
class SettingCreateSchema(Schema):
"""Schema for creating a new setting."""
key = fields.String(required=True, validate=validate.Length(min=1, max=255))
value = fields.Raw(required=True)
namespace = fields.String(allow_none=True, validate=validate.Length(max=100))
class SettingUpdateSchema(Schema):
"""Schema for updating an existing setting."""
value = fields.Raw(required=True)
namespace = fields.String(allow_none=True, validate=validate.Length(max=100))
class SettingResponseSchema(Schema):
"""Schema for setting response."""
key = fields.String()
value = fields.Raw()
namespace = fields.String(allow_none=True)
created_on = fields.DateTime()
changed_on = fields.DateTime()
created_by = fields.Nested("UserSchema", only=["id", "first_name", "last_name"])
changed_by = fields.Nested("UserSchema", only=["id", "first_name", "last_name"])
metadata = fields.Dict(allow_none=True) # Configuration metadata if available
source = fields.String(
allow_none=True
) # Source of the setting (database, env, defaults)
class SettingListResponseSchema(Schema):
"""Schema for listing settings."""
result = fields.List(fields.Nested(SettingResponseSchema))
count = fields.Integer()
class SettingMetadataSchema(Schema):
"""Schema for configuration metadata."""
title = fields.String()
description = fields.String()
type = fields.String()
category = fields.String()
impact = fields.String()
requires_restart = fields.Boolean()
default = fields.Raw()
minimum = fields.Integer(allow_none=True)
maximum = fields.Integer(allow_none=True)
readonly = fields.Boolean()
documentation_url = fields.String(allow_none=True)
class SettingValidationSchema(Schema):
"""Schema for setting validation."""
key = fields.String(required=True)
value = fields.Raw(required=True)
valid = fields.Boolean()
errors = fields.List(fields.String())
metadata = fields.Nested(SettingMetadataSchema, allow_none=True)
# Query schemas for API endpoints
setting_get_schema = {
"type": "object",
"properties": {
"include_metadata": {"type": "boolean"},
"include_source": {"type": "boolean"},
},
}
setting_list_schema = {
"type": "object",
"properties": {
"namespace": {"type": "string"},
"category": {"type": "string"},
"include_metadata": {"type": "boolean"},
"include_source": {"type": "boolean"},
},
}
setting_validate_schema = {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {}, # Any type
},
"required": ["key", "value"],
}