mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
refactor(extensions): add Pydantic validation for extension configuration (#36767)
This commit is contained in:
committed by
GitHub
parent
fb6f3fbb4d
commit
5920cb57ea
@@ -15,49 +15,190 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from typing import TypedDict
|
||||
"""
|
||||
Pydantic models for extension configuration and manifest validation.
|
||||
|
||||
Two distinct schemas:
|
||||
- ExtensionConfig: Source configuration (extension.json) authored by developers
|
||||
- Manifest: Built output (manifest.json) generated by the build tool
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field # noqa: I001
|
||||
|
||||
# =============================================================================
|
||||
# Shared components
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ModuleFederationConfig(TypedDict):
|
||||
exposes: dict[str, str]
|
||||
filename: str
|
||||
shared: dict[str, str]
|
||||
remotes: dict[str, str]
|
||||
class ModuleFederationConfig(BaseModel):
|
||||
"""Configuration for Webpack Module Federation."""
|
||||
|
||||
exposes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Modules exposed by this extension",
|
||||
)
|
||||
filename: str = Field(
|
||||
default="remoteEntry.js",
|
||||
description="Remote entry filename",
|
||||
)
|
||||
shared: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Shared dependencies configuration",
|
||||
)
|
||||
remotes: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Remote module references",
|
||||
)
|
||||
|
||||
|
||||
class FrontendContributionConfig(TypedDict):
|
||||
commands: dict[str, list[dict[str, str]]]
|
||||
views: dict[str, list[dict[str, str]]]
|
||||
menus: dict[str, list[dict[str, str]]]
|
||||
class ContributionConfig(BaseModel):
|
||||
"""Configuration for frontend UI contributions."""
|
||||
|
||||
commands: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Command contributions",
|
||||
)
|
||||
views: dict[str, list[dict[str, Any]]] = Field(
|
||||
default_factory=dict,
|
||||
description="View contributions by location",
|
||||
)
|
||||
menus: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Menu contributions",
|
||||
)
|
||||
|
||||
|
||||
class FrontendManifest(TypedDict):
|
||||
contributions: FrontendContributionConfig
|
||||
moduleFederation: ModuleFederationConfig
|
||||
remoteEntry: str
|
||||
class BaseExtension(BaseModel):
|
||||
"""Base fields shared by ExtensionConfig and Manifest."""
|
||||
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Unique extension identifier",
|
||||
min_length=1,
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Human-readable extension name",
|
||||
min_length=1,
|
||||
)
|
||||
version: str = Field(
|
||||
default="0.0.0",
|
||||
description="Semantic version string",
|
||||
pattern=r"^\d+\.\d+\.\d+$",
|
||||
)
|
||||
license: str | None = Field(
|
||||
default=None,
|
||||
description="License identifier (e.g., 'Apache-2.0')",
|
||||
)
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Extension description",
|
||||
)
|
||||
dependencies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of extension IDs this extension depends on",
|
||||
)
|
||||
permissions: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Permissions required by this extension",
|
||||
)
|
||||
|
||||
|
||||
class BackendManifest(TypedDict):
|
||||
entryPoints: list[str]
|
||||
# =============================================================================
|
||||
# ExtensionConfig (extension.json)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SharedBase(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
dependencies: list[str]
|
||||
description: str
|
||||
version: str
|
||||
frontend: FrontendManifest
|
||||
permissions: list[str]
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
class Manifest(SharedBase, total=False):
|
||||
backend: BackendManifest
|
||||
class ExtensionConfigBackend(BaseModel):
|
||||
"""Backend section in extension.json."""
|
||||
|
||||
entryPoints: list[str] = Field( # noqa: N815
|
||||
default_factory=list,
|
||||
description="Python module entry points to load",
|
||||
)
|
||||
files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Glob patterns for backend Python files",
|
||||
)
|
||||
|
||||
|
||||
class BackendMetadata(BackendManifest):
|
||||
files: list[str]
|
||||
class ExtensionConfig(BaseExtension):
|
||||
"""
|
||||
Schema for extension.json (source configuration).
|
||||
|
||||
This file is authored by developers to define extension metadata.
|
||||
"""
|
||||
|
||||
frontend: ExtensionConfigFrontend | None = Field(
|
||||
default=None,
|
||||
description="Frontend configuration",
|
||||
)
|
||||
backend: ExtensionConfigBackend | None = Field(
|
||||
default=None,
|
||||
description="Backend configuration",
|
||||
)
|
||||
|
||||
|
||||
class Metadata(SharedBase):
|
||||
backend: BackendMetadata
|
||||
# =============================================================================
|
||||
# Manifest (manifest.json)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ManifestFrontend(BaseModel):
|
||||
"""Frontend section in manifest.json."""
|
||||
|
||||
contributions: ContributionConfig = Field(
|
||||
default_factory=ContributionConfig,
|
||||
description="UI contribution points",
|
||||
)
|
||||
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
|
||||
default_factory=ModuleFederationConfig,
|
||||
description="Module Federation configuration",
|
||||
)
|
||||
remoteEntry: str = Field( # noqa: N815
|
||||
...,
|
||||
description="Path to the built remote entry file",
|
||||
)
|
||||
|
||||
|
||||
class ManifestBackend(BaseModel):
|
||||
"""Backend section in manifest.json."""
|
||||
|
||||
entryPoints: list[str] = Field( # noqa: N815
|
||||
default_factory=list,
|
||||
description="Python module entry points to load",
|
||||
)
|
||||
|
||||
|
||||
class Manifest(BaseExtension):
|
||||
"""
|
||||
Schema for manifest.json (built output).
|
||||
|
||||
This file is generated by the build tool from extension.json.
|
||||
"""
|
||||
|
||||
frontend: ManifestFrontend | None = Field(
|
||||
default=None,
|
||||
description="Frontend manifest",
|
||||
)
|
||||
backend: ManifestBackend | None = Field(
|
||||
default=None,
|
||||
description="Backend manifest",
|
||||
)
|
||||
|
||||
@@ -23,12 +23,17 @@ import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, cast
|
||||
from typing import Any, Callable
|
||||
|
||||
import click
|
||||
import semver
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from superset_core.extensions.types import Manifest, Metadata
|
||||
from superset_core.extensions.types import (
|
||||
ExtensionConfig,
|
||||
Manifest,
|
||||
ManifestBackend,
|
||||
ManifestFrontend,
|
||||
)
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
@@ -125,40 +130,40 @@ def clean_dist_frontend(cwd: Path) -> None:
|
||||
|
||||
|
||||
def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
|
||||
extension: Metadata = cast(Metadata, read_json(cwd / "extension.json"))
|
||||
if not extension:
|
||||
extension_data = read_json(cwd / "extension.json")
|
||||
if not extension_data:
|
||||
click.secho("❌ extension.json not found.", err=True, fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
manifest: Manifest = {
|
||||
"id": extension["id"],
|
||||
"name": extension["name"],
|
||||
"version": extension["version"],
|
||||
"permissions": extension["permissions"],
|
||||
"dependencies": extension.get("dependencies", []),
|
||||
}
|
||||
if (
|
||||
(frontend := extension.get("frontend"))
|
||||
and (contributions := frontend.get("contributions"))
|
||||
and (module_federation := frontend.get("moduleFederation"))
|
||||
and remote_entry
|
||||
):
|
||||
manifest["frontend"] = {
|
||||
"contributions": contributions,
|
||||
"moduleFederation": module_federation,
|
||||
"remoteEntry": remote_entry,
|
||||
}
|
||||
extension = ExtensionConfig.model_validate(extension_data)
|
||||
|
||||
if entry_points := extension.get("backend", {}).get("entryPoints"):
|
||||
manifest["backend"] = {"entryPoints": entry_points}
|
||||
frontend: ManifestFrontend | None = None
|
||||
if extension.frontend and remote_entry:
|
||||
frontend = ManifestFrontend(
|
||||
contributions=extension.frontend.contributions,
|
||||
moduleFederation=extension.frontend.moduleFederation,
|
||||
remoteEntry=remote_entry,
|
||||
)
|
||||
|
||||
return manifest
|
||||
backend: ManifestBackend | None = None
|
||||
if extension.backend and extension.backend.entryPoints:
|
||||
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
|
||||
|
||||
return Manifest(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
version=extension.version,
|
||||
permissions=extension.permissions,
|
||||
dependencies=extension.dependencies,
|
||||
frontend=frontend,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
|
||||
def write_manifest(cwd: Path, manifest: Manifest) -> None:
|
||||
dist_dir = cwd / "dist"
|
||||
(dist_dir / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True)
|
||||
manifest.model_dump_json(indent=2, exclude_none=True, by_alias=True)
|
||||
)
|
||||
click.secho("✅ Manifest updated", fg="green")
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
|
||||
"permissions": ["read_data"],
|
||||
"dependencies": ["some_dep"],
|
||||
"frontend": {
|
||||
"contributions": {"commands": ["test_command"]},
|
||||
"contributions": {"commands": [{"id": "test_command", "title": "Test"}]},
|
||||
"moduleFederation": {"exposes": ["./index"]},
|
||||
},
|
||||
"backend": {"entryPoints": ["test_extension.entrypoint"]},
|
||||
@@ -247,23 +247,23 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
|
||||
manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js")
|
||||
|
||||
# Verify manifest structure
|
||||
manifest_dict = dict(manifest)
|
||||
assert manifest_dict["id"] == "test_extension"
|
||||
assert manifest_dict["name"] == "Test Extension"
|
||||
assert manifest_dict["version"] == "1.0.0"
|
||||
assert manifest_dict["permissions"] == ["read_data"]
|
||||
assert manifest_dict["dependencies"] == ["some_dep"]
|
||||
assert manifest.id == "test_extension"
|
||||
assert manifest.name == "Test Extension"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.permissions == ["read_data"]
|
||||
assert manifest.dependencies == ["some_dep"]
|
||||
|
||||
# Verify frontend section
|
||||
assert "frontend" in manifest
|
||||
frontend = manifest["frontend"]
|
||||
assert frontend["contributions"] == {"commands": ["test_command"]}
|
||||
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
|
||||
assert frontend["remoteEntry"] == "remoteEntry.abc123.js"
|
||||
assert manifest.frontend is not None
|
||||
assert manifest.frontend.contributions.commands == [
|
||||
{"id": "test_command", "title": "Test"}
|
||||
]
|
||||
assert manifest.frontend.moduleFederation.exposes == ["./index"]
|
||||
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
|
||||
|
||||
# Verify backend section
|
||||
assert "backend" in manifest
|
||||
assert manifest["backend"]["entryPoints"] == ["test_extension.entrypoint"]
|
||||
assert manifest.backend is not None
|
||||
assert manifest.backend.entryPoints == ["test_extension.entrypoint"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -280,14 +280,13 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
|
||||
|
||||
manifest = build_manifest(isolated_filesystem, None)
|
||||
|
||||
manifest_dict = dict(manifest)
|
||||
assert manifest_dict["id"] == "minimal_extension"
|
||||
assert manifest_dict["name"] == "Minimal Extension"
|
||||
assert manifest_dict["version"] == "0.1.0"
|
||||
assert manifest_dict["permissions"] == []
|
||||
assert manifest_dict["dependencies"] == [] # Default empty list
|
||||
assert "frontend" not in manifest
|
||||
assert "backend" not in manifest
|
||||
assert manifest.id == "minimal_extension"
|
||||
assert manifest.name == "Minimal Extension"
|
||||
assert manifest.version == "0.1.0"
|
||||
assert manifest.permissions == []
|
||||
assert manifest.dependencies == [] # Default empty list
|
||||
assert manifest.frontend is None
|
||||
assert manifest.backend is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -23,6 +23,7 @@ import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from superset_core.extensions.types import Manifest
|
||||
from superset_extensions_cli.cli import app, FrontendChangeHandler
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ def test_dev_command_starts_watchers(
|
||||
"""Test dev command starts file watchers."""
|
||||
# Setup mocks
|
||||
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
|
||||
mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"}
|
||||
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
|
||||
|
||||
mock_observer = Mock()
|
||||
mock_observer_class.return_value = mock_observer
|
||||
@@ -100,7 +101,7 @@ def test_dev_command_initial_build(
|
||||
"""Test dev command performs initial build setup."""
|
||||
# Setup mocks
|
||||
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
|
||||
mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"}
|
||||
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
|
||||
|
||||
extension_setup_for_dev(isolated_filesystem)
|
||||
|
||||
@@ -188,11 +189,12 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
dist_dir.mkdir()
|
||||
|
||||
mock_manifest = Manifest(id="test", name="test", version="1.0.0")
|
||||
with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild:
|
||||
with patch("superset_extensions_cli.cli.build_manifest") as mock_build:
|
||||
with patch("superset_extensions_cli.cli.write_manifest") as mock_write:
|
||||
mock_rebuild.return_value = "remoteEntry.abc123.js"
|
||||
mock_build.return_value = {"name": "test", "version": "1.0.0"}
|
||||
mock_build.return_value = mock_manifest
|
||||
|
||||
# Simulate frontend watcher function logic
|
||||
frontend_dir = isolated_filesystem / "frontend"
|
||||
@@ -209,9 +211,7 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
|
||||
mock_build.assert_called_once_with(
|
||||
isolated_filesystem, "remoteEntry.abc123.js"
|
||||
)
|
||||
mock_write.assert_called_once_with(
|
||||
isolated_filesystem, {"name": "test", "version": "1.0.0"}
|
||||
)
|
||||
mock_write.assert_called_once_with(isolated_filesystem, mock_manifest)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -61,9 +61,8 @@ def discover_and_load_extensions(
|
||||
with ZipFile(supx_file, "r") as zip_file:
|
||||
files = get_bundle_files_from_zip(zip_file)
|
||||
extension = get_loaded_extension(files)
|
||||
extension_id = extension.manifest["id"]
|
||||
logger.info(
|
||||
"Loaded extension '%s' from %s", extension_id, supx_file
|
||||
"Loaded extension '%s' from %s", extension.id, supx_file
|
||||
)
|
||||
yield extension
|
||||
except Exception as e:
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Generator, Iterable, Tuple
|
||||
from zipfile import ZipFile
|
||||
|
||||
from flask import current_app
|
||||
from pydantic import ValidationError
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
from superset.extensions.types import BundleFile, LoadedExtension
|
||||
@@ -121,7 +122,7 @@ def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, No
|
||||
|
||||
|
||||
def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
|
||||
manifest: Manifest = {}
|
||||
manifest: Manifest | None = None
|
||||
frontend: dict[str, bytes] = {}
|
||||
backend: dict[str, bytes] = {}
|
||||
|
||||
@@ -131,13 +132,12 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
|
||||
|
||||
if filename == "manifest.json":
|
||||
try:
|
||||
manifest = json.loads(content)
|
||||
if "id" not in manifest:
|
||||
raise Exception("Missing 'id' in manifest")
|
||||
if "name" not in manifest:
|
||||
raise Exception("Missing 'name' in manifest")
|
||||
manifest_data = json.loads(content)
|
||||
manifest = Manifest.model_validate(manifest_data)
|
||||
except ValidationError as e:
|
||||
raise Exception(f"Invalid manifest.json: {e}") from e
|
||||
except Exception as e:
|
||||
raise Exception("Invalid manifest.json: %s" % e) from e
|
||||
raise Exception(f"Failed to parse manifest.json: {e}") from e
|
||||
|
||||
elif (match := FRONTEND_REGEX.match(filename)) is not None:
|
||||
frontend[match.group(1)] = content
|
||||
@@ -146,40 +146,39 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
|
||||
backend[match.group(1)] = content
|
||||
|
||||
else:
|
||||
raise Exception("Unexpected file in bundle: %s" % filename)
|
||||
raise Exception(f"Unexpected file in bundle: {filename}")
|
||||
|
||||
if manifest is None:
|
||||
raise Exception("Missing manifest.json in extension bundle")
|
||||
|
||||
id_ = manifest["id"]
|
||||
name = manifest["name"]
|
||||
version = manifest["version"]
|
||||
return LoadedExtension(
|
||||
id=id_,
|
||||
name=name,
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
manifest=manifest,
|
||||
frontend=frontend,
|
||||
backend=backend,
|
||||
version=version,
|
||||
version=manifest.version,
|
||||
)
|
||||
|
||||
|
||||
def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
||||
manifest: Manifest = extension.manifest
|
||||
manifest = extension.manifest
|
||||
extension_data: dict[str, Any] = {
|
||||
"id": manifest["id"],
|
||||
"id": manifest.id,
|
||||
"name": extension.name,
|
||||
"version": extension.version,
|
||||
"description": manifest.get("description", ""),
|
||||
"dependencies": manifest.get("dependencies", []),
|
||||
"extensionDependencies": manifest.get("extensionDependencies", []),
|
||||
"description": manifest.description or "",
|
||||
"dependencies": manifest.dependencies,
|
||||
}
|
||||
if frontend := manifest.get("frontend"):
|
||||
module_federation = frontend.get("moduleFederation", {})
|
||||
remote_entry = frontend["remoteEntry"]
|
||||
if manifest.frontend:
|
||||
frontend = manifest.frontend
|
||||
module_federation = frontend.moduleFederation
|
||||
remote_entry_url = f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}"
|
||||
extension_data.update(
|
||||
{
|
||||
"remoteEntry": "/api/v1/extensions/%s/%s"
|
||||
% (manifest["id"], remote_entry), # noqa: E501
|
||||
"exposedModules": module_federation.get("exposes", []),
|
||||
"contributions": frontend.get("contributions", {}),
|
||||
"remoteEntry": remote_entry_url,
|
||||
"exposedModules": module_federation.exposes,
|
||||
"contributions": frontend.contributions.model_dump(),
|
||||
}
|
||||
)
|
||||
return extension_data
|
||||
@@ -192,7 +191,7 @@ def get_extensions() -> dict[str, LoadedExtension]:
|
||||
for path in current_app.config["LOCAL_EXTENSIONS"]:
|
||||
files = get_bundle_files_from_path(path)
|
||||
extension = get_loaded_extension(files)
|
||||
extension_id = extension.manifest["id"]
|
||||
extension_id = extension.manifest.id
|
||||
extensions[extension_id] = extension
|
||||
logger.info(
|
||||
"Loading extension %s (ID: %s) from local filesystem",
|
||||
@@ -205,7 +204,7 @@ def get_extensions() -> dict[str, LoadedExtension]:
|
||||
from superset.extensions.discovery import discover_and_load_extensions
|
||||
|
||||
for extension in discover_and_load_extensions(extensions_path):
|
||||
extension_id = extension.manifest["id"]
|
||||
extension_id = extension.manifest.id
|
||||
if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS
|
||||
extensions[extension_id] = extension
|
||||
logger.info(
|
||||
|
||||
@@ -564,9 +564,9 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
if backend_files := extension.backend:
|
||||
install_in_memory_importer(backend_files)
|
||||
|
||||
backend = extension.manifest.get("backend")
|
||||
backend = extension.manifest.backend
|
||||
|
||||
if backend and (entrypoints := backend.get("entryPoints")):
|
||||
if backend and (entrypoints := backend.entryPoints):
|
||||
for entrypoint in entrypoints:
|
||||
try:
|
||||
eager_import(entrypoint)
|
||||
|
||||
258
tests/unit_tests/extensions/test_types.py
Normal file
258
tests/unit_tests/extensions/test_types.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# 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 extension configuration and manifest Pydantic models."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from superset_core.extensions.types import (
|
||||
ContributionConfig,
|
||||
ExtensionConfig,
|
||||
ExtensionConfigBackend,
|
||||
ExtensionConfigFrontend,
|
||||
Manifest,
|
||||
ManifestBackend,
|
||||
ModuleFederationConfig,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# ExtensionConfig (extension.json) tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_extension_config_minimal():
|
||||
"""Test ExtensionConfig with minimal required fields."""
|
||||
config = ExtensionConfig.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
}
|
||||
)
|
||||
assert config.id == "my-extension"
|
||||
assert config.name == "My Extension"
|
||||
assert config.version == "0.0.0"
|
||||
assert config.dependencies == []
|
||||
assert config.permissions == []
|
||||
assert config.frontend is None
|
||||
assert config.backend is None
|
||||
|
||||
|
||||
def test_extension_config_full():
|
||||
"""Test ExtensionConfig with all fields populated."""
|
||||
config = ExtensionConfig.model_validate(
|
||||
{
|
||||
"id": "query_insights",
|
||||
"name": "Query Insights",
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A query insights extension",
|
||||
"dependencies": ["other-extension"],
|
||||
"permissions": ["can_read", "can_view"],
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"views": {
|
||||
"sqllab.panels": [
|
||||
{"id": "query_insights.main", "name": "Query Insights"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"moduleFederation": {"exposes": ["./index"]},
|
||||
},
|
||||
"backend": {
|
||||
"entryPoints": ["query_insights.entrypoint"],
|
||||
"files": ["backend/src/query_insights/**/*.py"],
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config.id == "query_insights"
|
||||
assert config.name == "Query Insights"
|
||||
assert config.version == "1.0.0"
|
||||
assert config.license == "Apache-2.0"
|
||||
assert config.description == "A query insights extension"
|
||||
assert config.dependencies == ["other-extension"]
|
||||
assert config.permissions == ["can_read", "can_view"]
|
||||
assert config.frontend is not None
|
||||
assert config.frontend.moduleFederation.exposes == ["./index"]
|
||||
assert config.backend is not None
|
||||
assert config.backend.entryPoints == ["query_insights.entrypoint"]
|
||||
assert config.backend.files == ["backend/src/query_insights/**/*.py"]
|
||||
|
||||
|
||||
def test_extension_config_missing_id():
|
||||
"""Test ExtensionConfig raises error when id is missing."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExtensionConfig.model_validate({"name": "My Extension"})
|
||||
assert "id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_extension_config_missing_name():
|
||||
"""Test ExtensionConfig raises error when name is missing."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExtensionConfig.model_validate({"id": "my-extension"})
|
||||
assert "name" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_extension_config_empty_id():
|
||||
"""Test ExtensionConfig raises error when id is empty."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExtensionConfig.model_validate({"id": "", "name": "My Extension"})
|
||||
assert "id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_extension_config_invalid_version():
|
||||
"""Test ExtensionConfig raises error for invalid version format."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExtensionConfig.model_validate(
|
||||
{"id": "my-extension", "name": "My Extension", "version": "invalid"}
|
||||
)
|
||||
assert "version" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_extension_config_valid_versions():
|
||||
"""Test ExtensionConfig accepts valid semantic versions (major.minor.patch only)."""
|
||||
for version in ["1.0.0", "0.1.0", "10.20.30"]:
|
||||
config = ExtensionConfig.model_validate(
|
||||
{"id": "my-extension", "name": "My Extension", "version": version}
|
||||
)
|
||||
assert config.version == version
|
||||
|
||||
|
||||
def test_extension_config_prerelease_version_rejected():
|
||||
"""Test ExtensionConfig rejects prerelease versions."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExtensionConfig.model_validate(
|
||||
{"id": "my-extension", "name": "My Extension", "version": "1.0.0-beta"}
|
||||
)
|
||||
assert "version" in str(exc_info.value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Manifest (manifest.json) tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_manifest_minimal():
|
||||
"""Test Manifest with minimal required fields."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
}
|
||||
)
|
||||
assert manifest.id == "my-extension"
|
||||
assert manifest.name == "My Extension"
|
||||
assert manifest.frontend is None
|
||||
assert manifest.backend is None
|
||||
|
||||
|
||||
def test_manifest_with_frontend():
|
||||
"""Test Manifest with frontend section requires remoteEntry."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
"frontend": {
|
||||
"remoteEntry": "remoteEntry.abc123.js",
|
||||
"contributions": {},
|
||||
"moduleFederation": {"exposes": ["./index"]},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert manifest.frontend is not None
|
||||
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
|
||||
assert manifest.frontend.moduleFederation.exposes == ["./index"]
|
||||
|
||||
|
||||
def test_manifest_frontend_missing_remote_entry():
|
||||
"""Test Manifest raises error when frontend is missing remoteEntry."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Manifest.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
"frontend": {"contributions": {}, "moduleFederation": {}},
|
||||
}
|
||||
)
|
||||
assert "remoteEntry" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_manifest_with_backend():
|
||||
"""Test Manifest with backend section."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
"backend": {"entryPoints": ["my_extension.entrypoint"]},
|
||||
}
|
||||
)
|
||||
assert manifest.backend is not None
|
||||
assert manifest.backend.entryPoints == ["my_extension.entrypoint"]
|
||||
|
||||
|
||||
def test_manifest_backend_no_files_field():
|
||||
"""Test ManifestBackend does not have files field (only in ExtensionConfig)."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
"backend": {"entryPoints": ["my_extension.entrypoint"]},
|
||||
}
|
||||
)
|
||||
# ManifestBackend should not have a 'files' field
|
||||
assert not hasattr(manifest.backend, "files")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared component tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_module_federation_config_defaults():
|
||||
"""Test ModuleFederationConfig has correct defaults."""
|
||||
config = ModuleFederationConfig.model_validate({})
|
||||
assert config.exposes == []
|
||||
assert config.filename == "remoteEntry.js"
|
||||
assert config.shared == {}
|
||||
assert config.remotes == {}
|
||||
|
||||
|
||||
def test_contribution_config_defaults():
|
||||
"""Test ContributionConfig has correct defaults."""
|
||||
config = ContributionConfig.model_validate({})
|
||||
assert config.commands == []
|
||||
assert config.views == {}
|
||||
assert config.menus == {}
|
||||
|
||||
|
||||
def test_extension_config_frontend_defaults():
|
||||
"""Test ExtensionConfigFrontend has correct defaults."""
|
||||
frontend = ExtensionConfigFrontend.model_validate({})
|
||||
assert frontend.contributions.commands == []
|
||||
assert frontend.moduleFederation.exposes == []
|
||||
|
||||
|
||||
def test_extension_config_backend_defaults():
|
||||
"""Test ExtensionConfigBackend has correct defaults."""
|
||||
backend = ExtensionConfigBackend.model_validate({})
|
||||
assert backend.entryPoints == []
|
||||
assert backend.files == []
|
||||
|
||||
|
||||
def test_manifest_backend_defaults():
|
||||
"""Test ManifestBackend has correct defaults."""
|
||||
backend = ManifestBackend.model_validate({})
|
||||
assert backend.entryPoints == []
|
||||
Reference in New Issue
Block a user