From b54b0faeefcd4cb29ef9f33e063e4b5e39025842 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Tue, 10 Mar 2026 11:49:15 -0400 Subject: [PATCH] feat: add @semantic_layer decorator for extension discovery --- .../semantic_layers/decorators.py | 104 +++++++++++++++++ superset/core/api/core_api_injection.py | 37 ++++++ superset/semantic_layers/registry.py | 24 ++++ .../semantic_layers/decorators_test.py | 109 ++++++++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 superset-core/src/superset_core/semantic_layers/decorators.py create mode 100644 superset/semantic_layers/registry.py create mode 100644 tests/unit_tests/semantic_layers/decorators_test.py diff --git a/superset-core/src/superset_core/semantic_layers/decorators.py b/superset-core/src/superset_core/semantic_layers/decorators.py new file mode 100644 index 00000000000..50c67fc8028 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/decorators.py @@ -0,0 +1,104 @@ +# 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. + +""" +Semantic layer registration decorator for Superset. + +This module provides a decorator interface to register semantic layer +implementations with the host application, enabling automatic discovery +by the extensions framework. + +Usage: + from superset_core.semantic_layers.decorators import semantic_layer + + @semantic_layer( + id="snowflake", + name="Snowflake Cortex", + description="Snowflake semantic layer via Cortex Analyst", + ) + class SnowflakeSemanticLayer(SemanticLayer[SnowflakeConfig, SnowflakeView]): + ... + + # Or with minimal arguments: + @semantic_layer(id="dbt", name="dbt Semantic Layer") + class DbtSemanticLayer(SemanticLayer[DbtConfig, DbtView]): + ... +""" + +from __future__ import annotations + +from typing import Any, Callable, TypeVar + +from superset_core.semantic_layers.layer import SemanticLayer + +# Type variable for decorated semantic layer classes +T = TypeVar("T", bound=type[SemanticLayer[Any, Any]]) + + +def semantic_layer( + id: str, + name: str, + description: str | None = None, +) -> Callable[[T], T]: + """ + Decorator to register a semantic layer implementation. + + Automatically detects extension context and applies appropriate + namespacing to prevent ID conflicts between host and extension + semantic layers. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + Args: + id: Unique semantic layer type identifier (e.g., "snowflake", + "dbt"). Used as the key in the semantic layers registry and + stored in the ``type`` column of the ``SemanticLayer`` model. + name: Human-readable display name (e.g., "Snowflake Cortex"). + Shown in the UI when listing available semantic layer types. + description: Optional description for documentation and UI + tooltips. + + Returns: + Decorated semantic layer class registered with the host + application. + + Raises: + NotImplementedError: If called before host implementation is + initialized. + + Example: + from superset_core.semantic_layers.decorators import semantic_layer + from superset_core.semantic_layers.layer import SemanticLayer + + @semantic_layer( + id="snowflake", + name="Snowflake Cortex", + description="Connect to Snowflake Cortex Analyst", + ) + class SnowflakeSemanticLayer( + SemanticLayer[SnowflakeConfig, SnowflakeView] + ): + ... + """ + raise NotImplementedError( + "Semantic layer decorator not initialized. " + "This decorator should be replaced during Superset startup." + ) + + +__all__ = ["semantic_layer"] diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py index 34aa998bd54..4d2e88fcf7f 100644 --- a/superset/core/api/core_api_injection.py +++ b/superset/core/api/core_api_injection.py @@ -229,6 +229,42 @@ def inject_model_session_implementation() -> None: core_models_module.get_session = get_session +def inject_semantic_layer_implementations() -> None: + """ + Replace abstract semantic layer decorator in + superset_core.semantic_layers.decorators with a concrete implementation + that registers classes in the contributions registry. + """ + import superset_core.semantic_layers.decorators as core_sl_module + + from superset.extensions.context import get_current_extension_context + from superset.semantic_layers.registry import registry + + def semantic_layer_impl( + id: str, + name: str, + description: str | None = None, + ) -> Callable[[Any], Any]: + def decorator(cls: Any) -> Any: + context = get_current_extension_context() + + if context: + manifest = context.manifest + prefixed_id = f"extensions.{manifest.publisher}.{manifest.name}.{id}" + else: + prefixed_id = id + + cls.name = name + cls.description = description + cls._semantic_layer_id = prefixed_id + registry[prefixed_id] = cls + return cls + + return decorator + + core_sl_module.semantic_layer = semantic_layer_impl # type: ignore[assignment] + + def initialize_core_api_dependencies() -> None: """ Initialize all dependency injections for the superset-core API. @@ -242,3 +278,4 @@ def initialize_core_api_dependencies() -> None: inject_query_implementations() inject_task_implementations() inject_rest_api_implementations() + inject_semantic_layer_implementations() diff --git a/superset/semantic_layers/registry.py b/superset/semantic_layers/registry.py new file mode 100644 index 00000000000..076d1aa87de --- /dev/null +++ b/superset/semantic_layers/registry.py @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import Any + +from superset_core.semantic_layers.layer import SemanticLayer + +registry: dict[str, type[SemanticLayer[Any, Any]]] = {} diff --git a/tests/unit_tests/semantic_layers/decorators_test.py b/tests/unit_tests/semantic_layers/decorators_test.py new file mode 100644 index 00000000000..2d475c55bbc --- /dev/null +++ b/tests/unit_tests/semantic_layers/decorators_test.py @@ -0,0 +1,109 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +def test_semantic_layer_stub_raises() -> None: + """The stub decorator raises NotImplementedError before initialization.""" + import importlib + + import superset_core.semantic_layers.decorators as mod + + # Reload to get the original stub (injection may have replaced it) + importlib.reload(mod) + + with pytest.raises(NotImplementedError): + mod.semantic_layer(id="test", name="Test") + + +def test_inject_semantic_layer_host_context() -> None: + """The injected decorator registers a class in host context.""" + from superset.semantic_layers.registry import registry + + # Clear registry for test isolation + registry.clear() + + with patch( + "superset.core.api.core_api_injection.get_current_extension_context", + return_value=None, + ): + from superset.core.api.core_api_injection import ( + inject_semantic_layer_implementations, + ) + + inject_semantic_layer_implementations() + + import superset_core.semantic_layers.decorators as mod + + @mod.semantic_layer(id="test_layer", name="Test Layer", description="A test") + class FakeLayer: + pass + + assert "test_layer" in registry + assert registry["test_layer"] is FakeLayer + assert FakeLayer.name == "Test Layer" # type: ignore[attr-defined] + assert FakeLayer.description == "A test" # type: ignore[attr-defined] + + # Cleanup + registry.pop("test_layer", None) + + +def test_inject_semantic_layer_extension_context() -> None: + """The injected decorator prefixes ID in extension context.""" + from superset.semantic_layers.registry import registry + + registry.clear() + + mock_context = MagicMock() + mock_context.manifest.publisher = "acme" + mock_context.manifest.name = "analytics" + + with patch( + "superset.core.api.core_api_injection.get_current_extension_context", + return_value=None, + ): + from superset.core.api.core_api_injection import ( + inject_semantic_layer_implementations, + ) + + inject_semantic_layer_implementations() + + import superset_core.semantic_layers.decorators as mod + + # Now simulate extension context for the decorator call + with patch( + "superset.core.api.core_api_injection.get_current_extension_context", + return_value=mock_context, + ): + # Re-inject so the closure captures the mock context + inject_semantic_layer_implementations() + + @mod.semantic_layer(id="ext_layer", name="Extension Layer") + class ExtLayer: + pass + + expected_id = "extensions.acme.analytics.ext_layer" + assert expected_id in registry + assert registry[expected_id] is ExtLayer + + # Cleanup + registry.pop(expected_id, None)