From 0d5827ac42878de1bc4218017a1ff307a3865fdb Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:51:22 -0800 Subject: [PATCH] chore(extensions): unified contribution api and automatic prefixing (#38412) --- .../extensions/contribution-types.md | 55 ++++++++--- docs/developer_docs/extensions/development.md | 64 +++++++----- docs/developer_docs/extensions/overview.md | 2 +- docs/developer_docs/extensions/quick-start.md | 44 +++++---- .../src/superset_core/api/rest_api.py | 97 ++++++++++++++----- superset/core/api/core_api_injection.py | 62 ++++++++++-- superset/core/mcp/core_mcp_injection.py | 66 +++++++++++-- superset/extensions/context.py | 90 +++++++++++++++++ superset/extensions/contributions.py | 94 ++++++++++++++++++ superset/initialization/__init__.py | 5 +- superset/tasks/decorators.py | 15 ++- 11 files changed, 492 insertions(+), 102 deletions(-) create mode 100644 superset/extensions/context.py create mode 100644 superset/extensions/contributions.py diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index e6fddef7abd..945e288b932 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -115,22 +115,51 @@ Backend contribution types allow extensions to extend Superset's server-side cap ### REST API Endpoints -Extensions can register custom REST API endpoints under the `/api/v1/extensions/` namespace. This dedicated namespace prevents conflicts with built-in endpoints and provides a clear separation between core and extension functionality. - -```json -"backend": { - "entryPoints": ["my_extension.entrypoint"], - "files": ["backend/src/my_extension/**/*.py"] -} -``` - -The entry point module registers the API with Superset: +Extensions can register custom REST API endpoints under the `/extensions/` namespace. This dedicated namespace prevents conflicts with built-in endpoints and provides a clear separation between core and extension functionality. ```python -from superset_core.api.rest_api import add_extension_api -from .api import MyExtensionAPI +from superset_core.api.rest_api import RestApi, api +from flask_appbuilder.api import expose, protect -add_extension_api(MyExtensionAPI) +@api( + id="my_extension_api", + name="My Extension API", + description="Custom API endpoints for my extension" +) +class MyExtensionAPI(RestApi): + @expose("/hello", methods=("GET",)) + @protect() + def hello(self) -> Response: + return self.response(200, result={"message": "Hello from extension!"}) + +# Import the class in entrypoint.py to register it +from .api import MyExtensionAPI +``` + +**Note**: The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator automatically detects context and generates appropriate paths: + +- **Extension context**: `/extensions/{publisher}/{name}/` with ID prefixed as `extensions.{publisher}.{name}.{id}` +- **Host context**: `/api/v1/` with original ID + +For an extension with publisher `my-org` and name `dataset-tools`, the endpoint above would be accessible at: +``` +/extensions/my-org/dataset-tools/hello +``` + +You can also specify a `resource_name` parameter to add an additional path segment: + +```python +@api( + id="analytics_api", + name="Analytics API", + resource_name="analytics" # Adds /analytics to the path +) +class AnalyticsAPI(RestApi): + @expose("/insights", methods=("GET",)) + def insights(self): + # This endpoint will be available at: + # /extensions/my-org/dataset-tools/analytics/insights + return self.response(200, result={"insights": []}) ``` ### MCP Tools and Prompts diff --git a/docs/developer_docs/extensions/development.md b/docs/developer_docs/extensions/development.md index 41b112abb89..c6baa635986 100644 --- a/docs/developer_docs/extensions/development.md +++ b/docs/developer_docs/extensions/development.md @@ -203,32 +203,52 @@ Extension endpoints are registered under a dedicated `/extensions` namespace to ```python from superset_core.api.models import Database, get_session from superset_core.api.daos import DatabaseDAO -from superset_core.api.rest_api import add_extension_api -from .api import DatasetReferencesAPI +from superset_core.api.rest_api import RestApi, api +from flask_appbuilder.api import expose, protect -# Register a new extension REST API -add_extension_api(DatasetReferencesAPI) - -# Fetch Superset entities via the DAO to apply base filters that filter out entities -# that the user doesn't have access to -databases = DatabaseDAO.find_all() - -# ..or apply simple filters on top of base filters -databases = DatabaseDAO.filter_by(uuid=database.uuid) -if not databases: - raise Exception("Database not found") - -return databases[0] - -# Perform complex queries using SQLAlchemy Query, also filtering out -# inaccessible entities -session = get_session() -databases_query = session.query(Database).filter( - Database.database_name.ilike("%abc%") +@api( + id="dataset_references_api", + name="Dataset References API", + description="API for managing dataset references" ) -return DatabaseDAO.query(databases_query) +class DatasetReferencesAPI(RestApi): + @expose("/datasets", methods=("GET",)) + @protect() + def get_datasets(self) -> Response: + """Get all accessible datasets.""" + # Fetch Superset entities via the DAO to apply base filters that filter out entities + # that the user doesn't have access to + databases = DatabaseDAO.find_all() + + # ..or apply simple filters on top of base filters + databases = DatabaseDAO.filter_by(uuid=database.uuid) + if not databases: + raise Exception("Database not found") + + return self.response(200, result={"databases": databases}) + + @expose("/search", methods=("GET",)) + @protect() + def search_databases(self) -> Response: + """Search databases with complex queries.""" + # Perform complex queries using SQLAlchemy Query, also filtering out + # inaccessible entities + session = get_session() + databases_query = session.query(Database).filter( + Database.database_name.ilike("%abc%") + ) + databases = DatabaseDAO.query(databases_query) + + return self.response(200, result={"databases": databases}) ``` +### Automatic Context Detection + +The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator automatically detects whether it's being used in host or extension code: + +- **Extension APIs**: Registered under `/extensions/{publisher}/{name}/` with IDs prefixed as `extensions.{publisher}.{name}.{id}` +- **Host APIs**: Registered under `/api/v1/` with original IDs + In the future, we plan to expand the backend APIs to support configuring security models, database engines, SQL Alchemy dialects, etc. ## Development Mode diff --git a/docs/developer_docs/extensions/overview.md b/docs/developer_docs/extensions/overview.md index be8628836d8..2a6bce06c96 100644 --- a/docs/developer_docs/extensions/overview.md +++ b/docs/developer_docs/extensions/overview.md @@ -38,7 +38,7 @@ Extensions can provide: - **Custom UI Components**: New panels, views, and interactive elements - **Commands and Menus**: Custom actions accessible via menus and keyboard shortcuts -- **REST API Endpoints**: Backend services under the `/api/v1/extensions/` namespace +- **REST API Endpoints**: Backend services under the `/extensions/` namespace - **MCP Tools and Prompts**: AI agent capabilities for enhanced user assistance ## UI Components for Extensions diff --git a/docs/developer_docs/extensions/quick-start.md b/docs/developer_docs/extensions/quick-start.md index a3ac8390bdf..1f0e97720b2 100644 --- a/docs/developer_docs/extensions/quick-start.md +++ b/docs/developer_docs/extensions/quick-start.md @@ -129,11 +129,15 @@ The CLI generated a basic `backend/src/superset_extensions/my_org/hello_world/en ```python from flask import Response from flask_appbuilder.api import expose, protect, safe -from superset_core.api.rest_api import RestApi +from superset_core.api.rest_api import RestApi, api +@api( + id="hello_world_api", + name="Hello World API", + description="API endpoints for the Hello World extension" +) class HelloWorldAPI(RestApi): - resource_name = "hello_world" openapi_spec_tag = "Hello World" class_permission_name = "hello_world" @@ -170,25 +174,25 @@ class HelloWorldAPI(RestApi): **Key points:** -- Extends `RestApi` from `superset_core.api.types.rest_api` +- Uses [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator with automatic context detection +- Extends `RestApi` from `superset_core.api.rest_api` - Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`) - Returns responses using `self.response(status_code, result=data)` -- The endpoint will be accessible at `/extensions/my-org/hello-world/message` +- The endpoint will be accessible at `/extensions/my-org/hello-world/message` (automatic extension context) - OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically generate interactive API documentation at `/swagger/v1`, allowing developers to explore endpoints, understand schemas, and test the API directly from the browser **Update `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`** -Replace the generated print statement with API registration: +Replace the generated print statement with API import to trigger registration: ```python -from superset_core.api import rest_api - +# Importing the API class triggers the @api decorator registration from .api import HelloWorldAPI -rest_api.add_extension_api(HelloWorldAPI) +print("Hello World extension loaded successfully!") ``` -This registers your API with Superset when the extension loads. +The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator automatically detects extension context and registers your API with proper namespacing. ## Step 5: Create Frontend Component @@ -328,16 +332,16 @@ const HelloWorldPanel: React.FC = () => { const [error, setError] = useState(''); useEffect(() => { - const fetchMessage = async () => { - try { - const csrfToken = await authentication.getCSRFToken(); - const response = await fetch('/extensions/my-org/hello-world/message', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken!, - }, - }); + const fetchMessage = async () => { + try { + const csrfToken = await authentication.getCSRFToken(); + const response = await fetch('/extensions/my-org/hello-world/message', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken!, + }, + }); if (!response.ok) { throw new Error(`Server returned ${response.status}`); @@ -493,7 +497,7 @@ Superset will extract and validate the extension metadata, load the assets, regi Here's what happens when your extension loads: 1. **Superset starts**: Reads `extension.json` and loads the backend entrypoint -2. **Backend registration**: `entrypoint.py` registers your API via `rest_api.add_extension_api()` +2. **Backend registration**: `entrypoint.py` imports your API class, triggering the [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator to register it automatically 3. **Frontend loads**: When SQL Lab opens, Superset fetches the remote entry file 4. **Module Federation**: Webpack loads your extension module and resolves `@apache-superset/core` to `window.superset` 5. **Registration**: The module executes at load time, calling `views.registerView` to register your panel diff --git a/superset-core/src/superset_core/api/rest_api.py b/superset-core/src/superset_core/api/rest_api.py index 05ead50a906..40d7d2ce8a4 100644 --- a/superset-core/src/superset_core/api/rest_api.py +++ b/superset-core/src/superset_core/api/rest_api.py @@ -16,20 +16,31 @@ # under the License. """ -REST API functions for superset-core. +REST API functions and decorators for superset-core. -Provides dependency-injected REST API utility functions that will be replaced by -host implementations during initialization. +Provides dependency-injected REST API utility functions and decorators that will be +replaced by host implementations during initialization. Usage: - from superset_core.api.rest_api import add_api, add_extension_api + from superset_core.api.rest_api import api - add_api(MyCustomAPI) - add_extension_api(MyExtensionAPI) + # Unified decorator for both host and extension APIs + @api( + id="main_api", + name="Main API", + description="Primary endpoints" + ) + class MyAPI(RestApi): + pass """ +from typing import Callable, TypeVar + from flask_appbuilder.api import BaseApi +# Type variable for decorated API classes +T = TypeVar("T", bound=type["RestApi"]) + class RestApi(BaseApi): """ @@ -42,31 +53,65 @@ class RestApi(BaseApi): allow_browser_login = True -def add_api(api: type[RestApi]) -> None: +def api( + id: str, + name: str, + description: str | None = None, + resource_name: str | None = None, +) -> Callable[[T], T]: """ - Add a REST API to the Superset API. + Unified API decorator for both host and extension APIs. + + Automatically detects context: + - Host context: /api/v1/{resource_name}/ + - Extension context: /extensions/{publisher}/{name}/{resource_name}/ Host implementations will replace this function during initialization with a concrete implementation providing actual functionality. - :param api: A REST API instance. - :returns: None. + Args: + id: Unique API identifier (e.g., "main_api", "analytics_api") + name: Human-readable display name (e.g., "Main API") + description: Optional description for documentation + resource_name: Optional additional path segment for API grouping + + Returns: + Decorated API class with automatic path configuration + + Raises: + NotImplementedError: If called before host implementation is initialized + + Example: + @api( + id="main_api", + name="Main API", + description="Primary extension endpoints" + ) + class MyExtensionAPI(RestApi): + @expose("/hello", methods=("GET",)) + @protect() + def hello(self) -> Response: + # Available at: /extensions/acme/tools/hello (extension context) + # Available at: /api/v1/hello (host context) + return self.response(200, result={"message": "hello"}) + + @api( + id="analytics_api", + name="Analytics API", + resource_name="analytics" + ) + class AnalyticsAPI(RestApi): + @expose("/insights", methods=("GET",)) + @protect() + def insights(self) -> Response: + # Available at: /extensions/acme/tools/analytics/insights (extension) + # Available at: /api/v1/analytics/insights (host) + return self.response(200, result={}) """ - raise NotImplementedError("Function will be replaced during initialization") + raise NotImplementedError( + "API decorator not initialized. " + "This decorator should be replaced during Superset startup." + ) -def add_extension_api(api: type[RestApi]) -> None: - """ - Add an extension REST API to the Superset API. - - Host implementations will replace this function during initialization - with a concrete implementation providing actual functionality. - - :param api: An extension REST API instance. These are placed under - the /extensions resource. - :returns: None. - """ - raise NotImplementedError("Function will be replaced during initialization") - - -__all__ = ["RestApi", "add_api", "add_extension_api"] +__all__ = ["RestApi", "api"] diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py index be4ea69db4c..6744faded8e 100644 --- a/superset/core/api/core_api_injection.py +++ b/superset/core/api/core_api_injection.py @@ -23,10 +23,12 @@ into the abstract superset-core API modules. This allows the core API to be used with direct imports while maintaining loose coupling. """ -from typing import Any, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING, TypeVar from sqlalchemy.orm import scoped_session +from superset.extensions.context import get_current_extension_context + if TYPE_CHECKING: from superset_core.api.models import Database from superset_core.api.rest_api import RestApi @@ -137,24 +139,66 @@ def inject_task_implementations() -> None: def inject_rest_api_implementations() -> None: """ - Replace abstract REST API functions in superset_core.api.rest_api with concrete - implementations from Superset. + Replace abstract REST API functions and decorators in superset_core.api.rest_api + with concrete implementations from Superset. """ import superset_core.api.rest_api as core_rest_api_module from superset.extensions import appbuilder + T = TypeVar("T", bound=type["RestApi"]) + def add_api(api: "type[RestApi]") -> None: view = appbuilder.add_api(api) appbuilder._add_permission(view, True) - def add_extension_api(api: "type[RestApi]") -> None: - api.route_base = "/extensions/" + (api.resource_name or "") - view = appbuilder.add_api(api) - appbuilder._add_permission(view, True) + def api_impl( + id: str, + name: str, + description: str | None = None, + resource_name: str | None = None, + ) -> Callable[[T], T]: + def decorator(api_class: T) -> T: + # Check for ambient extension context + context = get_current_extension_context() - core_rest_api_module.add_api = add_api - core_rest_api_module.add_extension_api = add_extension_api + if context: + # EXTENSION CONTEXT + manifest = context.manifest + base_path = f"/extensions/{manifest.publisher}/{manifest.name}" + prefixed_id = f"extensions.{manifest.publisher}.{manifest.name}.{id}" + + else: + # HOST CONTEXT + base_path = "/api/v1" + prefixed_id = id + + # Add resource_name to path for both contexts + if resource_name: + base_path += f"/{resource_name}" + + # Set route base and register immediately + api_class.route_base = base_path + api_class._api_id = prefixed_id + api_class._api_metadata = { + "id": prefixed_id, + "name": name, + "description": description, + "resource_name": resource_name, + "is_extension": context is not None, + "context": context, + } + + # Register with Flask-AppBuilder immediately + view = appbuilder.add_api(api_class) + appbuilder._add_permission(view, True) + + return api_class + + return decorator + + # Replace core implementations with unified API decorator + core_rest_api_module.api = api_impl def inject_model_session_implementation() -> None: diff --git a/superset/core/mcp/core_mcp_injection.py b/superset/core/mcp/core_mcp_injection.py index 0ecde4ff591..b580e13e280 100644 --- a/superset/core/mcp/core_mcp_injection.py +++ b/superset/core/mcp/core_mcp_injection.py @@ -25,12 +25,34 @@ that replaces the abstract functions in superset-core during initialization. import logging from typing import Any, Callable, Optional, TypeVar +from superset.extensions.context import get_current_extension_context + # Type variable for decorated functions F = TypeVar("F", bound=Callable[..., Any]) logger = logging.getLogger(__name__) +def _get_prefixed_id_with_context(base_id: str) -> tuple[str, str]: + """ + Get ID with extension prefixing based on ambient context. + + Returns: + Tuple of (prefixed_id, context_type) where context_type is 'extension' or 'host' + """ + if context := get_current_extension_context(): + # Extension context: prefix ID to prevent collisions + manifest = context.manifest + prefixed_id = f"extensions.{manifest.publisher}.{manifest.name}.{base_id}" + context_type = "extension" + else: + # Host context: use original ID + prefixed_id = base_id + context_type = "host" + + return prefixed_id, context_type + + def create_tool_decorator( func_or_name: str | Callable[..., Any] | None = None, *, @@ -66,10 +88,13 @@ def create_tool_decorator( from superset.mcp_service.app import mcp # Use provided values or extract from function - tool_name = name or func.__name__ - tool_description = description or func.__doc__ or f"Tool: {tool_name}" + base_tool_name = name or func.__name__ + tool_description = description or func.__doc__ or f"Tool: {base_tool_name}" tool_tags = tags or [] + # Get prefixed ID based on ambient context + tool_name, context_type = _get_prefixed_id_with_context(base_tool_name) + # Conditionally apply authentication wrapper if protect: from superset.mcp_service.auth import mcp_auth_hook @@ -89,7 +114,12 @@ def create_tool_decorator( mcp.add_tool(tool) protected_status = "protected" if protect else "public" - logger.info("Registered MCP tool: %s (%s)", tool_name, protected_status) + logger.info( + "Registered MCP tool: %s (%s, %s)", + tool_name, + protected_status, + context_type, + ) return wrapped_func except Exception as e: @@ -153,11 +183,16 @@ def create_prompt_decorator( from superset.mcp_service.app import mcp # Use provided values or extract from function - prompt_name = name or func.__name__ + base_prompt_name = name or func.__name__ prompt_title = title or func.__name__ - prompt_description = description or func.__doc__ or f"Prompt: {prompt_name}" + prompt_description = ( + description or func.__doc__ or f"Prompt: {base_prompt_name}" + ) prompt_tags = tags or set() + # Get prefixed ID based on ambient context + prompt_name, context_type = _get_prefixed_id_with_context(base_prompt_name) + # Conditionally apply authentication wrapper if protect: from superset.mcp_service.auth import mcp_auth_hook @@ -175,7 +210,12 @@ def create_prompt_decorator( )(wrapped_func) protected_status = "protected" if protect else "public" - logger.info("Registered MCP prompt: %s (%s)", prompt_name, protected_status) + logger.info( + "Registered MCP prompt: %s (%s, %s)", + prompt_name, + protected_status, + context_type, + ) return wrapped_func except Exception as e: @@ -208,18 +248,26 @@ def initialize_core_mcp_dependencies() -> None: """ Initialize MCP dependency injection by replacing abstract functions in superset_core.api.mcp with concrete implementations. + + Also imports MCP service app to register all host tools BEFORE extension loading. """ try: + # Replace the abstract decorators with concrete implementations + import superset_core.api.mcp - # Replace the abstract decorators with concrete implementations superset_core.api.mcp.tool = create_tool_decorator superset_core.api.mcp.prompt = create_prompt_decorator logger.info("MCP dependency injection initialized successfully") - except ImportError as e: - logger.warning("superset_core not available, skipping MCP injection: %s", e) + # Import MCP service app to register host tools BEFORE extension loading + # This prevents host tools from being registered during extension context + + from superset.mcp_service import app # noqa: F401 + + logger.info("MCP service app imported - host tools registered") + except Exception as e: logger.error("Failed to initialize MCP dependencies: %s", e) raise diff --git a/superset/extensions/context.py b/superset/extensions/context.py new file mode 100644 index 00000000000..c2b1ac9ab61 --- /dev/null +++ b/superset/extensions/context.py @@ -0,0 +1,90 @@ +# 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. + +""" +Extension Context Management - provides ambient context during extension loading. + +This module provides a thread-local context system that allows decorators to +automatically detect whether they are being called in host or extension code +during extension loading. +""" + +from __future__ import annotations + +import contextlib +from threading import local +from typing import Any, Generator + +from superset_core.extensions.types import Manifest + +# Thread-local storage for extension context +_extension_context: local = local() + + +class ExtensionContext: + """Manages ambient extension context during loading.""" + + def __init__(self, manifest: Manifest): + self.manifest = manifest + + def __enter__(self) -> "ExtensionContext": + if getattr(_extension_context, "current", None) is not None: + current_extension = _extension_context.current.manifest.id + raise RuntimeError( + f"Cannot initialize extension {self.manifest.id} while extension " + f"{current_extension} is already being initialized. " + f"Nested extension initialization is not supported." + ) + + _extension_context.current = self + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + # Clear the current context + _extension_context.current = None + + +class ExtensionContextWrapper: + """Wrapper for extension context with extensible properties.""" + + def __init__(self, manifest: Manifest): + self._manifest = manifest + + @property + def manifest(self) -> Manifest: + """Get the extension manifest.""" + return self._manifest + + # Future: Add other context properties here + # @property + # def security_context(self) -> SecurityContext: ... + # @property + # def build_info(self) -> BuildInfo: ... + + +def get_current_extension_context() -> ExtensionContextWrapper | None: + """Get the currently active extension context wrapper, or None if in host code.""" + if context := getattr(_extension_context, "current", None): + return ExtensionContextWrapper(context.manifest) + return None + + +@contextlib.contextmanager +def extension_context(manifest: Manifest) -> Generator[None, None, None]: + """Context manager for setting extension context during loading.""" + with ExtensionContext(manifest): + yield diff --git a/superset/extensions/contributions.py b/superset/extensions/contributions.py new file mode 100644 index 00000000000..2af407500c2 --- /dev/null +++ b/superset/extensions/contributions.py @@ -0,0 +1,94 @@ +# 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. + +""" +Unified Contribution Processing System + +This module provides a centralized system for processing pending contributions +from decorators across all contribution types (extension APIs, MCP tools, tasks, etc.) +after extension loading when publisher/name context is available. +""" + +import logging +from typing import Protocol + +from superset_core.extensions.types import Manifest + +logger = logging.getLogger(__name__) + + +class ContributionProcessor(Protocol): + """Protocol for contribution processor functions.""" + + def __call__(self, manifest: Manifest) -> None: + """Process pending contributions for an extension.""" + ... + + +class ContributionProcessorRegistry: + """Registry for contribution processors from different decorator systems.""" + + def __init__(self) -> None: + self._processors: list[ContributionProcessor] = [] + + def register_processor(self, processor: ContributionProcessor) -> None: + """Register a contribution processor function.""" + self._processors.append(processor) + logger.debug( + "Registered contribution processor: %s", + getattr(processor, "__name__", repr(processor)), + ) + + def process_all_contributions(self, manifest: Manifest) -> None: + """Process all pending contributions for an extension.""" + logger.debug( + "Processing %d contribution processors for %s", + len(self._processors), + manifest.id, + ) + + for processor in self._processors: + try: + processor(manifest) + except Exception as e: + logger.error( + "Failed to process contributions with %s for %s: %s", + getattr(processor, "__name__", repr(processor)), + manifest.id, + e, + ) + + +# Global registry instance +_contribution_registry = ContributionProcessorRegistry() + + +def register_contribution_processor(processor: ContributionProcessor) -> None: + """Register a contribution processor function.""" + _contribution_registry.register_processor(processor) + + +def process_extension_contributions(manifest: Manifest) -> None: + """Process all pending contributions for an extension.""" + _contribution_registry.process_all_contributions(manifest) + + +__all__ = [ + "ContributionProcessor", + "register_contribution_processor", + "process_extension_contributions", +] diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 25cc42e16d3..abd2943f1f5 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -59,6 +59,7 @@ from superset.extensions import ( stats_logger_manager, talisman, ) +from superset.extensions.context import extension_context from superset.security import SupersetSecurityManager from superset.sql.parse import SQLGLOT_DIALECTS from superset.superset_typing import FlaskResponse @@ -589,7 +590,9 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods if backend and backend.entrypoint: try: - eager_import(backend.entrypoint) + with extension_context(extension.manifest): + eager_import(backend.entrypoint) + except Exception as ex: # pylint: disable=broad-except # noqa: S110 # Surface exceptions during initialization of extensions print(ex) diff --git a/superset/tasks/decorators.py b/superset/tasks/decorators.py index e2f753a94ad..fd795af35d5 100644 --- a/superset/tasks/decorators.py +++ b/superset/tasks/decorators.py @@ -112,7 +112,20 @@ def task( def decorator(f: Callable[P, R]) -> "TaskWrapper[P]": # Use function name if no name provided - task_name = name if name is not None else f.__name__ + base_task_name = name if name is not None else f.__name__ + + # Apply ambient context detection for ID prefixing (like MCP decorators) + from superset.extensions.context import get_current_extension_context + + if context := get_current_extension_context(): + # Extension context: prefix task name to prevent collisions + manifest = context.manifest + task_name = ( + f"extensions.{manifest.publisher}.{manifest.name}.{base_task_name}" + ) + else: + # Host context: use original task name + task_name = base_task_name # Create default options with no scope (scope is now in decorator) default_options = TaskOptions()