Compare commits

...

2 Commits

Author SHA1 Message Date
Ville Brofeldt
423e5280d0 feat(extensions): autodetect contributions 2026-02-25 09:53:58 -08:00
Ville Brofeldt
c5dce675a0 feat(extensions): autogenerate fe and be contributions 2026-02-25 09:22:49 -08:00
33 changed files with 4183 additions and 461 deletions

View File

@@ -24,132 +24,180 @@ under the License.
# Contribution Types
To facilitate the development of extensions, we define a set of well-defined contribution types that extensions can implement. These contribution types serve as the building blocks for extensions, allowing them to interact with the host application and provide new functionality.
Extensions provide functionality through **contributions** - well-defined extension points that integrate with the host application.
## Frontend
## Why Contributions?
Frontend contribution types allow extensions to extend Superset's user interface with new views, commands, and menu items.
The contribution system provides several key benefits:
- **Transparency**: Administrators can review exactly what functionality an extension provides before installation. The `manifest.json` documents all REST APIs, MCP tools, views, and other contributions in a single, readable location.
- **Security**: Only contributions explicitly declared in the manifest are registered during startup. Extensions cannot expose functionality they haven't declared, preventing hidden or undocumented code from executing.
- **Discoverability**: The manifest serves as a contract between extensions and the host application, making it easy to understand what an extension does without reading its source code.
## How Contributions Work
Contributions are automatically discovered from source code at build time. Simply use the `@extension_api`, `@tool`, `@prompt` decorators in Python or `define*()` functions in TypeScript - the build system finds them and generates a `manifest.json` with all discovered contributions.
No manual configuration needed!
## Backend Contributions
### REST API Endpoints
Register REST APIs under `/api/v1/extensions/`:
```python
from superset_core.api import RestApi, extension_api
from flask_appbuilder import expose
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={"message": "Hello"})
```
### MCP Tools
Register MCP tools for AI agents:
```python
from superset_core.mcp import tool
@tool(tags=["database"])
def query_database(sql: str, database_id: int) -> dict:
"""Execute a SQL query against a database."""
return execute_query(sql, database_id)
```
### MCP Prompts
Register MCP prompts:
```python
from superset_core.mcp import prompt
@prompt(tags={"analysis"})
async def analyze_data(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analyze the {dataset} dataset..."
```
See [MCP Integration](./mcp) for more details.
## Frontend Contributions
### Views
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Each view is registered with a unique ID and can be activated or deactivated as needed. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
Add panels or views to the UI using `defineView()`:
```json
"frontend": {
"contributions": {
"views": {
"sqllab": {
"panels": [
{
"id": "my_extension.main",
"name": "My Panel Name"
}
]
}
}
}
}
```tsx
import React from 'react';
import { defineView } from '@apache-superset/core';
import MyPanel from './MyPanel';
export const myView = defineView({
id: 'main',
title: 'My Panel',
location: 'sqllab.panels', // or dashboard.tabs, explore.panels, etc.
component: () => <MyPanel />,
});
```
### Commands
Extensions can define custom commands that can be executed within the host application, such as context-aware actions or menu options. Each command can specify properties like a unique command identifier, an icon, a title, and a description. These commands can be invoked by users through menus, keyboard shortcuts, or other UI elements, enabling extensions to add rich, interactive functionality to Superset.
Define executable commands using `defineCommand()`:
```json
"frontend": {
"contributions": {
"commands": [
{
"command": "my_extension.copy_query",
"icon": "CopyOutlined",
"title": "Copy Query",
"description": "Copy the current query to clipboard"
}
]
}
}
```tsx
import { defineCommand } from '@apache-superset/core';
export const copyQuery = defineCommand({
id: 'copy_query',
title: 'Copy Query',
icon: 'CopyOutlined',
execute: async () => {
// Copy the current query
navigator.clipboard.writeText(getCurrentQuery());
},
});
```
### Menus
Extensions can contribute new menu items or context menus to the host application, providing users with additional actions and options. Each menu item can specify properties such as the target view, the command to execute, its placement (primary, secondary, or context), and conditions for when it should be displayed. Menu contribution areas are uniquely identified (e.g., `sqllab.editor` for the SQL Lab editor), allowing extensions to seamlessly integrate their functionality into specific menus and workflows within Superset.
Add items to menus using `defineMenu()`:
```tsx
import { defineMenu } from '@apache-superset/core';
export const contextMenu = defineMenu({
id: 'clear_editor',
title: 'Clear Editor',
location: 'sqllab.editor.context',
action: () => {
clearEditor();
},
});
```
### Editors
Replace the default text editor using `defineEditor()`:
```tsx
import React from 'react';
import { defineEditor } from '@apache-superset/core';
import MonacoEditor from './MonacoEditor';
export const monacoSqlEditor = defineEditor({
id: 'monaco_sql',
name: 'Monaco SQL Editor',
mimeTypes: ['text/x-sql'],
component: MonacoEditor,
});
```
All contributions are automatically discovered at build time and registered at runtime - no manual configuration needed!
## Configuration
### extension.json
Specify which files to scan for contributions:
```json
"frontend": {
"contributions": {
"menus": {
"sqllab": {
"editor": {
"primary": [
{
"view": "builtin.editor",
"command": "my_extension.copy_query"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "my_extension.prettify"
}
],
"context": [
{
"view": "builtin.editor",
"command": "my_extension.clear"
}
]
}
}
{
"id": "my_extension",
"name": "My Extension",
"version": "1.0.0",
"backend": {
"entryPoints": ["my_extension.entrypoint"],
"files": ["backend/src/**/*.py"]
},
"frontend": {
"moduleFederation": {
"exposes": ["./index"]
}
}
}
```
### Editors
### Manual Contributions (Advanced)
Extensions can replace Superset's default text editors with custom implementations. This enables enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. When an extension registers an editor for a language, it replaces the default Ace editor in all locations that use that language (SQL Lab, Dashboard Properties, CSS editors, etc.).
Override auto-discovery by specifying contributions directly:
```json
"frontend": {
"contributions": {
"editors": [
{
"id": "my_extension.monaco_sql",
"name": "Monaco SQL Editor",
"languages": ["sql"],
"description": "Monaco-based SQL editor with IntelliSense"
}
]
{
"backend": {
"contributions": {
"mcpTools": [
{ "id": "query_db", "name": "query_db", "module": "my_ext.tools.query_db" }
],
"restApis": [
{ "id": "my_api", "name": "My API", "module": "my_ext.api.MyAPI", "basePath": "/my_api" }
]
}
}
}
```
See [Editors Extension Point](./extension-points/editors) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities with new API endpoints, MCP tools, and MCP prompts.
### 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:
```python
from superset_core.api.rest_api import add_extension_api
from .api import MyExtensionAPI
add_extension_api(MyExtensionAPI)
```
### MCP Tools and Prompts
Extensions can contribute Model Context Protocol (MCP) tools and prompts that AI agents can discover and use. See [MCP Integration](./mcp) for detailed documentation.

View File

@@ -417,25 +417,48 @@ Replace the generated code with the extension entry point:
```tsx
import React from 'react';
import { core } from '@apache-superset/core';
import { defineView } from '@apache-superset/core';
import HelloWorldPanel from './HelloWorldPanel';
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
core.registerViewProvider('my-org.hello-world.main', () => <HelloWorldPanel />),
);
};
// Define the view - automatically registered when extension loads
export const helloWorldView = defineView({
id: 'main',
title: 'Hello World',
location: 'sqllab.panels',
component: () => <HelloWorldPanel />,
});
```
export const deactivate = () => {};
That's it! For most extensions, this is all you need.
**Optional lifecycle callbacks:**
If you need to run code when your contribution activates or deactivates, add optional callbacks:
```tsx
export const helloWorldView = defineView({
id: 'main',
title: 'Hello World',
location: 'sqllab.panels',
component: () => <HelloWorldPanel />,
onActivate: () => {
// Optional: runs when panel is registered
console.log('Hello World panel activated');
},
onDeactivate: () => {
// Optional: runs when panel is unregistered
console.log('Hello World panel deactivated');
},
});
```
**Key patterns:**
- `activate` function is called when the extension loads
- `core.registerViewProvider` registers the component with ID `my-org.hello-world.main` (matching `extension.json`)
- `defineView()` automatically handles discovery, registration, and cleanup
- `onActivate` and `onDeactivate` are completely optional
- `authentication.getCSRFToken()` retrieves the CSRF token for API calls
- Fetch calls to `/extensions/{publisher}/{name}/{endpoint}` reach your backend API
- `context.disposables.push()` ensures proper cleanup
- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
- Everything happens automatically - no manual setup required
## Step 6: Install Dependencies

View File

@@ -16,20 +16,69 @@
# under the License.
"""
REST API functions for superset-core.
REST API decorators and base classes for superset-core.
Provides dependency-injected REST API utility functions that will be replaced by
host implementations during initialization.
Provides decorator stubs 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 import RestApi, api, extension_api
add_api(MyCustomAPI)
add_extension_api(MyExtensionAPI)
# For host application APIs
@api
class MyAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
# For extension APIs (auto-discovered, registered under /extensions/)
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, TypeVar
from flask_appbuilder.api import BaseApi
T = TypeVar("T", bound=type)
# =============================================================================
# Metadata dataclass - attached to decorated classes for discovery
# =============================================================================
@dataclass
class RestApiMetadata:
"""
Metadata stored on classes decorated with @extension_api.
Attached to classes as __rest_api_metadata__ for build-time discovery.
Includes auto-inferred Flask-AppBuilder configuration fields.
"""
id: str
name: str
description: str | None = None
base_path: str = "" # Defaults to /{id}
module: str = "" # Format: "package.module.ClassName"
# Auto-inferred Flask-AppBuilder fields
resource_name: str = "" # Used for URL generation and permissions
openapi_spec_tag: str = "" # Used for OpenAPI documentation grouping
class_permission_name: str = "" # Used for RBAC permissions
# =============================================================================
# Base class
# =============================================================================
class RestApi(BaseApi):
"""
@@ -42,31 +91,150 @@ class RestApi(BaseApi):
allow_browser_login = True
def add_api(api: type[RestApi]) -> None:
# =============================================================================
# Decorator stubs - replaced by host application during initialization
# =============================================================================
def api(cls: T) -> T:
"""
Add a REST API to the Superset API.
Decorator to register a REST API with the host application.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
:param api: A REST API instance.
:returns: None.
Usage:
@api
class MyAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
Args:
cls: The API class to register
Returns:
The decorated class
Raises:
NotImplementedError: Before host implementation is initialized
"""
raise NotImplementedError("Function will be replaced during initialization")
raise NotImplementedError(
"REST API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def add_extension_api(api: type[RestApi]) -> None:
def extension_api(
id: str, # noqa: A002
name: str,
description: str | None = None,
base_path: str | None = None,
resource_name: str | None = None,
openapi_spec_tag: str | None = None,
class_permission_name: str | None = None,
) -> Callable[[T], T]:
"""
Add an extension REST API to the Superset API.
Decorator to mark a class as an extension REST API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
In BUILD mode, stores metadata for discovery without registration.
Auto-infers Flask-AppBuilder fields from decorator parameters:
- resource_name: defaults to id (lowercase)
- openapi_spec_tag: defaults to name
- class_permission_name: defaults to resource_name
- base_path: defaults to /{id}
Extension APIs are:
- Auto-discovered at build time
- Registered under /api/v1/extensions/{id}/
- Subject to manifest validation for security
Usage:
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
# These are auto-set by the decorator:
# resource_name = "my_api"
# openapi_spec_tag = "My Extension API"
# class_permission_name = "my_api"
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
Args:
id: Unique identifier for this API (used in URL path and resource_name)
name: Human-readable name for the API (used for openapi_spec_tag)
description: Description of the API (defaults to class docstring)
base_path: Base URL path (defaults to /{id})
resource_name: Override resource_name (defaults to id)
openapi_spec_tag: Override OpenAPI tag (defaults to name)
class_permission_name: Override permission name (defaults to resource_name)
Returns:
Decorator that attaches __rest_api_metadata__ and auto-configures
Flask-AppBuilder fields
Raises:
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError("Function will be replaced during initialization")
def decorator(cls: T) -> T:
# Auto-infer Flask-AppBuilder fields
inferred_resource_name = resource_name or id.lower()
inferred_openapi_spec_tag = openapi_spec_tag or name
inferred_class_permission_name = class_permission_name or inferred_resource_name
inferred_base_path = base_path or f"/{id}"
# Set Flask-AppBuilder attributes on the class
cls.resource_name = inferred_resource_name # type: ignore[attr-defined]
cls.openapi_spec_tag = inferred_openapi_spec_tag # type: ignore[attr-defined]
cls.class_permission_name = inferred_class_permission_name # type: ignore[attr-defined]
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
api_description = description
if api_description is None and cls.__doc__:
api_description = cls.__doc__.strip().split("\n")[0]
metadata = RestApiMetadata(
id=id,
name=name,
description=api_description,
base_path=inferred_base_path,
module=f"{cls.__module__}.{cls.__name__}",
resource_name=inferred_resource_name,
openapi_spec_tag=inferred_openapi_spec_tag,
class_permission_name=inferred_class_permission_name,
)
cls.__rest_api_metadata__ = metadata # type: ignore[attr-defined]
return cls
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"Extension REST API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
return decorator
__all__ = ["RestApi", "add_api", "add_extension_api"]
__all__ = [
"RestApi",
"RestApiMetadata",
"api",
"extension_api",
]

View File

@@ -14,3 +14,51 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Extension framework types and context."""
from superset_core.extensions.context import (
ContributionType,
get_context,
PendingContribution,
RegistrationContext,
RegistrationMode,
)
from superset_core.extensions.types import (
BackendContributions,
ExtensionConfig,
ExtensionConfigBackend,
ExtensionConfigFrontend,
FrontendContributions,
Manifest,
ManifestBackend,
ManifestFrontend,
McpPromptContribution,
McpToolContribution,
ModuleFederationConfig,
RestApiContribution,
)
__all__ = [
# Context
"ContributionType",
"get_context",
"PendingContribution",
"RegistrationContext",
"RegistrationMode",
# Types - Config
"ExtensionConfig",
"ExtensionConfigBackend",
"ExtensionConfigFrontend",
# Types - Manifest
"Manifest",
"ManifestBackend",
"ManifestFrontend",
# Types - Contributions
"BackendContributions",
"FrontendContributions",
"McpToolContribution",
"McpPromptContribution",
"RestApiContribution",
"ModuleFederationConfig",
]

View File

@@ -0,0 +1,197 @@
# 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.
"""
Registration context for contribution discovery and security.
Controls how decorators behave based on the current execution context:
- host: Register immediately (for host application components)
- extension: Store metadata only, defer to ExtensionManager (security boundary)
- build: Store metadata only, for CLI discovery
The manifest.json serves as the security allowlist for extensions.
Only contributions declared in the manifest will be registered.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Iterator, Literal, TYPE_CHECKING
if TYPE_CHECKING:
from superset_core.api.rest_api import RestApiMetadata
from superset_core.mcp import PromptMetadata, ToolMetadata
# Type alias for contribution types
ContributionType = Literal["tool", "prompt", "restApi"]
class RegistrationMode(Enum):
"""Registration modes for decorator behavior."""
HOST = "host" # Register immediately (host application)
EXTENSION = "extension" # Defer registration (manifest validation)
BUILD = "build" # Metadata only (CLI discovery)
@dataclass
class PendingContribution:
"""A contribution waiting for registration after manifest validation."""
func: Callable[..., Any]
metadata: ToolMetadata | PromptMetadata | RestApiMetadata
contrib_type: ContributionType
@dataclass
class RegistrationContext:
"""
Global context controlling decorator registration behavior.
In host mode, decorators register immediately with MCP.
In extension mode, decorators store metadata and the ExtensionManager
validates against the manifest before completing registration.
In build mode, decorators only store metadata for discovery.
"""
_mode: RegistrationMode = RegistrationMode.HOST
_current_extension_id: str | None = None
_pending_contributions: dict[str, list[PendingContribution]] = field(
default_factory=dict
)
def set_mode(self, mode: RegistrationMode) -> None:
"""Set the global registration mode."""
self._mode = mode
@property
def mode(self) -> RegistrationMode:
"""Get the current registration mode."""
return self._mode
@property
def is_host_mode(self) -> bool:
"""True if in host mode (immediate registration)."""
return self._mode == RegistrationMode.HOST
@property
def is_extension_mode(self) -> bool:
"""True if in extension mode (deferred registration)."""
return self._mode == RegistrationMode.EXTENSION
@property
def is_build_mode(self) -> bool:
"""True if in build mode (metadata only)."""
return self._mode == RegistrationMode.BUILD
@property
def current_extension_id(self) -> str | None:
"""Get the current extension ID being loaded."""
return self._current_extension_id
@contextmanager
def extension_context(self, extension_id: str) -> Iterator[None]:
"""
Context manager for loading an extension.
While in this context, decorators defer registration and store
contributions for manifest validation.
Args:
extension_id: The extension being loaded
Yields:
None
"""
old_mode = self._mode
old_ext = self._current_extension_id
self._mode = RegistrationMode.EXTENSION
self._current_extension_id = extension_id
self._pending_contributions[extension_id] = []
try:
yield
finally:
self._mode = old_mode
self._current_extension_id = old_ext
def add_pending_contribution(
self,
func: Callable[..., Any],
metadata: ToolMetadata | PromptMetadata | RestApiMetadata,
contrib_type: ContributionType,
) -> None:
"""
Add a contribution pending manifest validation.
Called by decorators in extension mode.
Args:
func: The decorated function
metadata: The contribution metadata
contrib_type: Type of contribution ("tool", "prompt", "restApi")
"""
if self._current_extension_id is None:
raise RuntimeError(
"Cannot add pending contribution outside extension context"
)
self._pending_contributions[self._current_extension_id].append(
PendingContribution(
func=func,
metadata=metadata,
contrib_type=contrib_type,
)
)
def get_pending_contributions(self, extension_id: str) -> list[PendingContribution]:
"""
Get pending contributions for an extension.
Called by ExtensionManager during manifest validation.
Args:
extension_id: The extension to get contributions for
Returns:
List of pending contributions
"""
return self._pending_contributions.get(extension_id, [])
def clear_pending_contributions(self, extension_id: str) -> None:
"""
Clear pending contributions after registration.
Called by ExtensionManager after successful registration.
Args:
extension_id: The extension to clear contributions for
"""
self._pending_contributions.pop(extension_id, None)
# Global singleton instance
_context = RegistrationContext()
def get_context() -> RegistrationContext:
"""Get the global registration context."""
return _context

View File

@@ -87,6 +87,8 @@ class ContributionConfig(BaseModel):
}
}
"""
class FrontendContributions(BaseModel):
"""Frontend UI contributions."""
commands: list[dict[str, Any]] = Field(
default_factory=list,
@@ -104,6 +106,68 @@ class ContributionConfig(BaseModel):
default_factory=list,
description="Editor contributions",
)
editors: list[dict[str, Any]] = Field(
default_factory=list,
description="Editor contributions",
)
class McpToolContribution(BaseModel):
"""MCP tool contribution."""
id: str
name: str
description: str | None = None
module: str
tags: list[str] = Field(default_factory=list)
protect: bool = True
class McpPromptContribution(BaseModel):
"""MCP prompt contribution."""
id: str
name: str
title: str | None = None
description: str | None = None
module: str
tags: list[str] = Field(default_factory=list)
protect: bool = True
class RestApiContribution(BaseModel):
"""REST API contribution."""
id: str
name: str
description: str | None = None
module: str
basePath: str # noqa: N815
# Auto-inferred Flask-AppBuilder fields
resourceName: str = "" # noqa: N815
openapiSpecTag: str = "" # noqa: N815
classPermissionName: str = "" # noqa: N815
class BackendContributions(BaseModel):
"""Backend contributions."""
mcp_tools: list[McpToolContribution] = Field(
default_factory=list,
description="MCP tools",
alias="mcp.tools",
)
mcp_prompts: list[McpPromptContribution] = Field(
default_factory=list,
description="MCP prompts",
alias="mcp.prompts",
)
rest_apis: list[RestApiContribution] = Field(
default_factory=list,
description="REST APIs",
alias="rest.apis",
)
class BaseExtension(BaseModel):
@@ -158,10 +222,6 @@ class BaseExtension(BaseModel):
class ExtensionConfigFrontend(BaseModel):
"""Frontend section in extension.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
description="Module Federation configuration",
@@ -177,7 +237,8 @@ class ExtensionConfigBackend(BaseModel):
)
files: list[str] = Field(
default_factory=list,
description="Glob patterns for backend Python files",
description="Glob patterns for backend Python files (defaults to"
"backend/src/**/*.py)",
)
@@ -185,7 +246,9 @@ class ExtensionConfig(BaseExtension):
"""
Schema for extension.json (source configuration).
This file is authored by developers to define extension metadata.
Authored by developers. Contributions can be:
- Auto-discovered from decorated code (default)
- Manually specified in contributions field (overrides discovery)
"""
frontend: ExtensionConfigFrontend | None = Field(
@@ -206,9 +269,9 @@ class ExtensionConfig(BaseExtension):
class ManifestFrontend(BaseModel):
"""Frontend section in manifest.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
contributions: FrontendContributions = Field(
default_factory=FrontendContributions,
description="Frontend contributions",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
@@ -227,13 +290,18 @@ class ManifestBackend(BaseModel):
default_factory=list,
description="Python module entry points to load",
)
contributions: BackendContributions = Field(
default_factory=BackendContributions,
description="Backend contributions",
)
class Manifest(BaseExtension):
"""
Schema for manifest.json (built output).
This file is generated by the build tool from extension.json.
Generated by the build tool. Contains all contributions
(discovered or manually specified) that will be registered at runtime.
"""
id: str = Field(

View File

@@ -16,31 +16,78 @@
# under the License.
"""
MCP (Model Context Protocol) tool registration for Superset MCP server.
MCP (Model Context Protocol) tool and prompt registration for Superset.
This module provides a decorator interface to register MCP tools with the
host application.
This module provides decorator stubs that are replaced by the host application
during initialization. Each decorator defines metadata dataclasses that are
used for build-time discovery.
Usage:
from superset_core.mcp import tool
from superset_core.mcp import tool, prompt
@tool(name="my_tool", description="Custom business logic", tags=["extension"])
def my_extension_tool(param: str) -> dict:
return {"message": f"Hello {param}!"}
@tool(tags=["database"])
def query_database(sql: str) -> dict:
'''Execute a SQL query against a database.'''
return execute_query(sql)
# Or use function name and docstring:
@tool
def another_tool(value: int) -> str:
'''Tool description from docstring'''
return str(value * 2)
@prompt(tags={"analysis"})
async def analyze_data(ctx, dataset: str) -> str:
'''Generate analysis for a dataset.'''
return f"Analyze {dataset}..."
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, TypeVar
# Type variable for decorated functions
F = TypeVar("F", bound=Callable[..., Any])
# =============================================================================
# Metadata dataclasses - attached to decorated functions for discovery
# =============================================================================
@dataclass
class ToolMetadata:
"""
Metadata stored on functions decorated with @tool.
Attached to functions as __tool_metadata__ for build-time discovery.
"""
id: str
name: str
description: str | None = None
tags: list[str] = field(default_factory=list)
protect: bool = True
module: str = "" # Format: "package.module.function_name"
@dataclass
class PromptMetadata:
"""
Metadata stored on functions decorated with @prompt.
Attached to functions as __prompt_metadata__ for build-time discovery.
"""
id: str
name: str
title: str | None = None
description: str | None = None
tags: set[str] = field(default_factory=set)
protect: bool = True
module: str = "" # Format: "package.module.function_name"
# =============================================================================
# Decorator stubs - replaced by host application during initialization
# =============================================================================
def tool(
func_or_name: str | Callable[..., Any] | None = None,
*,
@@ -48,53 +95,90 @@ def tool(
description: str | None = None,
tags: list[str] | None = None,
protect: bool = True,
) -> Any: # Use Any to avoid mypy issues with dependency injection
) -> Any:
"""
Decorator to register an MCP tool with optional authentication.
This decorator combines FastMCP tool registration with optional authentication.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
In BUILD mode, stores metadata for discovery without registration.
Can be used as:
@tool
def my_tool(): ...
Or:
@tool(tags=["database"])
def query(): ...
@tool(name="custom_name", protect=False)
def my_tool(): ...
Args:
func_or_name: When used as @tool, this will be the function.
When used as @tool("name"), this will be the name.
name: Tool name (defaults to function name, prefixed with extension ID)
description: Tool description (defaults to function docstring)
tags: List of tags for categorizing the tool (defaults to empty list)
name: Tool name (defaults to function name)
description: Tool description (defaults to first line of docstring)
tags: List of tags for categorizing the tool
protect: Whether to require Superset authentication (defaults to True)
Returns:
Decorator function that registers and wraps the tool, or the wrapped function
Decorated function with __tool_metadata__ attribute
Raises:
NotImplementedError: If called before host implementation is initialized
Example:
@tool(name="my_tool", description="Does something useful", tags=["utility"])
def my_custom_tool(param: str) -> dict:
return {"result": param}
@tool # Uses function name and docstring with auth
def simple_tool(value: int) -> str:
'''Doubles the input value'''
return str(value * 2)
@tool(protect=False) # No authentication required
def public_tool() -> str:
'''Public tool accessible without auth'''
return "Hello world"
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError(
"MCP tool decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def decorator(func: F) -> F:
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
tool_name = name or func.__name__
tool_description = description
if tool_description is None and func.__doc__:
tool_description = func.__doc__.strip().split("\n")[0]
tool_tags = tags or []
metadata = ToolMetadata(
id=func.__name__,
name=tool_name,
description=tool_description,
tags=tool_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__tool_metadata__ = metadata # type: ignore[attr-defined]
return func
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"MCP tool decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
# Handle decorator usage patterns
if callable(func_or_name):
return decorator(func_or_name)
# Return parameterized decorator
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
nonlocal name
if actual_name is not None:
name = actual_name
return decorator(func)
return parameterized_decorator
def prompt(
@@ -105,57 +189,98 @@ def prompt(
description: str | None = None,
tags: set[str] | None = None,
protect: bool = True,
) -> Any: # Use Any to avoid mypy issues with dependency injection
) -> Any:
"""
Decorator to register an MCP prompt with optional authentication.
This decorator combines FastMCP prompt registration with optional authentication.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
In BUILD mode, stores metadata for discovery without registration.
Can be used as:
@prompt
async def my_prompt_handler(): ...
async def my_prompt(ctx): ...
Or:
@prompt("my_prompt")
async def my_prompt_handler(): ...
@prompt(tags={"analysis"})
async def analyze(ctx): ...
Or:
@prompt("my_prompt", protected=False, title="Custom Title")
async def my_prompt_handler(): ...
@prompt("custom_name", title="Custom Title")
async def my_prompt(ctx): ...
Args:
func_or_name: When used as @prompt, this will be the function.
When used as @prompt("name"), this will be the name.
name: Prompt name (defaults to function name if not provided)
name: Prompt name (defaults to function name)
title: Prompt title (defaults to function name)
description: Prompt description (defaults to function docstring)
description: Prompt description (defaults to first line of docstring)
tags: Set of tags for categorizing the prompt
protect: Whether to require Superset authentication (defaults to True)
Returns:
Decorator function that registers and wraps the prompt, or the wrapped function
Decorated function with __prompt_metadata__ attribute
Raises:
NotImplementedError: If called before host implementation is initialized
Example:
@prompt
async def my_prompt_handler(ctx: Context) -> str:
'''Interactive prompt for doing something.'''
return "Prompt instructions here..."
@prompt("custom_prompt", protect=False, title="Custom Title")
async def public_prompt_handler(ctx: Context) -> str:
'''Public prompt accessible without auth'''
return "Public prompt accessible without auth"
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def decorator(func: F) -> F:
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
prompt_name = name or func.__name__
prompt_title = title or func.__name__
prompt_description = description
if prompt_description is None and func.__doc__:
prompt_description = func.__doc__.strip().split("\n")[0]
prompt_tags = tags or set()
metadata = PromptMetadata(
id=func.__name__,
name=prompt_name,
title=prompt_title,
description=prompt_description,
tags=prompt_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__prompt_metadata__ = metadata # type: ignore[attr-defined]
return func
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
# Handle decorator usage patterns
if callable(func_or_name):
return decorator(func_or_name)
# Return parameterized decorator
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
nonlocal name
if actual_name is not None:
name = actual_name
return decorator(func)
return parameterized_decorator
__all__ = [
"tool",
"prompt",
"ToolMetadata",
"PromptMetadata",
]

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,147 @@
# 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.
"""Tests for REST API decorators in BUILD mode."""
from superset_core.api.rest_api import extension_api, RestApi
from superset_core.extensions.context import get_context, RegistrationMode
def test_extension_api_decorator_stores_metadata():
"""Test that @extension_api decorator stores metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(id="test_api", name="Test API")
class TestAPI(RestApi):
"""Test API class."""
pass
# Should have metadata attached
assert hasattr(TestAPI, "__rest_api_metadata__")
meta = TestAPI.__rest_api_metadata__
assert meta.id == "test_api"
assert meta.name == "Test API"
assert meta.description == "Test API class."
assert meta.base_path == "/test_api"
assert "TestAPI" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_with_custom_metadata():
"""Test @extension_api decorator with custom metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(
id="custom_api",
name="Custom API",
description="Custom description",
base_path="/custom/path",
)
class CustomAPI(RestApi):
"""Original docstring."""
pass
meta = CustomAPI.__rest_api_metadata__
assert meta.id == "custom_api"
assert meta.name == "Custom API"
assert meta.description == "Custom description"
assert meta.base_path == "/custom/path"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_auto_infers_flask_fields():
"""Test that @extension_api auto-infers Flask-AppBuilder fields."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(id="infer_api", name="Infer API")
class InferAPI(RestApi):
"""Test auto-inference."""
pass
meta = InferAPI.__rest_api_metadata__
# Check auto-inferred fields
assert meta.resource_name == "infer_api"
assert meta.openapi_spec_tag == "Infer API"
assert meta.class_permission_name == "infer_api"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_custom_flask_fields():
"""Test @extension_api with custom Flask-AppBuilder fields."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(
id="custom_flask_api",
name="Custom Flask API",
resource_name="custom_resource",
openapi_spec_tag="Custom Tag",
class_permission_name="custom_permission",
)
class CustomFlaskAPI(RestApi):
"""Custom Flask fields."""
pass
meta = CustomFlaskAPI.__rest_api_metadata__
assert meta.resource_name == "custom_resource"
assert meta.openapi_spec_tag == "Custom Tag"
assert meta.class_permission_name == "custom_permission"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_does_not_register_in_build_mode():
"""Test that @extension_api doesn't attempt registration in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# This should not raise an exception even though no registration mechanism
# is available
@extension_api(id="build_api", name="Build API")
class BuildAPI(RestApi):
"""Build mode API."""
pass
# Should have metadata
assert hasattr(BuildAPI, "__rest_api_metadata__")
finally:
ctx.set_mode(RegistrationMode.HOST)

View File

@@ -0,0 +1,154 @@
# 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.
"""Tests for MCP decorators in BUILD mode."""
from superset_core.extensions.context import get_context, RegistrationMode
from superset_core.mcp import prompt, tool
def test_tool_decorator_stores_metadata_in_build_mode():
"""Test that @tool decorator stores metadata when in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@tool(tags=["database"])
def test_query_tool(sql: str) -> dict:
"""Execute a SQL query."""
return {"result": "test"}
# Should have metadata attached
assert hasattr(test_query_tool, "__tool_metadata__")
meta = test_query_tool.__tool_metadata__
assert meta.id == "test_query_tool"
assert meta.name == "test_query_tool"
assert meta.description == "Execute a SQL query."
assert meta.tags == ["database"]
assert meta.protect is True
assert "test_query_tool" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_tool_decorator_with_custom_metadata():
"""Test @tool decorator with custom name and description."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@tool(
name="Custom Tool",
description="Custom description",
tags=["test"],
protect=False,
)
def my_tool() -> str:
"""Original docstring."""
return "test"
meta = my_tool.__tool_metadata__
assert meta.id == "my_tool"
assert meta.name == "Custom Tool"
assert meta.description == "Custom description"
assert meta.tags == ["test"]
assert meta.protect is False
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_prompt_decorator_stores_metadata_in_build_mode():
"""Test that @prompt decorator stores metadata when in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@prompt(tags=["analysis"])
async def test_prompt(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analyze {dataset}"
# Should have metadata attached
assert hasattr(test_prompt, "__prompt_metadata__")
meta = test_prompt.__prompt_metadata__
assert meta.id == "test_prompt"
assert meta.name == "test_prompt"
assert meta.description == "Generate analysis for a dataset."
assert meta.tags == ["analysis"]
assert meta.protect is True
assert "test_prompt" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_prompt_decorator_with_custom_metadata():
"""Test @prompt decorator with custom metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@prompt(
name="Custom Prompt",
title="Custom Title",
description="Custom description",
tags=["custom"],
protect=False,
)
async def my_prompt(ctx) -> str:
"""Original docstring."""
return "test"
meta = my_prompt.__prompt_metadata__
assert meta.id == "my_prompt"
assert meta.name == "Custom Prompt"
assert meta.title == "Custom Title"
assert meta.description == "Custom description"
assert meta.tags == ["custom"]
assert meta.protect is False
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_decorators_do_not_register_in_build_mode():
"""Test that decorators don't attempt registration in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# This should not raise an exception even though no MCP server is available
@tool(tags=["test"])
def build_mode_tool() -> str:
return "test"
@prompt(tags=["test"])
async def build_mode_prompt(ctx) -> str:
return "test"
# Both should have metadata
assert hasattr(build_mode_tool, "__tool_metadata__")
assert hasattr(build_mode_prompt, "__prompt_metadata__")
finally:
ctx.set_mode(RegistrationMode.HOST)

View File

@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
import importlib.util
import inspect
import json # noqa: TID251
import re
import shutil
@@ -28,11 +30,18 @@ from typing import Any, Callable
import click
import semver
from jinja2 import Environment, FileSystemLoader
from superset_core.extensions.types import (
from superset_core.extensions import (
BackendContributions,
ExtensionConfig,
FrontendContributions,
Manifest,
ManifestBackend,
ManifestFrontend,
McpPromptContribution,
McpToolContribution,
RegistrationMode,
RestApiContribution,
get_context,
)
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -55,6 +64,135 @@ REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist")
def discover_backend_contributions(
cwd: Path, files_patterns: list[str]
) -> BackendContributions:
"""
Discover backend contributions by importing modules and inspecting decorated objects.
Sets context to BUILD mode so decorators only store metadata, no registration.
"""
contributions = BackendContributions()
# Set build mode so decorators don't try to register
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# Collect all Python files matching patterns
py_files: list[Path] = []
for pattern in files_patterns:
py_files.extend(cwd.glob(pattern))
# Filter to only process Python files
python_files = [f for f in py_files if f.is_file() and f.suffix == ".py"]
for py_file in python_files:
try:
# Import module dynamically
module = _import_module_from_path(py_file)
if module is None:
continue
# Inspect all members for decorated objects
for name, obj in inspect.getmembers(module):
if name.startswith("_"):
continue
# Check for @tool metadata
if hasattr(obj, "__tool_metadata__"):
meta = obj.__tool_metadata__
contributions.mcp_tools.append(
McpToolContribution(
id=meta.id,
name=meta.name,
description=meta.description,
module=meta.module,
tags=list(meta.tags),
protect=meta.protect,
)
)
# Check for @prompt metadata
if hasattr(obj, "__prompt_metadata__"):
meta = obj.__prompt_metadata__
contributions.mcp_prompts.append(
McpPromptContribution(
id=meta.id,
name=meta.name,
title=meta.title,
description=meta.description,
module=meta.module,
tags=list(meta.tags),
protect=meta.protect,
)
)
# Check for @extension_api metadata
if hasattr(obj, "__rest_api_metadata__"):
meta = obj.__rest_api_metadata__
contributions.rest_apis.append(
RestApiContribution(
id=meta.id,
name=meta.name,
description=meta.description,
module=meta.module,
basePath=meta.base_path,
resourceName=meta.resource_name,
openapiSpecTag=meta.openapi_spec_tag,
classPermissionName=meta.class_permission_name,
)
)
except Exception as e:
click.secho(f"⚠️ Failed to analyze {py_file}: {e}", fg="yellow")
finally:
# Reset to host mode
ctx.set_mode(RegistrationMode.HOST)
return contributions
def discover_frontend_contributions(cwd: Path) -> FrontendContributions:
"""
Discover frontend contributions from webpack plugin output.
The webpack plugin outputs a contributions.json file during build.
"""
contributions_file = cwd / "frontend" / "dist" / "contributions.json"
if not contributions_file.exists():
# No frontend contributions found - this is normal for extensions without frontend
return FrontendContributions()
try:
contributions_data = json.loads(contributions_file.read_text())
return FrontendContributions.model_validate(contributions_data)
except Exception as e:
click.secho(f"⚠️ Failed to parse frontend contributions: {e}", fg="yellow")
return FrontendContributions()
def _import_module_from_path(py_file: Path) -> Any:
"""Import a Python module from a file path."""
module_name = py_file.stem
spec = importlib.util.spec_from_file_location(module_name, py_file)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
return module
except Exception:
# Clean up on failure
sys.modules.pop(module_name, None)
raise
def validate_npm() -> None:
"""Abort if `npm` is not on PATH."""
if shutil.which("npm") is None:
@@ -148,20 +286,53 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
extension = ExtensionConfig.model_validate(extension_data)
# Generate composite ID from publisher and name
composite_id = f"{extension.publisher}.{extension.name}"
# Build frontend manifest with auto-discovery
frontend: ManifestFrontend | None = None
if extension.frontend and remote_entry:
click.secho("🔍 Auto-discovering frontend contributions...", fg="cyan")
frontend_contributions = discover_frontend_contributions(cwd)
# Count contributions for feedback
command_count = len(frontend_contributions.commands)
view_count = sum(len(views) for views in frontend_contributions.views.values())
menu_count = len(frontend_contributions.menus)
editor_count = len(frontend_contributions.editors)
total_count = command_count + view_count + menu_count + editor_count
if total_count > 0:
click.secho(
f" Found: {command_count} commands, {view_count} views, {menu_count} menus, {editor_count} editors",
fg="green",
)
else:
click.secho(" No frontend contributions found", fg="yellow")
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
contributions=frontend_contributions,
moduleFederation=extension.frontend.moduleFederation,
remoteEntry=remote_entry,
)
# Build backend manifest with auto-discovered contributions
backend: ManifestBackend | None = None
if extension.backend and extension.backend.entryPoints:
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
if extension.backend:
click.secho("🔍 Auto-discovering backend contributions...", fg="cyan")
backend_contributions = discover_backend_contributions(
cwd, extension.backend.files
)
tool_count = len(backend_contributions.mcp_tools)
prompt_count = len(backend_contributions.mcp_prompts)
api_count = len(backend_contributions.rest_apis)
click.secho(
f" Found: {tool_count} tools, {prompt_count} prompts, {api_count} APIs",
fg="green",
)
backend = ManifestBackend(
entryPoints=extension.backend.entryPoints,
contributions=backend_contributions,
)
return Manifest(
id=composite_id,

View File

@@ -6,12 +6,6 @@
"license": "{{ license }}",
{% if include_frontend -%}
"frontend": {
"contributions": {
"commands": [],
"views": {},
"menus": {},
"editors": []
},
"moduleFederation": {
"name": "{{ mf_name }}",
"exposes": ["./index"]

View File

@@ -19,6 +19,7 @@
"react-dom": "^17.0.2"
},
"devDependencies": {
"@apache-superset/webpack-extension-plugin": "^0.0.1",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^19.0.10",

View File

@@ -1,13 +1,32 @@
import React from "react";
import { core } from "@apache-superset/core";
import { defineCommand, defineView } from "@apache-superset/core";
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>)
);
console.log("{{ name }} extension activated");
};
// Example command
export const exampleCommand = defineCommand({
id: "example",
title: "Example Command",
icon: "ExperimentOutlined",
execute: async () => {
console.log("{{ name }} command executed!");
},
onActivate: () => {
console.log("Example command activated");
},
onDeactivate: () => {
console.log("Example command deactivated");
},
});
export const deactivate = () => {
console.log("{{ name }} extension deactivated");
};
// Example view
export const exampleView = defineView({
id: "example",
title: "{{ name }} View",
location: "explore.panels", // or dashboard.tabs, sqllab.panels, etc.
component: () => <div>Welcome to {{ name }}!</div>,
onActivate: () => {
console.log("Example view activated");
},
onDeactivate: () => {
console.log("Example view deactivated");
},
});

View File

@@ -1,5 +1,6 @@
const path = require("path");
const { ModuleFederationPlugin } = require("webpack").container;
const SupersetExtensionPlugin = require("@apache-superset/webpack-extension-plugin");
const packageConfig = require("./package");
module.exports = (env, argv) => {
@@ -62,6 +63,12 @@ module.exports = (env, argv) => {
},
},
}),
// Auto-discover and validate frontend contributions
new SupersetExtensionPlugin({
outputPath: "contributions.json",
include: ["src/**/*.{ts,tsx,js,jsx}"],
exclude: ["**/*.test.*", "**/node_modules/**"],
}),
],
};
};

View File

@@ -0,0 +1,236 @@
# 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.
"""
Tests for backend contributions discovery.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from superset_extensions_cli.cli import discover_backend_contributions
@pytest.mark.unit
def test_discover_backend_contributions_finds_tools():
"""Test that discover_backend_contributions finds @tool decorated functions."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a Python file with decorated functions
backend_file = tmpdir_path / "backend.py"
backend_code = '''
from superset_core.mcp import tool, prompt
from superset_core.api.rest_api import RestApi, extension_api
@tool(tags=["database"], description="Query the database")
def query_database(sql: str) -> dict:
"""Execute a SQL query against the database."""
return {"result": "success"}
@tool(name="custom_tool", protect=False)
def my_custom_tool():
"""A custom tool with specific configuration."""
return {"custom": True}
@prompt(tags={"analysis"}, title="Data Analysis")
async def analyze_data(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analysis for {dataset}"
@extension_api(id="test_api", name="Test API")
class TestAPI(RestApi):
"""Test API for the extension."""
def get_data(self):
return self.response(200, result={})
'''
backend_file.write_text(backend_code)
# Run discovery
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Verify tools were discovered
assert len(contributions.mcp_tools) == 2
# Check first tool
tool1 = contributions.mcp_tools[0]
assert tool1.id == "query_database"
assert tool1.name == "query_database"
assert tool1.description == "Execute a SQL query against the database."
assert tool1.tags == ["database"]
assert tool1.protect is True
assert tool1.module == "backend.query_database"
# Check second tool
tool2 = contributions.mcp_tools[1]
assert tool2.id == "my_custom_tool"
assert tool2.name == "custom_tool"
assert tool2.description == "A custom tool with specific configuration."
assert tool2.tags == []
assert tool2.protect is False
assert tool2.module == "backend.my_custom_tool"
# Verify prompt was discovered
assert len(contributions.mcp_prompts) == 1
prompt1 = contributions.mcp_prompts[0]
assert prompt1.id == "analyze_data"
assert prompt1.name == "analyze_data"
assert prompt1.title == "Data Analysis"
assert prompt1.description == "Generate analysis for a dataset."
assert prompt1.tags == {"analysis"}
assert prompt1.protect is True
assert prompt1.module == "backend.analyze_data"
# Verify REST API was discovered
assert len(contributions.rest_apis) == 1
api1 = contributions.rest_apis[0]
assert api1.id == "test_api"
assert api1.name == "Test API"
assert api1.description == "Test API for the extension."
assert api1.basePath == "/test_api"
assert api1.module == "backend.TestAPI"
# Verify auto-inferred Flask-AppBuilder fields
assert api1.resourceName == "test_api" # defaults to id.lower()
assert api1.openapiSpecTag == "Test API" # defaults to name
assert api1.classPermissionName == "test_api" # defaults to resource_name
@pytest.mark.unit
def test_discover_backend_contributions_handles_empty_directory():
"""Test that discovery handles directories with no Python files."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a non-Python file
(tmpdir_path / "readme.txt").write_text("This is not Python")
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
assert len(contributions.mcp_tools) == 0
assert len(contributions.mcp_prompts) == 0
assert len(contributions.rest_apis) == 0
@pytest.mark.unit
def test_discover_backend_contributions_handles_syntax_errors():
"""Test that discovery handles Python files with syntax errors gracefully."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a Python file with syntax error
bad_file = tmpdir_path / "bad.py"
bad_file.write_text("def broken_function(\n # missing closing parenthesis")
# Create a good file with contributions
good_file = tmpdir_path / "good.py"
good_code = '''
from superset_core.mcp import tool
@tool
def working_tool():
"""This tool should be discovered."""
return {}
'''
good_file.write_text(good_code)
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Should discover the working tool despite the syntax error in other file
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "working_tool"
@pytest.mark.unit
def test_discover_backend_contributions_skips_private_functions():
"""Test that discovery skips functions starting with underscore."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
backend_file = tmpdir_path / "backend.py"
backend_code = '''
from superset_core.mcp import tool
@tool
def public_tool():
"""This should be discovered."""
return {}
@tool
def _private_tool():
"""This should be skipped."""
return {}
'''
backend_file.write_text(backend_code)
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Should only find the public tool
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "public_tool"
@pytest.mark.unit
def test_discover_backend_contributions_uses_file_patterns():
"""Test that discovery respects file patterns."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create files in different subdirectories
src_dir = tmpdir_path / "src"
src_dir.mkdir()
tests_dir = tmpdir_path / "tests"
tests_dir.mkdir()
src_file = src_dir / "tools.py"
src_code = '''
from superset_core.mcp import tool
@tool
def src_tool():
"""Tool from src directory."""
return {}
'''
src_file.write_text(src_code)
test_file = tests_dir / "test_tools.py"
test_code = '''
from superset_core.mcp import tool
@tool
def test_tool():
"""Tool from tests directory."""
return {}
'''
test_file.write_text(test_code)
# Only search in src directory
contributions = discover_backend_contributions(tmpdir_path, ["src/**/*.py"])
# Should only find the src tool
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "src_tool"

View File

@@ -61,23 +61,15 @@ def extension_with_build_structure():
if include_frontend:
extension_json["frontend"] = {
"contributions": {
"commands": [],
"views": {},
"menus": {},
"editors": [],
},
"moduleFederation": {
"exposes": ["./index"],
"name": "testOrg_testExtension",
},
"moduleFederation": {"exposes": ["./index"]},
}
if include_backend:
extension_json["backend"] = {
"entryPoints": [
"superset_extensions.test_org.test_extension.entrypoint"
]
],
"files": ["backend/src/**/*.py"],
}
(base_path / "extension.json").write_text(json.dumps(extension_json))
@@ -250,19 +242,11 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
"permissions": ["read_data"],
"dependencies": ["some_dep"],
"frontend": {
"contributions": {
"commands": [{"id": "test_command", "title": "Test"}],
"views": {},
"menus": {},
"editors": [],
},
"moduleFederation": {
"exposes": ["./index"],
"name": "testOrg_testExtension",
},
"moduleFederation": {"exposes": ["./index"]},
},
"backend": {
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"]
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"],
"files": ["backend/src/**/*.py"],
},
}
extension_json = isolated_filesystem / "extension.json"
@@ -279,15 +263,15 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
assert manifest.permissions == ["read_data"]
assert manifest.dependencies == ["some_dep"]
# Verify frontend section
# Verify frontend section (auto-discovery, currently empty)
assert manifest.frontend is not None
assert manifest.frontend.contributions.commands == [
{"id": "test_command", "title": "Test"}
]
assert (
manifest.frontend.contributions.commands == []
) # Auto-discovered (empty for now)
assert manifest.frontend.moduleFederation.exposes == ["./index"]
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
# Verify backend section
# Verify backend section (auto-discovery)
assert manifest.backend is not None
assert manifest.backend.entryPoints == [
"superset_extensions.test_org.test_extension.entrypoint"

View File

@@ -470,6 +470,10 @@
"resolved": "packages/superset-core",
"link": true
},
"node_modules/@apache-superset/webpack-extension-plugin": {
"resolved": "packages/webpack-extension-plugin",
"link": true
},
"node_modules/@asamuzakjp/css-color": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
@@ -6072,7 +6076,6 @@
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -14702,7 +14705,6 @@
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*",
@@ -14713,7 +14715,6 @@
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint": "*",
@@ -14724,7 +14725,6 @@
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/expect": {
@@ -17224,7 +17224,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
@@ -17235,28 +17234,24 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
@@ -17268,14 +17263,12 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17288,7 +17281,6 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
@@ -17298,7 +17290,6 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@xtuc/long": "4.2.2"
@@ -17308,14 +17299,12 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17332,7 +17321,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17346,7 +17334,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17359,7 +17346,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17374,7 +17360,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
@@ -17432,14 +17417,12 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@yarnpkg/lockfile": {
@@ -17931,7 +17914,6 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
@@ -19273,7 +19255,6 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -19706,7 +19687,6 @@
"version": "1.0.30001764",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -19997,7 +19977,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0"
@@ -23871,7 +23850,6 @@
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
"node_modules/emittery": {
@@ -23963,7 +23941,6 @@
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -25289,7 +25266,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
@@ -25303,7 +25279,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
@@ -25621,7 +25596,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -28059,7 +28033,6 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/global-dirs": {
@@ -36195,7 +36168,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
@@ -37468,7 +37440,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -37478,7 +37449,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -38227,7 +38197,6 @@
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nomnom": {
@@ -41368,7 +41337,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
@@ -44937,7 +44905,6 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
@@ -45064,7 +45031,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
@@ -45711,7 +45677,6 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
@@ -45722,7 +45687,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -46941,7 +46905,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -47094,7 +47057,6 @@
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
"integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
@@ -47113,7 +47075,6 @@
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
@@ -47148,7 +47109,6 @@
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -47163,7 +47123,6 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -47179,7 +47138,6 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -47192,7 +47150,6 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude": {
@@ -49185,7 +49142,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -49884,7 +49840,6 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
"integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob-to-regexp": "^0.4.1",
@@ -50565,14 +50520,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack/node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -50585,7 +50538,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -50598,14 +50550,12 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack/node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"license": "MIT"
},
"node_modules/websocket-driver": {
@@ -53013,6 +52963,20 @@
"version": "0.20.3",
"license": "Apache-2.0"
},
"packages/webpack-extension-plugin": {
"name": "@apache-superset/webpack-extension-plugin",
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"typescript": "^5.0.0"
},
"devDependencies": {
"@types/node": "^25.1.0"
},
"peerDependencies": {
"webpack": "^5.0.0"
}
},
"plugins/legacy-plugin-chart-calendar": {
"name": "@superset-ui/legacy-plugin-chart-calendar",
"version": "0.20.3",

View File

@@ -0,0 +1,326 @@
/**
* 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.
*/
// Contribution configuration interfaces
export interface CommandConfig {
id: string;
title: string;
icon?: string;
execute: () => void | Promise<void>;
when?: () => boolean;
onActivate?: () => void; // Called when command is registered
onDeactivate?: () => void; // Called when command is unregistered
}
export interface ViewConfig {
id: string;
title: string;
location: string; // e.g., "dashboard.tabs", "explore.panels"
component: React.ComponentType;
when?: () => boolean;
onActivate?: () => void; // Called when view is registered
onDeactivate?: () => void; // Called when view is unregistered
}
export interface EditorConfig {
id: string;
name: string;
mimeTypes: string[];
component: React.ComponentType;
onActivate?: () => void; // Called when editor is registered
onDeactivate?: () => void; // Called when editor is unregistered
}
export interface MenuConfig {
id: string;
title: string;
location: string; // e.g., "navbar.items", "context.menus"
action: () => void | Promise<void>;
when?: () => boolean;
onActivate?: () => void; // Called when menu item is registered
onDeactivate?: () => void; // Called when menu item is unregistered
}
// Extension metadata attached to defined contributions
export interface ContributionMetadata {
type: 'command' | 'view' | 'editor' | 'menu';
id: string;
config: any;
}
// Handle returned by define* functions for cleanup
export interface ContributionHandle<T = any> {
config: T;
dispose: () => void;
__contributionMeta__: ContributionMetadata;
}
// Extension context interface (simplified)
export interface ExtensionContext {
registerCommand: (config: CommandConfig) => () => void;
registerViewProvider: (
id: string,
component: React.ComponentType,
) => () => void;
registerEditor: (config: EditorConfig) => () => void;
registerMenu: (config: MenuConfig) => () => void;
}
// Global registry for auto-registration
let _context: ExtensionContext | null = null;
const _pendingContributions: ContributionHandle[] = [];
/**
* Set the extension context for auto-registration.
* Called automatically by the extension loader.
*/
export function setExtensionContext(context: ExtensionContext): void {
_context = context;
// Auto-register any pending contributions
for (const handle of _pendingContributions) {
_registerContribution(handle);
}
_pendingContributions.length = 0;
}
/**
* Internal: Auto-register a single contribution
*/
function _registerContribution(handle: ContributionHandle): void {
if (!_context) {
_pendingContributions.push(handle);
return;
}
const { config, __contributionMeta__ } = handle;
let disposeFn: () => void;
// Call onActivate callback if provided
const typedConfig = config as
| CommandConfig
| ViewConfig
| EditorConfig
| MenuConfig;
if (typedConfig.onActivate) {
typedConfig.onActivate();
}
switch (__contributionMeta__.type) {
case 'command':
disposeFn = _context.registerCommand(config as CommandConfig);
break;
case 'view':
const viewConfig = config as ViewConfig;
disposeFn = _context.registerViewProvider(
viewConfig.id,
viewConfig.component,
);
break;
case 'editor':
disposeFn = _context.registerEditor(config as EditorConfig);
break;
case 'menu':
disposeFn = _context.registerMenu(config as MenuConfig);
break;
default:
throw new Error(
`Unknown contribution type: ${__contributionMeta__.type}`,
);
}
// Wrap dispose function to call onDeactivate
const originalDispose = disposeFn;
handle.dispose = () => {
if (typedConfig.onDeactivate) {
typedConfig.onDeactivate();
}
originalDispose();
};
}
// Type augmentation to add metadata to functions
declare global {
interface Function {
__contributionMeta__?: ContributionMetadata;
}
}
/**
* Define a command contribution.
*
* Commands are actions that can be triggered from various UI elements
* like menus, toolbars, or keyboard shortcuts.
*
* Auto-registers when extension context is available.
*
* @param config Command configuration
* @returns Handle with config and dispose function
*/
export function defineCommand<T extends CommandConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'command',
id: config.id,
config,
};
// Attach metadata to the execute function for runtime validation
if (config.execute) {
config.execute.__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define a view contribution.
*
* Views are UI components that can be embedded in various locations
* throughout the Superset interface.
*
* Auto-registers when extension context is available.
*
* @param config View configuration
* @returns Handle with config and dispose function
*/
export function defineView<T extends ViewConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'view',
id: config.id,
config,
};
// Attach metadata to the component for runtime validation
if (config.component) {
(config.component as any).__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define an editor contribution.
*
* Editors provide custom editing interfaces for specific MIME types
* in SQL Lab and other contexts.
*
* Auto-registers when extension context is available.
*
* @param config Editor configuration
* @returns Handle with config and dispose function
*/
export function defineEditor<T extends EditorConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'editor',
id: config.id,
config,
};
// Attach metadata to the component for runtime validation
if (config.component) {
(config.component as any).__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define a menu contribution.
*
* Menus add items to various menu locations throughout the interface.
*
* Auto-registers when extension context is available.
*
* @param config Menu configuration
* @returns Handle with config and dispose function
*/
export function defineMenu<T extends MenuConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'menu',
id: config.id,
config,
};
// Attach metadata to the action function for runtime validation
if (config.action) {
config.action.__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Internal: Clear the contribution registry (for testing)
*/
export function _clearContributionRegistry(): void {
_pendingContributions.length = 0;
_context = null;
}

View File

@@ -17,5 +17,6 @@
* under the License.
*/
export * from './api';
export * from './extensions';
export * from './ui';
export * from './utils';

View File

@@ -0,0 +1,253 @@
/**
* 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 * as React from 'react';
import {
defineCommand,
defineView,
defineEditor,
defineMenu,
setExtensionContext,
_clearContributionRegistry,
} from '../src/extensions';
// Mock extension context for testing
const mockContext = {
registerCommand: jest.fn(() => jest.fn()),
registerViewProvider: jest.fn(() => jest.fn()),
registerEditor: jest.fn(() => jest.fn()),
registerMenu: jest.fn(() => jest.fn()),
};
describe('Extension Contributions', () => {
beforeEach(() => {
_clearContributionRegistry();
jest.clearAllMocks();
});
describe('defineCommand', () => {
test('should create command contribution with metadata', () => {
const command = defineCommand({
id: 'test-command',
title: 'Test Command',
icon: 'TestIcon',
execute: async () => console.log('executed'),
});
expect(command.config.id).toBe('test-command');
expect(command.config.title).toBe('Test Command');
expect(command.config.icon).toBe('TestIcon');
expect(command.__contributionMeta__).toBeDefined();
expect(command.__contributionMeta__.type).toBe('command');
expect(command.__contributionMeta__.id).toBe('test-command');
});
test('should auto-register when context is available', () => {
setExtensionContext(mockContext);
const command = defineCommand({
id: 'auto-command',
title: 'Auto Command',
execute: async () => {},
});
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
});
test('should call lifecycle callbacks', () => {
const onActivate = jest.fn();
const onDeactivate = jest.fn();
setExtensionContext(mockContext);
const command = defineCommand({
id: 'lifecycle-command',
title: 'Lifecycle Command',
execute: async () => {},
onActivate,
onDeactivate,
});
expect(onActivate).toHaveBeenCalled();
// Test disposal
command.dispose();
expect(onDeactivate).toHaveBeenCalled();
});
});
describe('defineView', () => {
test('should create view contribution with metadata', () => {
const TestComponent = () => React.createElement('div', null, 'Test');
const view = defineView({
id: 'test-view',
title: 'Test View',
location: 'sqllab.panels',
component: TestComponent,
});
expect(view.config.id).toBe('test-view');
expect(view.config.title).toBe('Test View');
expect(view.config.location).toBe('sqllab.panels');
expect(view.config.component).toBe(TestComponent);
expect(view.__contributionMeta__).toBeDefined();
expect(view.__contributionMeta__.type).toBe('view');
});
test('should auto-register when context is available', () => {
const TestComponent = () => React.createElement('div', null, 'Test');
setExtensionContext(mockContext);
defineView({
id: 'auto-view',
title: 'Auto View',
location: 'dashboard.tabs',
component: TestComponent,
});
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
'auto-view',
TestComponent
);
});
});
describe('defineEditor', () => {
test('should create editor contribution with metadata', () => {
const EditorComponent = () => React.createElement('textarea');
const editor = defineEditor({
id: 'test-editor',
name: 'Test Editor',
mimeTypes: ['text/x-sql'],
component: EditorComponent,
});
expect(editor.config.id).toBe('test-editor');
expect(editor.config.name).toBe('Test Editor');
expect(editor.config.mimeTypes).toEqual(['text/x-sql']);
expect(editor.__contributionMeta__).toBeDefined();
expect(editor.__contributionMeta__.type).toBe('editor');
});
test('should auto-register when context is available', () => {
const EditorComponent = () => React.createElement('textarea');
setExtensionContext(mockContext);
const editor = defineEditor({
id: 'auto-editor',
name: 'Auto Editor',
mimeTypes: ['text/plain'],
component: EditorComponent,
});
expect(mockContext.registerEditor).toHaveBeenCalledWith(editor.config);
});
});
describe('defineMenu', () => {
test('should create menu contribution with metadata', () => {
const menu = defineMenu({
id: 'test-menu',
title: 'Test Menu',
location: 'navbar.items',
action: () => console.log('clicked'),
});
expect(menu.config.id).toBe('test-menu');
expect(menu.config.title).toBe('Test Menu');
expect(menu.config.location).toBe('navbar.items');
expect(menu.__contributionMeta__).toBeDefined();
expect(menu.__contributionMeta__.type).toBe('menu');
});
test('should auto-register when context is available', () => {
setExtensionContext(mockContext);
const menu = defineMenu({
id: 'auto-menu',
title: 'Auto Menu',
location: 'context.menus',
action: () => {},
});
expect(mockContext.registerMenu).toHaveBeenCalledWith(menu.config);
});
});
describe('Auto-registration system', () => {
test('should queue contributions when no context is set', () => {
const command = defineCommand({
id: 'queued-command',
title: 'Queued Command',
execute: async () => {},
});
// Should not be registered yet
expect(mockContext.registerCommand).not.toHaveBeenCalled();
// Set context - should register queued contributions
setExtensionContext(mockContext);
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
});
test('should handle disposal correctly', () => {
const mockDispose = jest.fn();
mockContext.registerCommand.mockReturnValue(mockDispose);
setExtensionContext(mockContext);
const command = defineCommand({
id: 'dispose-command',
title: 'Dispose Command',
execute: async () => {},
});
// Dispose should call the returned cleanup function
command.dispose();
expect(mockDispose).toHaveBeenCalled();
});
test('should handle mixed contribution types', () => {
setExtensionContext(mockContext);
const command = defineCommand({
id: 'mixed-command',
title: 'Mixed Command',
execute: async () => {},
});
const view = defineView({
id: 'mixed-view',
title: 'Mixed View',
location: 'explore.panels',
component: () => React.createElement('div', null, 'Mixed'),
});
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
'mixed-view',
view.config.component
);
});
});
});

View File

@@ -0,0 +1,45 @@
{
"name": "@apache-superset/webpack-extension-plugin",
"version": "0.0.1",
"description": "Webpack plugin for processing and validating Superset extension contributions",
"keywords": [
"superset",
"webpack",
"plugin",
"extensions"
],
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/packages/webpack-extension-plugin#readme",
"bugs": {
"url": "https://github.com/apache/superset/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/packages/webpack-contribution-plugin"
},
"license": "Apache-2.0",
"author": "Superset",
"sideEffects": false,
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib"
],
"scripts": {
"clean": "rm -rf lib tsconfig.tsbuildinfo",
"build": "npm run clean && npx tsc --build",
"type": "npx tsc --noEmit"
},
"dependencies": {
"typescript": "^5.0.0"
},
"devDependencies": {
"@types/node": "^25.1.0"
},
"peerDependencies": {
"webpack": "^5.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,532 @@
/**
* 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 * as ts from 'typescript';
import * as path from 'path';
import * as fs from 'fs';
import { Compiler, WebpackPluginInstance, sources, Compilation } from 'webpack';
// Contribution type definitions matching backend schema
interface CommandContribution {
id: string;
title: string;
icon?: string;
execute: string; // Module path to the execute function
}
interface ViewContribution {
id: string;
title: string;
component: string; // Module path to the component
location: string; // e.g., "dashboard.tabs", "explore.panels"
}
interface EditorContribution {
id: string;
name: string;
component: string; // Module path to the component
mimeTypes: string[];
}
interface MenuContribution {
id: string;
title: string;
location: string; // e.g., "navbar.items", "context.menus"
action: string; // Module path to the action
}
interface FrontendContributions {
commands: CommandContribution[];
views: Record<string, ViewContribution[]>; // Grouped by location
editors: EditorContribution[];
menus: Record<string, MenuContribution[]>; // Grouped by location
}
interface SupersetExtensionPluginOptions {
outputPath?: string; // Where to write contributions.json
include?: string[]; // File patterns to include
exclude?: string[]; // File patterns to exclude
}
/**
* Webpack plugin for auto-discovering Superset extension contributions.
*
* Analyzes TypeScript/JavaScript files during compilation to find calls to
* define* functions from @apache-superset/core and outputs a contributions.json
* file with the discovered contributions.
*/
export default class SupersetContributionPlugin implements WebpackPluginInstance {
private options: SupersetExtensionPluginOptions;
constructor(options: SupersetExtensionPluginOptions = {}) {
this.options = {
outputPath: 'contributions.json',
include: ['src/**/*.{ts,tsx,js,jsx}'],
exclude: ['**/*.test.*', '**/node_modules/**'],
...options,
};
}
apply(compiler: Compiler): void {
compiler.hooks.compilation.tap(
'SupersetContributionPlugin',
compilation => {
compilation.hooks.processAssets.tap(
{
name: 'SupersetContributionPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
try {
const contributions = this.discoverContributions(
compiler.context,
);
const contributionsJson = JSON.stringify(contributions, null, 2);
// Add the contributions.json file to webpack's output
const outputPath = this.options.outputPath!;
compilation.emitAsset(
outputPath,
new sources.RawSource(contributionsJson),
);
console.log(
`🔍 Discovered ${this.countContributions(contributions)} frontend contributions`,
);
} catch (error) {
compilation.errors.push(
new Error(`SupersetContributionPlugin: ${error}`),
);
}
},
);
},
);
}
private discoverContributions(rootPath: string): FrontendContributions {
const contributions: FrontendContributions = {
commands: [],
views: {},
editors: [],
menus: {},
};
// Find all TypeScript/JavaScript files
const files = this.findSourceFiles(rootPath);
for (const filePath of files) {
try {
const fileContributions = this.analyzeFile(filePath);
this.mergeContributions(contributions, fileContributions);
} catch (error) {
console.warn(`⚠️ Failed to analyze ${filePath}:`, error);
}
}
return contributions;
}
private findSourceFiles(rootPath: string): string[] {
const files: string[] = [];
const { include, exclude } = this.options;
const walkDir = (dir: string) => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path
.relative(rootPath, fullPath)
.replace(/\\/g, '/'); // Normalize path separators
if (entry.isDirectory()) {
// Skip excluded directories and node_modules
if (entry.name === 'node_modules') {
continue;
}
const isExcluded = this.matchesPatterns(relativePath, exclude!);
if (!isExcluded) {
walkDir(fullPath);
}
} else if (entry.isFile()) {
// Only check files that might be relevant (have extensions we care about)
const hasRelevantExtension = /\.(ts|tsx|js|jsx)$/.test(
relativePath,
);
if (hasRelevantExtension) {
const isIncluded = this.matchesPatterns(relativePath, include!);
const isExcluded = this.matchesPatterns(relativePath, exclude!);
if (isIncluded && !isExcluded) {
files.push(fullPath);
}
}
}
}
} catch (error) {
// Skip directories that can't be read
}
};
walkDir(rootPath);
return files;
}
private matchesPatterns(filePath: string, patterns: string[]): boolean {
return patterns.some(pattern => {
// Handle brace expansion like {ts,tsx,js,jsx}
const expandedPatterns = this.expandBraces(pattern);
return expandedPatterns.some(expandedPattern => {
// Convert glob pattern to regex
// Special handling for **/pattern which should match pattern directly
let regex = expandedPattern
// Replace glob patterns first
.replace(/\*\*\/\*/g, '§DOUBLESTAR_SLASH_STAR§') // **/* becomes special pattern
.replace(/\*\*/g, '§DOUBLESTAR§') // ** becomes another pattern
.replace(/\*/g, '§STAR§') // * becomes yet another
.replace(/\?/g, '§QUESTION§') // ? becomes final pattern
// Then escape all regex special chars
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
// Then restore glob patterns as regex
.replace(/§DOUBLESTAR_SLASH_STAR§/g, '(?:.*/)?[^/]*') // **/* matches zero+ dirs + filename
.replace(/§DOUBLESTAR§/g, '(?:.*)?') // ** matches zero+ chars
.replace(/§STAR§/g, '[^/]*') // * matches anything except /
.replace(/§QUESTION§/g, '.'); // ? matches any single char
const regexPattern = new RegExp(`^${regex}$`);
const matches = regexPattern.test(filePath);
return matches;
});
});
}
private expandBraces(pattern: string): string[] {
const braceMatch = pattern.match(/^(.+)\{([^}]+)\}(.*)$/);
if (!braceMatch) {
return [pattern];
}
const [, prefix, options, suffix] = braceMatch;
return options.split(',').map(option => prefix + option.trim() + suffix);
}
private analyzeFile(filePath: string): FrontendContributions {
const sourceCode = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(
filePath,
sourceCode,
ts.ScriptTarget.Latest,
true,
);
const contributions: FrontendContributions = {
commands: [],
views: {},
editors: [],
menus: {},
};
// Create a TypeScript program for type checking
const program = ts.createProgram([filePath], {
target: ts.ScriptTarget.Latest,
module: ts.ModuleKind.ESNext,
allowJs: true,
jsx: ts.JsxEmit.React,
});
const checker = program.getTypeChecker();
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
this.analyzeCallExpression(node, checker, contributions, filePath);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return contributions;
}
private analyzeCallExpression(
call: ts.CallExpression,
checker: ts.TypeChecker,
contributions: FrontendContributions,
filePath: string,
): void {
if (!ts.isIdentifier(call.expression)) {
return;
}
const functionName = call.expression.text;
// Check if this is a define* function call
if (
!['defineCommand', 'defineView', 'defineEditor', 'defineMenu'].includes(
functionName,
)
) {
return;
}
// For externals (like @apache-superset/core), validate by checking import statements
const isValidPackage = this.isValidImportInFile(filePath, functionName);
if (!isValidPackage) {
console.warn(
`⚠️ ${functionName} not imported from @apache-superset/core in ${filePath}`,
);
return;
}
// Extract the configuration object
if (call.arguments.length === 0) {
return;
}
const configArg = call.arguments[0];
const config = this.extractObjectLiteral(configArg);
if (!config) {
console.warn(
`⚠️ Could not extract config from ${functionName} call in ${filePath}`,
);
return;
}
// Process the contribution based on its type
switch (functionName) {
case 'defineCommand':
this.processCommandContribution(config, contributions, filePath);
break;
case 'defineView':
this.processViewContribution(config, contributions, filePath);
break;
case 'defineEditor':
this.processEditorContribution(config, contributions, filePath);
break;
case 'defineMenu':
this.processMenuContribution(config, contributions, filePath);
break;
}
}
private isValidImportInFile(filePath: string, functionName: string): boolean {
try {
const sourceCode = fs.readFileSync(filePath, 'utf-8');
// Check for import statements that import our function from @apache-superset/core
const importRegex = new RegExp(
`import\\s+{[^}]*\\b${functionName}\\b[^}]*}\\s+from\\s+['"]@apache-superset/core['"]`,
);
const hasValidImport = importRegex.test(sourceCode);
if (hasValidImport) {
return true;
}
// Also check for default imports or namespace imports
const defaultImportRegex =
/import\s+\w+\s+from\s+['"]@apache-superset\/core['"]/;
const namespaceImportRegex =
/import\s+\*\s+as\s+\w+\s+from\s+['"]@apache-superset\/core['"]/;
return (
defaultImportRegex.test(sourceCode) ||
namespaceImportRegex.test(sourceCode)
);
} catch (error) {
return false;
}
}
private extractObjectLiteral(node: ts.Node): Record<string, any> | null {
if (!ts.isObjectLiteralExpression(node)) {
return null;
}
const result: Record<string, any> = {};
for (const property of node.properties) {
if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) {
const key = property.name.text;
const value = this.extractValue(property.initializer);
result[key] = value;
}
}
return result;
}
private extractValue(node: ts.Node): any {
if (ts.isStringLiteral(node)) {
return node.text;
} else if (ts.isNumericLiteral(node)) {
return parseFloat(node.text);
} else if (node.kind === ts.SyntaxKind.TrueKeyword) {
return true;
} else if (node.kind === ts.SyntaxKind.FalseKeyword) {
return false;
} else if (ts.isArrayLiteralExpression(node)) {
return node.elements.map(element => this.extractValue(element));
} else if (ts.isObjectLiteralExpression(node)) {
return this.extractObjectLiteral(node);
}
// For complex expressions, return a string representation
return node.getText();
}
private processCommandContribution(
config: Record<string, any>,
contributions: FrontendContributions,
filePath: string,
): void {
if (!config.id || !config.title) {
console.warn(
`⚠️ Command contribution missing required fields in ${filePath}`,
);
return;
}
contributions.commands.push({
id: config.id,
title: config.title,
icon: config.icon,
execute: this.getModulePath(filePath),
});
}
private processViewContribution(
config: Record<string, any>,
contributions: FrontendContributions,
filePath: string,
): void {
if (!config.id || !config.title || !config.location) {
console.warn(
`⚠️ View contribution missing required fields in ${filePath}`,
);
return;
}
const { location } = config;
if (!contributions.views[location]) {
contributions.views[location] = [];
}
contributions.views[location].push({
id: config.id,
title: config.title,
component: this.getModulePath(filePath),
location,
});
}
private processEditorContribution(
config: Record<string, any>,
contributions: FrontendContributions,
filePath: string,
): void {
if (!config.id || !config.name || !config.mimeTypes) {
console.warn(
`⚠️ Editor contribution missing required fields in ${filePath}`,
);
return;
}
contributions.editors.push({
id: config.id,
name: config.name,
component: this.getModulePath(filePath),
mimeTypes: Array.isArray(config.mimeTypes)
? config.mimeTypes
: [config.mimeTypes],
});
}
private processMenuContribution(
config: Record<string, any>,
contributions: FrontendContributions,
filePath: string,
): void {
if (!config.id || !config.title || !config.location) {
console.warn(
`⚠️ Menu contribution missing required fields in ${filePath}`,
);
return;
}
const { location } = config;
if (!contributions.menus[location]) {
contributions.menus[location] = [];
}
contributions.menus[location].push({
id: config.id,
title: config.title,
location,
action: this.getModulePath(filePath),
});
}
private getModulePath(filePath: string): string {
// Convert absolute file path to a module path relative to src/
const srcIndex = filePath.lastIndexOf('/src/');
if (srcIndex !== -1) {
return filePath.substring(srcIndex + 5).replace(/\.(ts|tsx|js|jsx)$/, '');
}
return filePath;
}
private mergeContributions(
target: FrontendContributions,
source: FrontendContributions,
): void {
target.commands.push(...source.commands);
target.editors.push(...source.editors);
// Merge views by location
for (const [location, views] of Object.entries(source.views)) {
if (!target.views[location]) {
target.views[location] = [];
}
target.views[location].push(...views);
}
// Merge menus by location
for (const [location, menus] of Object.entries(source.menus)) {
if (!target.menus[location]) {
target.menus[location] = [];
}
target.menus[location].push(...menus);
}
}
private countContributions(contributions: FrontendContributions): number {
return (
contributions.commands.length +
contributions.editors.length +
Object.values(contributions.views).flat().length +
Object.values(contributions.menus).flat().length
);
}
}

View File

@@ -0,0 +1,148 @@
/**
* 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 SupersetContributionPlugin from '../src/index';
// Mock webpack compiler and compilation
const createMockCompiler = (outputPath: string = '/test/output') => {
return {
options: {
output: {
path: outputPath,
},
},
hooks: {
compilation: {
tap: jest.fn(),
},
emit: {
tap: jest.fn(),
},
},
} as any;
};
const createMockCompilation = (assets: Record<string, any> = {}) => {
return {
assets,
hooks: {
processAssets: {
tap: jest.fn(),
},
},
emitAsset: jest.fn(),
} as any;
};
describe('SupersetContributionPlugin', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
test('should create plugin with default options', () => {
const plugin = new SupersetContributionPlugin();
// Test that plugin was created (can't access private options easily)
expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
});
test('should create plugin with custom options', () => {
const plugin = new SupersetContributionPlugin({
outputPath: 'custom.json',
include: ['app/**/*.ts'],
exclude: ['**/*.spec.*'],
});
expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
});
});
describe('apply', () => {
test('should register emit hook', () => {
const plugin = new SupersetContributionPlugin();
const compiler = createMockCompiler();
plugin.apply(compiler);
expect(compiler.hooks.emit.tapAsync).toHaveBeenCalledWith(
'SupersetContributionPlugin',
expect.any(Function),
);
});
});
describe('integration test', () => {
test('should process and emit contributions.json', done => {
const plugin = new SupersetContributionPlugin();
const compiler = createMockCompiler('/test/root');
const compilation = createMockCompilation();
// Mock the emit hook to be called synchronously for testing
compiler.hooks.emit.tapAsync.mockImplementation(
(name: string, callback: any) => {
// Call the callback with mocked compilation
try {
callback(compilation, () => {
// Verify contributions.json was emitted
expect(compilation.assets['contributions.json']).toBeDefined();
const asset = compilation.assets['contributions.json'];
const content = asset.source();
const contributions = JSON.parse(content);
// Should have the expected structure
expect(contributions).toHaveProperty('commands');
expect(contributions).toHaveProperty('views');
expect(contributions).toHaveProperty('editors');
expect(contributions).toHaveProperty('menus');
expect(Array.isArray(contributions.commands)).toBe(true);
expect(typeof contributions.views).toBe('object');
expect(Array.isArray(contributions.editors)).toBe(true);
expect(typeof contributions.menus).toBe('object');
done();
});
} catch (error) {
done(error);
}
},
);
plugin.apply(compiler);
});
});
describe('file pattern matching', () => {
test('should match TypeScript files', () => {
const plugin = new SupersetContributionPlugin();
// Test that the plugin handles common file patterns
expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
});
test('should exclude test files', () => {
const plugin = new SupersetContributionPlugin();
// Test that the plugin excludes test patterns
expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
});
});
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "dom"],
"module": "CommonJS",
"declaration": true,
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "**/*.test.ts"]
}

View File

@@ -31,7 +31,7 @@ import { SqlLabRootState } from 'src/SqlLab/types';
import { ViewLocations } from 'src/SqlLab/contributions';
import PanelToolbar from 'src/components/PanelToolbar';
import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
import ExtensionLoader from 'src/extensions/ExtensionLoader';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import useLogAction from 'src/logger/useLogAction';
import { LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB } from 'src/logger/LogUtils';
@@ -105,9 +105,7 @@ const SouthPane = ({
const theme = useTheme();
const dispatch = useDispatch();
const contributions =
ExtensionsManager.getInstance().getViewContributions(
ViewLocations.sqllab.panels,
) || [];
ExtensionLoader.getInstance().getViewContributions('sqllab.panels') || [];
const { getView } = useExtensionsContext();
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({

View File

@@ -0,0 +1,470 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core';
import { setExtensionContext } from '@apache-superset/core';
import type { ExtensionContext } from '@apache-superset/core';
// Manifest schema from auto-discovery
interface ManifestFrontend {
remoteEntry: string;
contributions: FrontendContributions;
}
interface FrontendContributions {
commands: CommandContribution[];
views: Record<string, ViewContribution[]>;
editors: EditorContribution[];
menus: Record<string, MenuContribution[]>;
}
interface CommandContribution {
id: string;
title: string;
icon?: string;
execute: string; // Module path
}
interface ViewContribution {
id: string;
title: string;
component: string; // Module path
location: string;
}
interface EditorContribution {
id: string;
name: string;
component: string; // Module path
mimeTypes: string[];
}
interface MenuContribution {
id: string;
title: string;
location: string;
action: string; // Module path
}
interface ExtensionManifest {
id: string;
name: string;
version: string;
frontend?: ManifestFrontend;
}
interface LoadedExtension {
id: string;
name: string;
manifest: ExtensionManifest;
module?: any; // The loaded webpack module
disposables: Array<() => void>;
}
/**
* ExtensionLoader - Loads and validates extensions using the auto-discovery system
*/
class ExtensionLoader {
private static instance: ExtensionLoader;
private loadedExtensions: Map<string, LoadedExtension> = new Map();
private contributionRegistry: Map<string, any> = new Map(); // Track registered contributions
private constructor() {}
public static getInstance(): ExtensionLoader {
if (!ExtensionLoader.instance) {
ExtensionLoader.instance = new ExtensionLoader();
}
return ExtensionLoader.instance;
}
/**
* Initialize and load all available extensions
*/
public async initializeExtensions(): Promise<void> {
try {
const response = await SupersetClient.get({
endpoint: '/api/v1/extensions/',
});
const extensions: ExtensionManifest[] = response.json.result;
await Promise.all(
extensions.map(async manifest => {
try {
await this.loadExtension(manifest);
} catch (error) {
logging.error(`Failed to load extension ${manifest.id}:`, error);
}
}),
);
logging.info(
`Loaded ${this.loadedExtensions.size} extensions successfully`,
);
} catch (error) {
logging.error('Failed to initialize extensions:', error);
}
}
/**
* Load a single extension using webpack module federation
*/
public async loadExtension(manifest: ExtensionManifest): Promise<void> {
const { id, name, frontend } = manifest;
if (!frontend?.remoteEntry) {
logging.warn(`Extension ${id} has no frontend component, skipping`);
return;
}
try {
logging.info(`Loading extension: ${name} (${id})`);
// Construct full URL for remote entry
const remoteEntryUrl = frontend.remoteEntry.startsWith('http')
? frontend.remoteEntry
: `/static/extensions/${id}/${frontend.remoteEntry}`;
// Load the remote entry script
await this.loadRemoteEntry(remoteEntryUrl, id);
// Get the webpack module federation container
const container = (window as any)[id];
if (!container) {
throw new Error(
`Extension container ${id} not found after loading remote entry`,
);
}
// Initialize webpack sharing
// @ts-ignore
await __webpack_init_sharing__('default');
// @ts-ignore
await container.init(__webpack_share_scopes__.default);
// Load the main module (typically exposed as './index')
const factory = await container.get('./index');
const extensionModule = factory();
// Create extension context for auto-registration
const context = this.createExtensionContext(id);
// Set context in the extension's environment so define* functions auto-register
setExtensionContext(context);
// Create loaded extension record
const loadedExtension: LoadedExtension = {
id,
name,
manifest,
module: extensionModule,
disposables: [],
};
// Validate contributions against manifest (security check)
await this.validateContributions(loadedExtension, frontend.contributions);
this.loadedExtensions.set(id, loadedExtension);
logging.info(`✅ Extension ${name} loaded and validated successfully`);
} catch (error) {
logging.error(`Failed to load extension ${name}:`, error);
throw error;
}
}
/**
* Load remote entry script for webpack module federation
*/
private async loadRemoteEntry(
remoteEntry: string,
extensionId: string,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
// Check if already loaded
if (document.querySelector(`script[src="${remoteEntry}"]`)) {
resolve();
return;
}
const script = document.createElement('script');
script.src = remoteEntry;
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
logging.debug(`Remote entry loaded: ${remoteEntry}`);
resolve();
};
script.onerror = error => {
const message = `Failed to load remote entry: ${remoteEntry}`;
logging.error(message, error);
reject(new Error(message));
};
document.head.appendChild(script);
});
}
/**
* Create extension context with registration callbacks
*/
private createExtensionContext(extensionId: string): ExtensionContext {
return {
registerCommand: config => {
const key = `${extensionId}.${config.id}`;
this.contributionRegistry.set(`command:${key}`, config);
logging.debug(`Registered command: ${key}`);
return () => {
this.contributionRegistry.delete(`command:${key}`);
logging.debug(`Unregistered command: ${key}`);
};
},
registerViewProvider: (id, component) => {
const key = `${extensionId}.${id}`;
this.contributionRegistry.set(`view:${key}`, { id, component });
logging.debug(`Registered view provider: ${key}`);
return () => {
this.contributionRegistry.delete(`view:${key}`);
logging.debug(`Unregistered view provider: ${key}`);
};
},
registerEditor: config => {
const key = `${extensionId}.${config.id}`;
this.contributionRegistry.set(`editor:${key}`, config);
logging.debug(`Registered editor: ${key}`);
return () => {
this.contributionRegistry.delete(`editor:${key}`);
logging.debug(`Unregistered editor: ${key}`);
};
},
registerMenu: config => {
const key = `${extensionId}.${config.id}`;
this.contributionRegistry.set(`menu:${key}`, config);
logging.debug(`Registered menu: ${key}`);
return () => {
this.contributionRegistry.delete(`menu:${key}`);
logging.debug(`Unregistered menu: ${key}`);
};
},
};
}
/**
* Validate that runtime contributions match the manifest allowlist
*/
private async validateContributions(
extension: LoadedExtension,
manifestContributions: FrontendContributions,
): Promise<void> {
const { id } = extension;
// Small delay to allow define* functions to execute and register
await new Promise(resolve => setTimeout(resolve, 100));
// Validate commands
for (const commandDef of manifestContributions.commands) {
const key = `command:${id}.${commandDef.id}`;
if (!this.contributionRegistry.has(key)) {
throw new Error(
`Command ${commandDef.id} declared in manifest but not found in runtime exports`,
);
}
}
// Validate views
for (const [, views] of Object.entries(manifestContributions.views)) {
for (const viewDef of views) {
const key = `view:${id}.${viewDef.id}`;
if (!this.contributionRegistry.has(key)) {
throw new Error(
`View ${viewDef.id} declared in manifest but not found in runtime exports`,
);
}
}
}
// Validate editors
for (const editorDef of manifestContributions.editors) {
const key = `editor:${id}.${editorDef.id}`;
if (!this.contributionRegistry.has(key)) {
throw new Error(
`Editor ${editorDef.id} declared in manifest but not found in runtime exports`,
);
}
}
// Validate menus
for (const [, menus] of Object.entries(manifestContributions.menus)) {
for (const menuDef of menus) {
const key = `menu:${id}.${menuDef.id}`;
if (!this.contributionRegistry.has(key)) {
throw new Error(
`Menu ${menuDef.id} declared in manifest but not found in runtime exports`,
);
}
}
}
logging.debug(`✅ All contributions validated for extension ${id}`);
}
/**
* Get view contributions for a specific location
*/
public getViewContributions(
location: string,
): Array<{ id: string; name: string; component: React.ComponentType }> {
const views: Array<{
id: string;
name: string;
component: React.ComponentType;
}> = [];
for (const [key, contribution] of this.contributionRegistry.entries()) {
if (key.startsWith('view:')) {
const extension = Array.from(this.loadedExtensions.values()).find(ext =>
key.startsWith(`view:${ext.id}.`),
);
if (extension) {
const viewContributions =
extension.manifest.frontend?.contributions.views[location] || [];
const contributionId = key.replace(/^view:[^.]+\./, '');
const viewDef = viewContributions.find(v => v.id === contributionId);
if (viewDef) {
views.push({
id: contribution.id,
name: viewDef.title,
component: contribution.component,
});
}
}
}
}
return views;
}
/**
* Get command contributions
*/
public getCommandContributions(): Array<any> {
const commands: Array<any> = [];
for (const [key, contribution] of this.contributionRegistry.entries()) {
if (key.startsWith('command:')) {
commands.push(contribution);
}
}
return commands;
}
/**
* Get editor contributions
*/
public getEditorContributions(): Array<any> {
const editors: Array<any> = [];
for (const [key, contribution] of this.contributionRegistry.entries()) {
if (key.startsWith('editor:')) {
editors.push(contribution);
}
}
return editors;
}
/**
* Get menu contributions for a specific location
*/
public getMenuContributions(location: string): Array<any> {
const menus: Array<any> = [];
for (const [key, contribution] of this.contributionRegistry.entries()) {
if (key.startsWith('menu:')) {
const extension = Array.from(this.loadedExtensions.values()).find(ext =>
key.startsWith(`menu:${ext.id}.`),
);
if (extension) {
const menuContributions =
extension.manifest.frontend?.contributions.menus[location] || [];
const contributionId = key.replace(/^menu:[^.]+\./, '');
if (menuContributions.some(m => m.id === contributionId)) {
menus.push(contribution);
}
}
}
}
return menus;
}
/**
* Get loaded extensions
*/
public getLoadedExtensions(): LoadedExtension[] {
return Array.from(this.loadedExtensions.values());
}
/**
* Unload an extension
*/
public unloadExtension(id: string): boolean {
const extension = this.loadedExtensions.get(id);
if (!extension) {
return false;
}
try {
// Dispose all registered contributions
extension.disposables.forEach(dispose => dispose());
// Clear from registry
for (const key of this.contributionRegistry.keys()) {
if (key.includes(`${id}.`)) {
this.contributionRegistry.delete(key);
}
}
this.loadedExtensions.delete(id);
logging.info(`Extension ${extension.name} unloaded successfully`);
return true;
} catch (error) {
logging.error(`Failed to unload extension ${extension.name}:`, error);
return false;
}
}
}
export default ExtensionLoader;

View File

@@ -31,7 +31,7 @@ import {
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import { useExtensionsContext } from './ExtensionsContext';
import ExtensionsManager from './ExtensionsManager';
import ExtensionLoader from './ExtensionLoader';
declare global {
interface Window {
@@ -77,7 +77,7 @@ const ExtensionsStartup = () => {
// Initialize extensions
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
try {
ExtensionsManager.getInstance().initializeExtensions();
ExtensionLoader.getInstance().initializeExtensions();
supersetCore.logging.info('Extensions initialized successfully.');
} catch (error) {
supersetCore.logging.error('Error setting up extensions:', error);

View File

@@ -137,24 +137,112 @@ def inject_task_implementations() -> None:
def inject_rest_api_implementations() -> None:
"""
Replace abstract REST API functions in superset_core.api.rest_api with concrete
Replace abstract REST API decorators in superset_core.api.rest_api with concrete
implementations from Superset.
The decorators:
1. Store metadata on classes for build-time discovery
2. In host mode: Register immediately with Flask-AppBuilder
3. In extension mode: Defer registration (ExtensionManager validates manifest)
4. In build mode: Store metadata only
"""
import logging
from typing import Callable, TypeVar
import superset_core.api.rest_api as core_rest_api_module
from superset_core.api.rest_api import RestApiMetadata
from superset_core.extensions.context import get_context
from superset.extensions import appbuilder
def add_api(api: "type[RestApi]") -> None:
view = appbuilder.add_api(api)
appbuilder._add_permission(view, True)
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=type)
def add_extension_api(api: "type[RestApi]") -> None:
api.route_base = "/extensions/" + (api.resource_name or "")
view = appbuilder.add_api(api)
def _register_api_with_appbuilder(
api_cls: type["RestApi"],
route_base: str | None = None,
) -> None:
"""Register an API class with Flask-AppBuilder."""
if route_base:
api_cls.route_base = route_base
view = appbuilder.add_api(api_cls)
appbuilder._add_permission(view, True)
logger.info("Registered REST API: %s", api_cls.__name__)
core_rest_api_module.add_api = add_api
core_rest_api_module.add_extension_api = add_extension_api
def create_api_decorator(cls: T) -> T:
"""
Decorator to register a REST API with the host application.
In host mode: Registers immediately with Flask-AppBuilder.
In extension mode: Defers registration (should not be used for extensions).
In build mode: No-op (host APIs are not discovered).
"""
ctx = get_context()
# Build mode: no-op for host APIs
if ctx.is_build_mode:
return cls
# Host mode: register immediately
if ctx.is_host_mode:
_register_api_with_appbuilder(cls)
return cls
# Extension mode: host @api decorator should not be used
logger.warning(
"Host @api decorator used in extension context. "
"Use @extension_api instead for extensions."
)
return cls
def create_extension_api_decorator(
id: str, # noqa: A002
name: str,
description: str | None = None,
base_path: str | None = None,
) -> Callable[[T], T]:
"""
Decorator to mark a class as an extension REST API.
This decorator:
1. Stores RestApiMetadata on the class for build-time discovery
2. In host mode: Registers immediately under /extensions/{id}/
3. In extension mode: Defers registration (ExtensionManager validates manifest)
4. In build mode: Stores metadata only
"""
def decorator(cls: T) -> T:
# Build metadata
metadata = RestApiMetadata(
id=id,
name=name,
description=description or cls.__doc__,
base_path=base_path or f"/{id}",
module=f"{cls.__module__}.{cls.__name__}",
)
cls.__rest_api_metadata__ = metadata # type: ignore[attr-defined]
ctx = get_context()
# Build mode: metadata only, no registration
if ctx.is_build_mode:
return cls
# Extension mode: defer registration to ExtensionManager
if ctx.is_extension_mode:
ctx.add_pending_contribution(cls, metadata, "restApi")
return cls
# Host mode: register immediately (for testing/development)
route_base = f"/extensions{metadata.base_path}"
_register_api_with_appbuilder(cls, route_base)
return cls
return decorator
# Replace the abstract decorators with concrete implementations
core_rest_api_module.api = create_api_decorator
core_rest_api_module.extension_api = create_extension_api_decorator
def inject_model_session_implementation() -> None:

View File

@@ -20,6 +20,12 @@ MCP dependency injection implementation.
This module provides the concrete implementation of MCP abstractions
that replaces the abstract functions in superset-core during initialization.
The decorators:
1. Store metadata on functions for build-time discovery
2. In host mode: Register immediately with FastMCP
3. In extension mode: Defer registration (manifest validation by ExtensionManager)
4. In build mode: Metadata only (CLI discovery)
"""
import logging
@@ -31,6 +37,98 @@ F = TypeVar("F", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
def _register_tool_with_mcp(
func: Callable[..., Any],
tool_name: str,
tool_description: str | None,
tool_tags: list[str],
protect: bool,
) -> Callable[..., Any]:
"""
Register a tool with FastMCP.
Args:
func: The function to register
tool_name: Name for the tool
tool_description: Description for the tool
tool_tags: Tags for categorization
protect: Whether to wrap with authentication
Returns:
The wrapped function (with auth if protect=True)
"""
from superset.mcp_service.app import mcp
# Conditionally apply authentication wrapper
if protect:
from superset.mcp_service.auth import mcp_auth_hook
wrapped_func = mcp_auth_hook(func)
else:
wrapped_func = func
from fastmcp.tools import Tool
tool = Tool.from_function(
wrapped_func,
name=tool_name,
description=tool_description or f"Tool: {tool_name}",
tags=tool_tags,
)
mcp.add_tool(tool)
protected_status = "protected" if protect else "public"
logger.info("Registered MCP tool: %s (%s)", tool_name, protected_status)
return wrapped_func
def _register_prompt_with_mcp(
func: Callable[..., Any],
prompt_name: str,
prompt_title: str,
prompt_description: str | None,
prompt_tags: set[str],
protect: bool,
) -> Callable[..., Any]:
"""
Register a prompt with FastMCP.
Args:
func: The function to register
prompt_name: Name for the prompt
prompt_title: Title for the prompt
prompt_description: Description for the prompt
prompt_tags: Tags for categorization
protect: Whether to wrap with authentication
Returns:
The wrapped function (with auth if protect=True)
"""
from superset.mcp_service.app import mcp
# Conditionally apply authentication wrapper
if protect:
from superset.mcp_service.auth import mcp_auth_hook
wrapped_func = mcp_auth_hook(func)
else:
wrapped_func = func
# Register prompt with FastMCP
mcp.prompt(
name=prompt_name,
title=prompt_title,
description=prompt_description or f"Prompt: {prompt_name}",
tags=prompt_tags,
)(wrapped_func)
protected_status = "protected" if protect else "public"
logger.info("Registered MCP prompt: %s (%s)", prompt_name, protected_status)
return wrapped_func
def create_tool_decorator(
func_or_name: str | Callable[..., Any] | None = None,
*,
@@ -42,8 +140,11 @@ def create_tool_decorator(
"""
Create the concrete MCP tool decorator implementation.
This combines FastMCP tool registration with optional Superset authentication,
replacing the need for separate @mcp.tool and @mcp_auth_hook decorators.
This decorator:
1. Stores ToolMetadata on the function for build-time discovery
2. In host mode: Registers immediately with FastMCP
3. In extension mode: Defers registration (ExtensionManager validates manifest)
4. In build mode: Stores metadata only
Supports both @tool and @tool() syntax.
@@ -56,58 +157,60 @@ def create_tool_decorator(
protect: Whether to apply Superset authentication (defaults to True)
Returns:
Decorator that registers and wraps the tool with optional authentication,
or the wrapped function when used without parentheses
Decorated function with __tool_metadata__ attribute
"""
def decorator(func: F) -> F:
try:
# Import here to avoid circular imports
from superset.mcp_service.app import mcp
from superset_core.extensions.context import get_context
from superset_core.mcp import ToolMetadata
# Use provided values or extract from function
tool_name = name or func.__name__
tool_description = description or func.__doc__ or f"Tool: {tool_name}"
tool_tags = tags or []
# Use provided values or extract from function
tool_name = name or func.__name__
tool_description = description
if tool_description is None and func.__doc__:
tool_description = func.__doc__.strip().split("\n")[0]
tool_tags = tags or []
# Conditionally apply authentication wrapper
if protect:
from superset.mcp_service.auth import mcp_auth_hook
# Store metadata on function for discovery
metadata = ToolMetadata(
id=func.__name__,
name=tool_name,
description=tool_description,
tags=tool_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__tool_metadata__ = metadata # type: ignore[attr-defined]
wrapped_func = mcp_auth_hook(func)
else:
wrapped_func = func
ctx = get_context()
from fastmcp.tools import Tool
tool = Tool.from_function(
wrapped_func,
name=tool_name,
description=tool_description,
tags=tool_tags,
)
mcp.add_tool(tool)
protected_status = "protected" if protect else "public"
logger.info("Registered MCP tool: %s (%s)", tool_name, protected_status)
return wrapped_func
except Exception as e:
logger.error("Failed to register MCP tool %s: %s", name or func.__name__, e)
# Return the original function so extension doesn't break
# Build mode: metadata only, no registration
if ctx.is_build_mode:
return func
# If called as @tool (without parentheses)
# Extension mode: defer registration to ExtensionManager
if ctx.is_extension_mode:
ctx.add_pending_contribution(func, metadata, "tool")
return func
# Host mode: register immediately
try:
wrapped = _register_tool_with_mcp(
func, tool_name, tool_description, tool_tags, protect
)
wrapped.__tool_metadata__ = metadata # type: ignore[attr-defined]
return wrapped # type: ignore[return-value]
except Exception as e:
logger.error("Failed to register MCP tool %s: %s", tool_name, e)
return func
# Handle decorator usage patterns
if callable(func_or_name):
# Type cast is safe here since we've confirmed it's callable
return decorator(func_or_name) # type: ignore[arg-type]
# If called as @tool() or @tool(name="...")
# func_or_name would be the name parameter or None
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
# Use the actual_name if provided via func_or_name
nonlocal name
if actual_name is not None:
name = actual_name
@@ -128,8 +231,11 @@ def create_prompt_decorator(
"""
Create the concrete MCP prompt decorator implementation.
This combines FastMCP prompt registration with optional Superset authentication,
replacing the need for separate @mcp.prompt and @mcp_auth_hook decorators.
This decorator:
1. Stores PromptMetadata on the function for build-time discovery
2. In host mode: Registers immediately with FastMCP
3. In extension mode: Defers registration (ExtensionManager validates manifest)
4. In build mode: Stores metadata only
Supports both @prompt and @prompt(...) syntax.
@@ -143,59 +249,67 @@ def create_prompt_decorator(
protect: Whether to apply Superset authentication (defaults to True)
Returns:
Decorator that registers and wraps the prompt with optional authentication,
or the wrapped function when used without parentheses
Decorated function with __prompt_metadata__ attribute
"""
def decorator(func: F) -> F:
try:
# Import here to avoid circular imports
from superset.mcp_service.app import mcp
from superset_core.extensions.context import get_context
from superset_core.mcp import PromptMetadata
# Use provided values or extract from function
prompt_name = name or func.__name__
prompt_title = title or func.__name__
prompt_description = description or func.__doc__ or f"Prompt: {prompt_name}"
prompt_tags = tags or set()
# Use provided values or extract from function
prompt_name = name or func.__name__
prompt_title = title or func.__name__
prompt_description = description
if prompt_description is None and func.__doc__:
prompt_description = func.__doc__.strip().split("\n")[0]
prompt_tags = tags or set()
# Conditionally apply authentication wrapper
if protect:
from superset.mcp_service.auth import mcp_auth_hook
# Store metadata on function for discovery
metadata = PromptMetadata(
id=func.__name__,
name=prompt_name,
title=prompt_title,
description=prompt_description,
tags=prompt_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__prompt_metadata__ = metadata # type: ignore[attr-defined]
wrapped_func = mcp_auth_hook(func)
else:
wrapped_func = func
ctx = get_context()
# Register prompt with FastMCP using the same pattern as existing code
mcp.prompt(
name=prompt_name,
title=prompt_title,
description=prompt_description,
tags=prompt_tags,
)(wrapped_func)
protected_status = "protected" if protect else "public"
logger.info("Registered MCP prompt: %s (%s)", prompt_name, protected_status)
return wrapped_func
except Exception as e:
logger.error(
"Failed to register MCP prompt %s: %s", name or func.__name__, e
)
# Return the original function so extension doesn't break
# Build mode: metadata only, no registration
if ctx.is_build_mode:
return func
# If called as @prompt (without parentheses)
# Extension mode: defer registration to ExtensionManager
if ctx.is_extension_mode:
ctx.add_pending_contribution(func, metadata, "prompt")
return func
# Host mode: register immediately
try:
wrapped = _register_prompt_with_mcp(
func,
prompt_name,
prompt_title,
prompt_description,
prompt_tags,
protect,
)
wrapped.__prompt_metadata__ = metadata # type: ignore[attr-defined]
return wrapped # type: ignore[return-value]
except Exception as e:
logger.error("Failed to register MCP prompt %s: %s", prompt_name, e)
return func
# Handle decorator usage patterns
if callable(func_or_name):
# Type cast is safe here since we've confirmed it's callable
return decorator(func_or_name) # type: ignore[arg-type]
# If called as @prompt() or @prompt(name="...")
# func_or_name would be the name parameter or None
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
# Use the actual_name if provided via name_or_fn
nonlocal name
if actual_name is not None:
name = actual_name
@@ -204,6 +318,69 @@ def create_prompt_decorator(
return parameterized_decorator
def register_tool_from_manifest(
func: Callable[..., Any],
metadata: Any, # ToolMetadata
extension_id: str,
) -> Callable[..., Any]:
"""
Register a tool from an extension after manifest validation.
Called by ExtensionManager after verifying the contribution
is declared in the extension's manifest.
Args:
func: The decorated function
metadata: ToolMetadata from the function
extension_id: The extension ID (used for namespacing)
Returns:
The registered wrapped function
"""
# Namespace the tool name with extension ID
prefixed_name = f"{extension_id}.{metadata.name}"
return _register_tool_with_mcp(
func,
prefixed_name,
metadata.description,
metadata.tags,
metadata.protect,
)
def register_prompt_from_manifest(
func: Callable[..., Any],
metadata: Any, # PromptMetadata
extension_id: str,
) -> Callable[..., Any]:
"""
Register a prompt from an extension after manifest validation.
Called by ExtensionManager after verifying the contribution
is declared in the extension's manifest.
Args:
func: The decorated function
metadata: PromptMetadata from the function
extension_id: The extension ID (used for namespacing)
Returns:
The registered wrapped function
"""
# Namespace the prompt name with extension ID
prefixed_name = f"{extension_id}.{metadata.name}"
return _register_prompt_with_mcp(
func,
prefixed_name,
metadata.title or metadata.name,
metadata.description,
metadata.tags,
metadata.protect,
)
def initialize_core_mcp_dependencies() -> None:
"""
Initialize MCP dependency injection by replacing abstract functions

View File

@@ -0,0 +1,333 @@
# 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 manager for loading and validating extensions.
The ExtensionManager coordinates extension loading:
1. Discovers extensions from configured paths
2. Validates contributions against manifests
3. Registers only declared contributions
Security model: The manifest.json serves as an allowlist.
Only contributions declared in the manifest are registered.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, TYPE_CHECKING, TypeVar
from superset_core.extensions.context import get_context, PendingContribution
from superset_core.extensions.types import (
Manifest,
)
from superset.extensions.types import LoadedExtension
from superset.extensions.utils import (
eager_import,
install_in_memory_importer,
)
if TYPE_CHECKING:
from flask import Flask
logger = logging.getLogger(__name__)
T = TypeVar("T")
class ContributionValidationError(Exception):
"""Raised when a contribution is not declared in the manifest."""
pass
class ExtensionManager:
"""
Manages extension lifecycle and contribution registration.
Extensions are loaded and their contributions are validated against
the manifest before registration. This ensures only declared
functionality is exposed.
"""
def __init__(self) -> None:
self._extensions: dict[str, LoadedExtension] = {}
self._contribution_registry: dict[str, dict[str, Any]] = {
"mcpTools": {},
"mcpPrompts": {},
"restApis": {},
}
def init_app(self, app: Flask) -> None:
"""
Initialize extension manager with Flask app.
Loads extensions from configuration and registers contributions.
Args:
app: Flask application instance
"""
from superset.extensions.utils import get_extensions
with app.app_context():
extensions = get_extensions()
for extension_id, extension in extensions.items():
try:
self._load_extension(extension, app)
except Exception as e:
logger.error(
"Failed to load extension %s: %s",
extension_id,
e,
exc_info=True,
)
def _load_extension(self, extension: LoadedExtension, app: Flask) -> None:
"""
Load a single extension and register its contributions.
Args:
extension: The loaded extension
app: Flask application instance
"""
extension_id = extension.id
manifest = extension.manifest
logger.info("Loading extension: %s (%s)", extension.name, extension_id)
# Store extension reference
self._extensions[extension_id] = extension
# Install in-memory importer for backend code
if extension.backend:
install_in_memory_importer(extension.backend, extension.source_base_path)
# Get registration context
ctx = get_context()
# Load entry points within extension context
with ctx.extension_context(extension_id):
if manifest.backend and manifest.backend.entryPoints:
for entry_point in manifest.backend.entryPoints:
logger.debug(
"Loading entry point: %s for extension %s",
entry_point,
extension_id,
)
try:
eager_import(entry_point)
except Exception as e:
logger.error(
"Failed to load entry point %s: %s",
entry_point,
e,
exc_info=True,
)
raise
# Validate and register pending contributions
self._validate_and_register_contributions(extension_id, manifest)
def _validate_and_register_contributions(
self, extension_id: str, manifest: Manifest
) -> None:
"""
Validate pending contributions against manifest and register.
Args:
extension_id: Extension being validated
manifest: Extension manifest
Raises:
ContributionValidationError: If undeclared contribution found
"""
ctx = get_context()
pending = ctx.get_pending_contributions(extension_id)
backend_contribs = manifest.backend.contributions if manifest.backend else None
if not pending:
return
# Build allowlists from manifest
allowed_tools: set[str] = set()
allowed_prompts: set[str] = set()
allowed_apis: set[str] = set()
if backend_contribs:
allowed_tools = {t.id for t in backend_contribs.mcpTools}
allowed_prompts = {p.id for p in backend_contribs.mcpPrompts}
allowed_apis = {a.id for a in backend_contribs.restApis}
for contrib in pending:
self._validate_single_contribution(
extension_id,
contrib,
allowed_tools,
allowed_prompts,
allowed_apis,
)
self._register_contribution(extension_id, contrib)
# Clear pending after successful registration
ctx.clear_pending_contributions(extension_id)
logger.info(
"Registered %d contributions for extension %s",
len(pending),
extension_id,
)
def _validate_single_contribution(
self,
extension_id: str,
contrib: PendingContribution,
allowed_tools: set[str],
allowed_prompts: set[str],
allowed_apis: set[str],
) -> None:
"""
Validate a single contribution against the allowlist.
Args:
extension_id: Extension owning the contribution
contrib: The pending contribution
allowed_tools: Set of allowed tool IDs
allowed_prompts: Set of allowed prompt IDs
allowed_apis: Set of allowed API IDs
Raises:
ContributionValidationError: If not in allowlist
"""
contrib_id = contrib.metadata.name
contrib_type = contrib.contrib_type
if contrib_type == "tool":
if contrib_id not in allowed_tools:
raise ContributionValidationError(
f"Extension '{extension_id}' attempted to register undeclared "
f"MCP tool '{contrib_id}'. Add it to manifest.json to allow."
)
elif contrib_type == "prompt":
if contrib_id not in allowed_prompts:
raise ContributionValidationError(
f"Extension '{extension_id}' attempted to register undeclared "
f"MCP prompt '{contrib_id}'. Add it to manifest.json to allow."
)
elif contrib_type == "restApi":
if contrib_id not in allowed_apis:
raise ContributionValidationError(
f"Extension '{extension_id}' attempted to register undeclared "
f"REST API '{contrib_id}'. Add it to manifest.json to allow."
)
def _register_contribution(
self, extension_id: str, contrib: PendingContribution
) -> None:
"""
Register a validated contribution.
Args:
extension_id: Extension owning the contribution
contrib: The contribution to register
"""
from superset.core.mcp.core_mcp_injection import (
register_prompt_from_manifest,
register_tool_from_manifest,
)
contrib_type = contrib.contrib_type
contrib_id = contrib.metadata.name
if contrib_type == "tool":
register_tool_from_manifest(contrib.func, contrib.metadata, extension_id)
self._contribution_registry["mcpTools"][contrib_id] = {
"extension": extension_id,
"func": contrib.func,
"metadata": contrib.metadata,
}
elif contrib_type == "prompt":
register_prompt_from_manifest(contrib.func, contrib.metadata, extension_id)
self._contribution_registry["mcpPrompts"][contrib_id] = {
"extension": extension_id,
"func": contrib.func,
"metadata": contrib.metadata,
}
elif contrib_type == "restApi":
# REST APIs are registered through Flask-AppBuilder
# during the extension loading process
self._contribution_registry["restApis"][contrib_id] = {
"extension": extension_id,
"cls": contrib.func,
"metadata": contrib.metadata,
}
def get_extension(self, extension_id: str) -> LoadedExtension | None:
"""
Get a loaded extension by ID.
Args:
extension_id: Extension identifier
Returns:
LoadedExtension or None if not found
"""
return self._extensions.get(extension_id)
def get_contribution(
self, contrib_type: str, contrib_id: str
) -> Callable[..., Any] | type | None:
"""
Get a registered contribution by type and ID.
Args:
contrib_type: Contribution type (mcpTools, mcpPrompts, restApis)
contrib_id: Contribution identifier
Returns:
The contribution function/class or None if not found
"""
registry = self._contribution_registry.get(contrib_type, {})
if entry := registry.get(contrib_id):
return entry.get("func") or entry.get("cls")
return None
def list_contributions(self, contrib_type: str) -> list[str]:
"""
List all registered contribution IDs of a type.
Args:
contrib_type: Contribution type
Returns:
List of contribution IDs
"""
return list(self._contribution_registry.get(contrib_type, {}).keys())
def list_extensions(self) -> list[str]:
"""
List all loaded extension IDs.
Returns:
List of extension IDs
"""
return list(self._extensions.keys())
# Global extension manager instance
extension_manager = ExtensionManager()

View File

@@ -564,36 +564,24 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
self.init_extensions()
def init_extensions(self) -> None:
from superset.extensions.utils import (
eager_import,
get_extensions,
install_in_memory_importer,
)
"""
Initialize extensions using the ExtensionManager.
The ExtensionManager:
1. Discovers extensions from configured paths
2. Loads extension backend code
3. Validates contributions against manifests
4. Registers only declared contributions
"""
from superset.extensions.manager import extension_manager
try:
extensions = get_extensions()
except Exception: # pylint: disable=broad-except # noqa: S110
extension_manager.init_app(self.superset_app)
except Exception as ex: # pylint: disable=broad-except
# If the db hasn't been initialized yet, an exception will be raised.
# It's fine to ignore this, as in this case there are no extensions
# present yet.
return
for extension in extensions.values():
if backend_files := extension.backend:
install_in_memory_importer(
backend_files,
source_base_path=extension.source_base_path,
)
backend = extension.manifest.backend
if backend and (entrypoints := backend.entryPoints):
for entrypoint in entrypoints:
try:
eager_import(entrypoint)
except Exception as ex: # pylint: disable=broad-except # noqa: S110
# Surface exceptions during initialization of extensions
print(ex)
logger.warning("Failed to initialize extensions: %s", ex)
def init_app_in_ctx(self) -> None:
"""

File diff suppressed because one or more lines are too long