mirror of
https://github.com/apache/superset.git
synced 2026-06-15 20:49:18 +00:00
Compare commits
2 Commits
bump-setup
...
villebro/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423e5280d0 | ||
|
|
c5dce675a0 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
197
superset-core/src/superset_core/extensions/context.py
Normal file
197
superset-core/src/superset_core/extensions/context.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
16
superset-core/tests/__init__.py
Normal file
16
superset-core/tests/__init__.py
Normal 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.
|
||||
147
superset-core/tests/test_api_decorators.py
Normal file
147
superset-core/tests/test_api_decorators.py
Normal 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)
|
||||
154
superset-core/tests/test_mcp_decorators.py
Normal file
154
superset-core/tests/test_mcp_decorators.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
"license": "{{ license }}",
|
||||
{% if include_frontend -%}
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"commands": [],
|
||||
"views": {},
|
||||
"menus": {},
|
||||
"editors": []
|
||||
},
|
||||
"moduleFederation": {
|
||||
"name": "{{ mf_name }}",
|
||||
"exposes": ["./index"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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/**"],
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
236
superset-extensions-cli/tests/test_backend_discovery.py
Normal file
236
superset-extensions-cli/tests/test_backend_discovery.py
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
72
superset-frontend/package-lock.json
generated
72
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
326
superset-frontend/packages/superset-core/src/extensions/index.ts
Normal file
326
superset-frontend/packages/superset-core/src/extensions/index.ts
Normal 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;
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
export * from './api';
|
||||
export * from './extensions';
|
||||
export * from './ui';
|
||||
export * from './utils';
|
||||
|
||||
253
superset-frontend/packages/superset-core/test/extensions.test.ts
Normal file
253
superset-frontend/packages/superset-core/test/extensions.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
532
superset-frontend/packages/webpack-extension-plugin/src/index.ts
Normal file
532
superset-frontend/packages/webpack-extension-plugin/src/index.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
470
superset-frontend/src/extensions/ExtensionLoader.ts
Normal file
470
superset-frontend/src/extensions/ExtensionLoader.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
333
superset/extensions/manager.py
Normal file
333
superset/extensions/manager.py
Normal 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()
|
||||
@@ -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
Reference in New Issue
Block a user