This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch theme_uuid
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0fdc637533a4f717ec1afe83331c6c03366ddbcc
Author: Maxime Beauchemin <[email protected]>
AuthorDate: Fri Aug 1 13:50:37 2025 -0700

    feat(themes): Add UUID reference system for dynamic theme configuration
    
    Implements a UUID-based reference system that enables Superset themes to be
    dynamically configured through external systems like Split.io, overcoming
    payload size limitations.
    
    ## Problem
    Split.io has a 1KB limit for JSON payloads, but Superset theme 
configurations
    typically exceed 3KB. This prevented teams from managing themes dynamically
    through feature flags without deployments.
    
    ## Solution
    Instead of storing full theme objects, store lightweight UUID references:
    - Before: `{"algorithm": "dark", "token": {...}, ...}` (3KB+)
    - After: `{"uuid": "a7f3c8e2-4d1b-4c7a-9f8e-2b5d6c8a9e1f"}` (<100 bytes)
    
    ## Implementation Details
    
    ### Backend Changes
    - **ResolveAndUpsertThemeCommand**: New command that resolves UUID 
references
      to full theme configurations and upserts them as system themes
    - **Enhanced bootstrap data**: Modified `get_theme_bootstrap_data()` to
      dynamically resolve UUID references on every page load
    - **Fallback support**: Graceful degradation to safe defaults if UUID
      resolution fails (empty object for default theme, dark algorithm for dark 
theme)
    
    ### Frontend Changes
    - **UUID display in Theme Modal**: Added read-only UUID field with 
copy-to-clipboard
      functionality using existing CopyToClipboard component
    - **Minimal styling**: Uses Label component with monospace font, following
      existing patterns
    
    ### How It Works
    1. Store themes in Superset's Theme CRUD system (each gets a UUID)
    2. Reference themes by UUID in Split.io or other configuration systems
    3. On page load, the system:
       - Detects UUID references in theme configuration
       - Resolves them to full theme definitions from the database
       - Upserts as system themes for consistency
       - Falls back to safe defaults on errors
    
    ### Testing
    Comprehensive test coverage including:
    - UUID resolution scenarios (found, not found, invalid JSON)
    - System theme upsert behavior (create new, update existing)
    - Fallback configurations for different theme types
    - UUID precedence over inline configuration
    
    This architecture transforms Split.io's constraint into a feature, enabling
    truly dynamic theme management with instant updates and no deployment 
required.
    
    🤖 Generated with [Claude Code](https://claude.ai/code)
    
    Co-Authored-By: Claude <[email protected]>
---
 .../src/features/themes/ThemeModal.tsx             |  23 +++
 superset/commands/theme/resolve.py                 | 126 ++++++++++++++
 superset/views/base.py                             |  27 ++-
 tests/unit_tests/commands/theme/__init__.py        |  16 ++
 tests/unit_tests/commands/theme/test_resolve.py    | 184 +++++++++++++++++++++
 5 files changed, 360 insertions(+), 16 deletions(-)

diff --git a/superset-frontend/src/features/themes/ThemeModal.tsx 
b/superset-frontend/src/features/themes/ThemeModal.tsx
index f4cc68ec2d..0f47c9c009 100644
--- a/superset-frontend/src/features/themes/ThemeModal.tsx
+++ b/superset-frontend/src/features/themes/ThemeModal.tsx
@@ -32,11 +32,13 @@ import {
   Form,
   Tooltip,
   Alert,
+  Label,
 } from '@superset-ui/core/components';
 import { useJsonValidation } from 
'@superset-ui/core/components/AsyncAceEditor';
 import { Typography } from '@superset-ui/core/components/Typography';
 
 import { OnlyKeyWithType } from 'src/utils/types';
+import { CopyToClipboard } from 'src/components/CopyToClipboard';
 import { ThemeObject } from './types';
 
 interface ThemeModalProps {
@@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
             />
           </Form.Item>
 
+          {currentTheme?.uuid && (
+            <Form.Item label={t('UUID')}>
+              <div
+                css={css`
+                  display: flex;
+                  align-items: center;
+                  gap: ${supersetTheme.sizeUnit * 2}px;
+                `}
+              >
+                <Label monospace>{currentTheme.uuid}</Label>
+                <CopyToClipboard
+                  text={currentTheme.uuid}
+                  shouldShowText={false}
+                  wrapped={false}
+                  copyNode={<Icons.CopyOutlined iconSize="m" />}
+                  tooltipText={t('Copy UUID to clipboard')}
+                />
+              </div>
+            </Form.Item>
+          )}
+
           <Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
             <Alert
               type="info"
diff --git a/superset/commands/theme/resolve.py 
b/superset/commands/theme/resolve.py
new file mode 100644
index 0000000000..da99599a63
--- /dev/null
+++ b/superset/commands/theme/resolve.py
@@ -0,0 +1,126 @@
+# 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
+
+from superset.commands.base import BaseCommand
+from superset.daos.theme import ThemeDAO
+from superset.extensions import db
+from superset.models.core import Theme
+from superset.utils import json
+from superset.utils.decorators import transaction
+
+logger = logging.getLogger(__name__)
+
+
+class ResolveAndUpsertThemeCommand(BaseCommand):
+    """Command to resolve theme configuration and upsert to system theme."""
+
+    def __init__(self, theme_config: dict[str, Any], theme_name: str):
+        self._theme_config = theme_config
+        self._theme_name = theme_name
+        self._fallback_config = self._get_fallback_config()
+
+    def run(self) -> dict[str, Any]:
+        """Resolve theme configuration and upsert to system theme."""
+        try:
+            self.validate()
+
+            # First resolve the theme configuration
+            resolved_config = self._resolve_theme_config()
+
+            # Then upsert to system theme
+            self._upsert_system_theme(resolved_config)
+
+            return resolved_config
+        except Exception as ex:
+            logger.error(
+                "Failed to resolve and upsert theme %s: %s. Using fallback.",
+                self._theme_name,
+                ex,
+            )
+            return self._fallback_config
+
+    def _get_fallback_config(self) -> dict[str, Any]:
+        """Get fallback configuration based on theme name."""
+        if self._theme_name == "THEME_DARK":
+            return {"algorithm": "dark"}
+        return {}
+
+    def _resolve_theme_config(self) -> dict[str, Any]:
+        """Resolve theme configuration, looking up UUID references if 
present."""
+        # Check if config contains a UUID reference
+        if isinstance(self._theme_config, dict) and "uuid" in 
self._theme_config:
+            uuid = self._theme_config["uuid"]
+            referenced_theme = ThemeDAO.find_by_uuid(uuid)
+
+            if referenced_theme and referenced_theme.json_data:
+                try:
+                    resolved_config = json.loads(referenced_theme.json_data)
+                    logger.debug(
+                        "Resolved UUID reference %s for %s to theme 
definition",
+                        uuid,
+                        self._theme_name,
+                    )
+                    return resolved_config
+                except (ValueError, TypeError) as ex:
+                    logger.error(
+                        "Failed to parse theme JSON for UUID %s: %s",
+                        uuid,
+                        ex,
+                    )
+                    return self._fallback_config
+            else:
+                logger.error(
+                    "Referenced theme with UUID %s not found for %s",
+                    uuid,
+                    self._theme_name,
+                )
+                return self._fallback_config
+
+        # Not a UUID reference, return as-is
+        return self._theme_config
+
+    @transaction()
+    def _upsert_system_theme(self, theme_config: dict[str, Any]) -> None:
+        """Upsert the resolved theme configuration as a system theme."""
+        existing_theme = (
+            db.session.query(Theme)
+            .filter(Theme.theme_name == self._theme_name, Theme.is_system)
+            .first()
+        )
+
+        json_data = json.dumps(theme_config)
+
+        if existing_theme:
+            existing_theme.json_data = json_data
+            logger.info(f"Updated system theme: {self._theme_name}")
+        else:
+            new_theme = Theme(
+                theme_name=self._theme_name,
+                json_data=json_data,
+                is_system=True,
+            )
+            db.session.add(new_theme)
+            logger.info(f"Created system theme: {self._theme_name}")
+
+    def validate(self) -> None:
+        """Validate that the theme config is a dictionary."""
+        if not isinstance(self._theme_config, dict):
+            self._theme_config = {}
+        if not self._theme_name:
+            raise ValueError("Theme name is required")
diff --git a/superset/views/base.py b/superset/views/base.py
index 2318efe1f9..90db015070 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -62,7 +62,6 @@ from superset.extensions import cache_manager
 from superset.reports.models import ReportRecipientType
 from superset.superset_typing import FlaskResponse
 from superset.themes.utils import (
-    is_valid_theme,
     is_valid_theme_settings,
 )
 from superset.utils import core as utils, json
@@ -307,29 +306,25 @@ def menu_data(user: User) -> dict[str, Any]:
 def get_theme_bootstrap_data() -> dict[str, Any]:
     """
     Returns the theme data to be sent to the client.
+    Resolves UUID references and upserts system themes.
     """
+    from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
+
     # Get theme configs
     default_theme_config = get_config_value("THEME_DEFAULT")
     dark_theme_config = get_config_value("THEME_DARK")
     theme_settings = get_config_value("THEME_SETTINGS")
 
-    # Validate theme configurations
-    default_theme = default_theme_config
-    if not is_valid_theme(default_theme):
-        logger.warning(
-            "Invalid THEME_DEFAULT configuration: %s, using empty theme",
-            default_theme_config,
-        )
-        default_theme = {}
+    # Resolve and upsert themes - command handles all error cases
+    default_theme = ResolveAndUpsertThemeCommand(
+        default_theme_config or {}, "THEME_DEFAULT"
+    ).run()
 
-    dark_theme = dark_theme_config
-    if not is_valid_theme(dark_theme):
-        logger.warning(
-            "Invalid THEME_DARK configuration: %s, using empty theme",
-            dark_theme_config,
-        )
-        dark_theme = {}
+    dark_theme = ResolveAndUpsertThemeCommand(
+        dark_theme_config or {}, "THEME_DARK"
+    ).run()
 
+    # Validate theme settings
     if not is_valid_theme_settings(theme_settings):
         logger.warning(
             "Invalid THEME_SETTINGS configuration: %s, using defaults", 
theme_settings
diff --git a/tests/unit_tests/commands/theme/__init__.py 
b/tests/unit_tests/commands/theme/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/tests/unit_tests/commands/theme/__init__.py
@@ -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.
diff --git a/tests/unit_tests/commands/theme/test_resolve.py 
b/tests/unit_tests/commands/theme/test_resolve.py
new file mode 100644
index 0000000000..feee842730
--- /dev/null
+++ b/tests/unit_tests/commands/theme/test_resolve.py
@@ -0,0 +1,184 @@
+# 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 unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
+from superset.models.core import Theme
+from superset.utils import json
+
+
[email protected]
+def mock_theme_dao():
+    with patch("superset.commands.theme.resolve.ThemeDAO") as mock:
+        yield mock
+
+
[email protected]
+def mock_db():
+    with patch("superset.commands.theme.resolve.db") as mock:
+        yield mock
+
+
+class TestResolveAndUpsertThemeCommand:
+    def test_resolve_uuid_reference_found(self, mock_theme_dao, mock_db):
+        """Test resolving a UUID reference when theme is found."""
+        # Setup
+        uuid = "test-uuid-123"
+        theme_config = {"uuid": uuid}
+        expected_config = {"algorithm": "dark", "token": {"colorPrimary": 
"#1890ff"}}
+
+        mock_theme = MagicMock(spec=Theme)
+        mock_theme.json_data = json.dumps(expected_config)
+        mock_theme_dao.find_by_uuid.return_value = mock_theme
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert
+        assert result == expected_config
+        mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
+
+    def test_resolve_uuid_reference_not_found(self, mock_theme_dao, mock_db):
+        """Test resolving a UUID reference when theme is not found."""
+        # Setup
+        uuid = "missing-uuid-123"
+        theme_config = {"uuid": uuid}
+        mock_theme_dao.find_by_uuid.return_value = None
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert - should return empty dict fallback
+        assert result == {}
+        mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
+
+    def test_resolve_uuid_reference_invalid_json(self, mock_theme_dao, 
mock_db):
+        """Test resolving a UUID reference with invalid JSON data."""
+        # Setup
+        uuid = "test-uuid-123"
+        theme_config = {"uuid": uuid}
+
+        mock_theme = MagicMock(spec=Theme)
+        mock_theme.json_data = "invalid json"
+        mock_theme_dao.find_by_uuid.return_value = mock_theme
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert - should return empty dict fallback
+        assert result == {}
+
+    def test_resolve_non_uuid_config(self, mock_theme_dao, mock_db):
+        """Test resolving a regular theme config (not UUID reference)."""
+        # Setup
+        theme_config = {"algorithm": "default", "token": {"colorPrimary": 
"#ff0000"}}
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert - should return config as-is
+        assert result == theme_config
+        mock_theme_dao.find_by_uuid.assert_not_called()
+
+    def test_upsert_creates_new_system_theme(self, mock_theme_dao, mock_db):
+        """Test upserting creates a new system theme when none exists."""
+        # Setup
+        theme_config = {"algorithm": "default"}
+        
mock_db.session.query.return_value.filter.return_value.first.return_value = None
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        command.run()
+
+        # Assert
+        mock_db.session.add.assert_called_once()
+        added_theme = mock_db.session.add.call_args[0][0]
+        assert added_theme.theme_name == "THEME_DEFAULT"
+        assert added_theme.is_system is True
+        assert added_theme.json_data == json.dumps(theme_config)
+
+    def test_upsert_updates_existing_system_theme(self, mock_theme_dao, 
mock_db):
+        """Test upserting updates an existing system theme."""
+        # Setup
+        theme_config = {"algorithm": "dark"}
+        existing_theme = MagicMock(spec=Theme)
+        
mock_db.session.query.return_value.filter.return_value.first.return_value = (
+            existing_theme
+        )
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
+        command.run()
+
+        # Assert
+        assert existing_theme.json_data == json.dumps(theme_config)
+        mock_db.session.add.assert_not_called()
+
+    def test_fallback_for_theme_default(self, mock_theme_dao, mock_db):
+        """Test fallback returns empty dict for THEME_DEFAULT."""
+        # Setup - simulate UUID lookup failure
+        theme_config = {"uuid": "non-existent-uuid"}
+        mock_theme_dao.find_by_uuid.return_value = None
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert
+        assert result == {}
+
+    def test_fallback_for_theme_dark(self, mock_theme_dao, mock_db):
+        """Test fallback returns dark algorithm for THEME_DARK."""
+        # Setup - simulate UUID lookup failure
+        theme_config = {"uuid": "non-existent-uuid"}
+        mock_theme_dao.find_by_uuid.return_value = None
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
+        result = command.run()
+
+        # Assert
+        assert result == {"algorithm": "dark"}
+
+    def test_uuid_with_additional_fields(self, mock_theme_dao, mock_db):
+        """Test that UUID takes precedence even with additional fields."""
+        # Setup
+        uuid = "test-uuid-123"
+        theme_config = {
+            "uuid": uuid,
+            "algorithm": "ignored",  # This should be ignored
+            "token": {"ignored": True},  # This should be ignored
+        }
+        expected_config = {"algorithm": "dark", "token": {"colorPrimary": 
"#1890ff"}}
+
+        mock_theme = MagicMock(spec=Theme)
+        mock_theme.json_data = json.dumps(expected_config)
+        mock_theme_dao.find_by_uuid.return_value = mock_theme
+
+        # Execute
+        command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
+        result = command.run()
+
+        # Assert - should return the resolved config, not the input
+        assert result == expected_config
+        mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)

Reply via email to