chore(extensions): clean up backend entrypoints and file globs (#38360)

This commit is contained in:
Ville Brofeldt
2026-03-03 09:45:35 -08:00
committed by GitHub
parent 016417f793
commit c35bf344a9
11 changed files with 426 additions and 117 deletions

View File

@@ -91,7 +91,7 @@ The `README.md` file provides documentation and instructions for using the exten
## Extension Metadata ## Extension Metadata
The `extension.json` file contains the metadata necessary for the host application to identify and load the extension. Backend contributions (entry points and files) are declared here. Frontend contributions are registered directly in code from `frontend/src/index.tsx`. The `extension.json` file contains the metadata necessary for the host application to identify and load the extension. Extensions follow a **convention-over-configuration** approach where entry points and build configuration are determined by standardized file locations rather than explicit declarations.
```json ```json
{ {
@@ -100,15 +100,36 @@ The `extension.json` file contains the metadata necessary for the host applicati
"displayName": "Dataset References", "displayName": "Dataset References",
"version": "1.0.0", "version": "1.0.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"backend": {
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
},
"permissions": [] "permissions": []
} }
``` ```
The `backend` section specifies Python entry points to load eagerly when the extension starts, and glob patterns for source files to include in the bundle. ### Convention-Based Entry Points
Extensions use standardized entry point locations:
- **Backend**: `backend/src/superset_extensions/{publisher}/{name}/entrypoint.py`
- **Frontend**: `frontend/src/index.tsx`
### Build Configuration
Backend build configuration is specified in `backend/pyproject.toml`:
```toml
[project]
name = "my_org-dataset_references"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
# Files to include in the extension build/bundle
include = [
"src/superset_extensions/my_org/dataset_references/**/*.py",
]
exclude = []
```
The `include` patterns specify which files to bundle, while `exclude` patterns can filter out unwanted files (e.g., test files, cache directories).
## Interacting with the Host ## Interacting with the Host

View File

@@ -87,10 +87,6 @@ class BaseExtension(BaseModel):
class ExtensionConfigBackend(BaseModel): class ExtensionConfigBackend(BaseModel):
"""Backend section in extension.json.""" """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( files: list[str] = Field(
default_factory=list, default_factory=list,
description="Glob patterns for backend Python files", description="Glob patterns for backend Python files",
@@ -131,10 +127,7 @@ class ManifestFrontend(BaseModel):
class ManifestBackend(BaseModel): class ManifestBackend(BaseModel):
"""Backend section in manifest.json.""" """Backend section in manifest.json."""
entryPoints: list[str] = Field( # noqa: N815 entrypoint: str
default_factory=list,
description="Python module entry points to load",
)
class Manifest(BaseExtension): class Manifest(BaseExtension):

View File

@@ -162,8 +162,13 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
) )
backend: ManifestBackend | None = None backend: ManifestBackend | None = None
if extension.backend and extension.backend.entryPoints: backend_dir = cwd / "backend"
backend = ManifestBackend(entryPoints=extension.backend.entryPoints) if backend_dir.exists():
# Generate conventional entry point
publisher_snake = kebab_to_snake_case(extension.publisher)
name_snake = kebab_to_snake_case(extension.name)
entrypoint = f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
backend = ManifestBackend(entrypoint=entrypoint)
return Manifest( return Manifest(
id=composite_id, id=composite_id,
@@ -217,17 +222,34 @@ def copy_frontend_dist(cwd: Path) -> str:
def copy_backend_files(cwd: Path) -> None: def copy_backend_files(cwd: Path) -> None:
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
dist_dir = cwd / "dist" dist_dir = cwd / "dist"
extension = read_json(cwd / "extension.json") backend_dir = cwd / "backend"
if not extension:
click.secho("❌ No extension.json file found.", err=True, fg="red")
sys.exit(1)
for pat in extension.get("backend", {}).get("files", []): # Read build config from pyproject.toml
for f in cwd.glob(pat): pyproject = read_toml(backend_dir / "pyproject.toml")
assert pyproject
build_config = (
pyproject.get("tool", {}).get("apache_superset_extensions", {}).get("build", {})
)
include_patterns = build_config.get("include", [])
exclude_patterns = build_config.get("exclude", [])
# Process include patterns
for pattern in include_patterns:
for f in backend_dir.glob(pattern):
if not f.is_file(): if not f.is_file():
continue continue
tgt = dist_dir / f.relative_to(cwd)
# Check exclude patterns
relative_path = f.relative_to(backend_dir)
should_exclude = any(
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns
)
if should_exclude:
continue
tgt = dist_dir / "backend" / relative_path
tgt.parent.mkdir(parents=True, exist_ok=True) tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, tgt) shutil.copy2(f, tgt)
@@ -272,6 +294,89 @@ def app() -> None:
def validate() -> None: def validate() -> None:
validate_npm() validate_npm()
cwd = Path.cwd()
# Validate extension.json exists and is valid
extension_data = read_json(cwd / "extension.json")
if not extension_data:
click.secho("❌ extension.json not found.", err=True, fg="red")
sys.exit(1)
try:
extension = ExtensionConfig.model_validate(extension_data)
except Exception as e:
click.secho(f"❌ Invalid extension.json: {e}", err=True, fg="red")
sys.exit(1)
# Validate conventional backend structure if backend directory exists
backend_dir = cwd / "backend"
if backend_dir.exists():
# Check for pyproject.toml
pyproject_path = backend_dir / "pyproject.toml"
if not pyproject_path.exists():
click.secho(
"❌ Backend directory exists but pyproject.toml not found",
err=True,
fg="red",
)
sys.exit(1)
# Validate pyproject.toml has build configuration
pyproject = read_toml(pyproject_path)
if not pyproject:
click.secho("❌ Failed to read backend pyproject.toml", err=True, fg="red")
sys.exit(1)
build_config = (
pyproject.get("tool", {})
.get("apache_superset_extensions", {})
.get("build", {})
)
if not build_config.get("include"):
click.secho(
"❌ Missing [tool.apache_superset_extensions.build] section with 'include' patterns in pyproject.toml",
err=True,
fg="red",
)
sys.exit(1)
# Check conventional backend entry point
publisher_snake = kebab_to_snake_case(extension.publisher)
name_snake = kebab_to_snake_case(extension.name)
expected_entry_file = (
backend_dir
/ "src"
/ "superset_extensions"
/ publisher_snake
/ name_snake
/ "entrypoint.py"
)
if not expected_entry_file.exists():
click.secho(
f"❌ Backend entry point not found at expected location: {expected_entry_file.relative_to(cwd)}",
err=True,
fg="red",
)
click.secho(
f" Convention requires: backend/src/superset_extensions/{publisher_snake}/{name_snake}/entrypoint.py",
fg="yellow",
)
sys.exit(1)
# Validate conventional frontend entry point if frontend directory exists
frontend_dir = cwd / "frontend"
if frontend_dir.exists():
expected_frontend_entry = frontend_dir / "src" / "index.tsx"
if not expected_frontend_entry.exists():
click.secho(
f"❌ Frontend entry point not found at expected location: {expected_frontend_entry.relative_to(cwd)}",
err=True,
fg="red",
)
click.secho(" Convention requires: frontend/src/index.tsx", fg="yellow")
sys.exit(1)
click.secho("✅ Validation successful", fg="green") click.secho("✅ Validation successful", fg="green")

View File

@@ -2,3 +2,10 @@
name = "{{ backend_package }}" name = "{{ backend_package }}"
version = "{{ version }}" version = "{{ version }}"
license = "{{ license }}" license = "{{ license }}"
[tool.apache_superset_extensions.build]
# Files to include in the extension build/bundle
include = [
"src/{{ backend_path|replace('.', '/') }}/**/*.py",
]
exclude = []

View File

@@ -4,11 +4,5 @@
"displayName": "{{ display_name }}", "displayName": "{{ display_name }}",
"version": "{{ version }}", "version": "{{ version }}",
"license": "{{ license }}", "license": "{{ license }}",
{% if include_backend -%}
"backend": {
"entryPoints": ["{{ backend_entry }}"],
"files": ["backend/src/{{ backend_path|replace('.', '/') }}/**/*.py"]
},
{% endif -%}
"permissions": [] "permissions": []
} }

View File

@@ -46,10 +46,50 @@ def extension_with_build_structure():
frontend_dir = base_path / "frontend" frontend_dir = base_path / "frontend"
frontend_dir.mkdir() frontend_dir.mkdir()
# Create conventional frontend entry point
frontend_src_dir = frontend_dir / "src"
frontend_src_dir.mkdir()
(frontend_src_dir / "index.tsx").write_text("// Frontend entry point")
if include_backend: if include_backend:
backend_dir = base_path / "backend" backend_dir = base_path / "backend"
backend_dir.mkdir() backend_dir.mkdir()
# Create conventional backend structure
backend_src_dir = (
backend_dir
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_extension"
)
backend_src_dir.mkdir(parents=True)
# Create conventional entry point file
(backend_src_dir / "entrypoint.py").write_text("# Backend entry point")
(backend_src_dir / "__init__.py").write_text("")
# Create parent __init__.py files for namespace packages
(backend_dir / "src" / "superset_extensions" / "__init__.py").write_text("")
(
backend_dir / "src" / "superset_extensions" / "test_org" / "__init__.py"
).write_text("")
# Create pyproject.toml matching the template structure
pyproject_content = """[project]
name = "test_org-test_extension"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
# Files to include in the extension build/bundle
include = [
"src/superset_extensions/test_org/test_extension/**/*.py",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
# Create extension.json # Create extension.json
extension_json = { extension_json = {
"publisher": "test-org", "publisher": "test-org",
@@ -59,13 +99,6 @@ def extension_with_build_structure():
"permissions": [], "permissions": [],
} }
if include_backend:
extension_json["backend"] = {
"entryPoints": [
"superset_extensions.test_org.test_extension.entrypoint"
]
}
(base_path / "extension.json").write_text(json.dumps(extension_json)) (base_path / "extension.json").write_text(json.dumps(extension_json))
return { return {
@@ -96,7 +129,18 @@ def test_build_command_success_flow(
"""Test build command success flow.""" """Test build command success flow."""
# Setup mocks # Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_read_toml.return_value = {"project": {"name": "test"}} mock_read_toml.return_value = {
"project": {"name": "test"},
"tool": {
"apache_superset_extensions": {
"build": {
"include": [
"src/superset_extensions/test_org/test_extension/**/*.py"
]
}
}
},
}
# Create extension structure # Create extension structure
dirs = extension_with_build_structure(isolated_filesystem) dirs = extension_with_build_structure(isolated_filesystem)
@@ -117,7 +161,9 @@ def test_build_command_success_flow(
@patch("superset_extensions_cli.cli.validate_npm") @patch("superset_extensions_cli.cli.validate_npm")
@patch("superset_extensions_cli.cli.init_frontend_deps") @patch("superset_extensions_cli.cli.init_frontend_deps")
@patch("superset_extensions_cli.cli.rebuild_frontend") @patch("superset_extensions_cli.cli.rebuild_frontend")
@patch("superset_extensions_cli.cli.read_toml")
def test_build_command_handles_frontend_build_failure( def test_build_command_handles_frontend_build_failure(
mock_read_toml,
mock_rebuild_frontend, mock_rebuild_frontend,
mock_init_frontend_deps, mock_init_frontend_deps,
mock_validate_npm, mock_validate_npm,
@@ -128,6 +174,18 @@ def test_build_command_handles_frontend_build_failure(
"""Test build command handles frontend build failure.""" """Test build command handles frontend build failure."""
# Setup mocks # Setup mocks
mock_rebuild_frontend.return_value = None # Indicates failure mock_rebuild_frontend.return_value = None # Indicates failure
mock_read_toml.return_value = {
"project": {"name": "test"},
"tool": {
"apache_superset_extensions": {
"build": {
"include": [
"src/superset_extensions/test_org/test_extension/**/*.py"
]
}
}
},
}
# Create extension structure # Create extension structure
extension_with_build_structure(isolated_filesystem) extension_with_build_structure(isolated_filesystem)
@@ -225,9 +283,16 @@ def test_init_frontend_deps_exits_on_npm_ci_failure(
# Build Manifest Tests # Build Manifest Tests
@pytest.mark.unit @pytest.mark.unit
def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): def test_build_manifest_creates_correct_manifest_structure(
isolated_filesystem, extension_with_build_structure
):
"""Test build_manifest creates correct manifest from extension.json.""" """Test build_manifest creates correct manifest from extension.json."""
# Create extension.json # Create extension structure with both frontend and backend
extension_with_build_structure(
isolated_filesystem, include_frontend=True, include_backend=True
)
# Update extension.json with additional fields
extension_data = { extension_data = {
"publisher": "test-org", "publisher": "test-org",
"name": "test-extension", "name": "test-extension",
@@ -235,9 +300,6 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
"version": "1.0.0", "version": "1.0.0",
"permissions": ["read_data"], "permissions": ["read_data"],
"dependencies": ["some_dep"], "dependencies": ["some_dep"],
"backend": {
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"]
},
} }
extension_json = isolated_filesystem / "extension.json" extension_json = isolated_filesystem / "extension.json"
extension_json.write_text(json.dumps(extension_data)) extension_json.write_text(json.dumps(extension_data))
@@ -258,11 +320,12 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js" assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
assert manifest.frontend.moduleFederationName == "testOrg_testExtension" assert manifest.frontend.moduleFederationName == "testOrg_testExtension"
# Verify backend section # Verify backend section and conventional entrypoint
assert manifest.backend is not None assert manifest.backend is not None
assert manifest.backend.entryPoints == [ assert (
"superset_extensions.test_org.test_extension.entrypoint" manifest.backend.entrypoint
] == "superset_extensions.test_org.test_extension.entrypoint"
)
@pytest.mark.unit @pytest.mark.unit
@@ -413,7 +476,8 @@ def test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem):
def test_copy_backend_files_skips_non_files(isolated_filesystem): def test_copy_backend_files_skips_non_files(isolated_filesystem):
"""Test copy_backend_files skips directories and non-files.""" """Test copy_backend_files skips directories and non-files."""
# Create backend structure with directory # Create backend structure with directory
backend_src = isolated_filesystem / "backend" / "src" / "test_ext" backend_dir = isolated_filesystem / "backend"
backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "test_ext"
backend_src.mkdir(parents=True) backend_src.mkdir(parents=True)
(backend_src / "__init__.py").write_text("# init") (backend_src / "__init__.py").write_text("# init")
@@ -421,16 +485,27 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
subdir = backend_src / "subdir" subdir = backend_src / "subdir"
subdir.mkdir() subdir.mkdir()
# Create extension.json with backend file patterns # Create pyproject.toml with build configuration
pyproject_content = """[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"src/superset_extensions/test_org/test_ext/**/*",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
# Create extension.json
extension_data = { extension_data = {
"publisher": "test-org", "publisher": "test-org",
"name": "test-ext", "name": "test-ext",
"displayName": "Test Extension", "displayName": "Test Extension",
"version": "1.0.0", "version": "1.0.0",
"permissions": [], "permissions": [],
"backend": {
"files": ["backend/src/test_ext/**/*"] # Will match both files and dirs
},
} }
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
@@ -441,10 +516,26 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
# Verify only files were copied, not directories # Verify only files were copied, not directories
dist_dir = isolated_filesystem / "dist" dist_dir = isolated_filesystem / "dist"
assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "__init__.py"
)
# Directory should not be copied as a file # Directory should not be copied as a file
copied_subdir = dist_dir / "backend" / "src" / "test_ext" / "subdir" copied_subdir = (
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "subdir"
)
# The directory might exist but should be empty since we skip non-files # The directory might exist but should be empty since we skip non-files
if copied_subdir.exists(): if copied_subdir.exists():
assert list(copied_subdir.iterdir()) == [] assert list(copied_subdir.iterdir()) == []
@@ -452,21 +543,35 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
@pytest.mark.unit @pytest.mark.unit
def test_copy_backend_files_copies_matched_files(isolated_filesystem): def test_copy_backend_files_copies_matched_files(isolated_filesystem):
"""Test copy_backend_files copies files matching patterns from extension.json.""" """Test copy_backend_files copies files matching patterns from pyproject.toml."""
# Create backend source files # Create backend source files
backend_src = isolated_filesystem / "backend" / "src" / "test_ext" backend_dir = isolated_filesystem / "backend"
backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "test_ext"
backend_src.mkdir(parents=True) backend_src.mkdir(parents=True)
(backend_src / "__init__.py").write_text("# init") (backend_src / "__init__.py").write_text("# init")
(backend_src / "main.py").write_text("# main") (backend_src / "main.py").write_text("# main")
# Create extension.json with backend file patterns # Create pyproject.toml with build configuration
pyproject_content = """[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"src/superset_extensions/test_org/test_ext/**/*.py",
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
# Create extension.json
extension_data = { extension_data = {
"publisher": "test-org", "publisher": "test-org",
"name": "test-ext", "name": "test-ext",
"displayName": "Test Extension", "displayName": "Test Extension",
"version": "1.0.0", "version": "1.0.0",
"permissions": [], "permissions": [],
"backend": {"files": ["backend/src/test_ext/**/*.py"]},
} }
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
@@ -477,37 +582,117 @@ def test_copy_backend_files_copies_matched_files(isolated_filesystem):
# Verify files were copied # Verify files were copied
dist_dir = isolated_filesystem / "dist" dist_dir = isolated_filesystem / "dist"
assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") assert_file_exists(
assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "main.py") dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "__init__.py"
)
assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "main.py"
)
@pytest.mark.unit @pytest.mark.unit
def test_copy_backend_files_handles_no_backend_config(isolated_filesystem): def test_copy_backend_files_handles_various_glob_patterns(isolated_filesystem):
"""Test copy_backend_files handles extension.json without backend config.""" """Test copy_backend_files correctly handles different glob pattern formats."""
# Create backend structure with files in different locations
backend_dir = isolated_filesystem / "backend"
backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "test_ext"
backend_src.mkdir(parents=True)
# Create files that should match different pattern types
(backend_src / "__init__.py").write_text("# init")
(backend_src / "main.py").write_text("# main")
(backend_dir / "config.py").write_text("# config") # Root level file
# Create subdirectory with files
subdir = backend_src / "utils"
subdir.mkdir()
(subdir / "helper.py").write_text("# helper")
# Create pyproject.toml with various glob patterns that would fail with old logic
pyproject_content = """[project]
name = "test_org-test_ext"
version = "1.0.0"
license = "Apache-2.0"
[tool.apache_superset_extensions.build]
include = [
"config.py", # No '/' - would break old logic
"**/*.py", # Starts with '**' - would break old logic
"src/superset_extensions/test_org/test_ext/main.py", # Specific file
]
exclude = []
"""
(backend_dir / "pyproject.toml").write_text(pyproject_content)
# Create extension.json
extension_data = { extension_data = {
"publisher": "frontend-org", "publisher": "test-org",
"name": "frontend-only", "name": "test-ext",
"displayName": "Frontend Only Extension", "displayName": "Test Extension",
"version": "1.0.0", "version": "1.0.0",
"permissions": [], "permissions": [],
} }
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
# Create dist directory
clean_dist(isolated_filesystem) clean_dist(isolated_filesystem)
# Should not raise error
copy_backend_files(isolated_filesystem) copy_backend_files(isolated_filesystem)
# Verify files were copied according to patterns
dist_dir = isolated_filesystem / "dist"
@pytest.mark.unit # config.py (pattern: "config.py")
def test_copy_backend_files_exits_when_extension_json_missing(isolated_filesystem): assert_file_exists(dist_dir / "backend" / "config.py")
"""Test copy_backend_files exits when extension.json is missing."""
clean_dist(isolated_filesystem)
with pytest.raises(SystemExit) as exc_info: # All .py files should be included (pattern: "**/*.py")
copy_backend_files(isolated_filesystem) assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "__init__.py"
)
assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "utils"
/ "helper.py"
)
assert exc_info.value.code == 1 # Specific file (pattern: "src/superset_extensions/test_org/test_ext/main.py")
assert_file_exists(
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_ext"
/ "main.py"
)
# Removed obsolete tests:
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called
# Frontend Dist Copy Tests # Frontend Dist Copy Tests

View File

@@ -226,17 +226,8 @@ def test_extension_json_content_is_correct(
# Verify frontend section is not present (contributions are code-first) # Verify frontend section is not present (contributions are code-first)
assert "frontend" not in content assert "frontend" not in content
# Verify backend section exists and has correct structure # Verify no backend section in extension.json (moved to pyproject.toml)
assert "backend" in content assert "backend" not in content
backend = content["backend"]
assert "entryPoints" in backend
assert "files" in backend
assert backend["entryPoints"] == [
"superset_extensions.test_org.test_extension.entrypoint"
]
assert backend["files"] == [
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@pytest.mark.cli @pytest.mark.cli

View File

@@ -25,8 +25,20 @@ from superset_extensions_cli.cli import app, validate_npm
# Validate Command Tests # Validate Command Tests
@pytest.mark.cli @pytest.mark.cli
def test_validate_command_success(cli_runner): def test_validate_command_success(cli_runner, isolated_filesystem):
"""Test validate command succeeds when npm is available and valid.""" """Test validate command succeeds when npm is available and valid."""
# Create minimal extension.json for validation
extension_json = {
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
import json
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_json))
with patch("superset_extensions_cli.cli.validate_npm") as mock_validate: with patch("superset_extensions_cli.cli.validate_npm") as mock_validate:
result = cli_runner.invoke(app, ["validate"]) result = cli_runner.invoke(app, ["validate"])

View File

@@ -81,15 +81,8 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
# Verify frontend section is not present (contributions are code-first) # Verify frontend section is not present (contributions are code-first)
assert "frontend" not in parsed assert "frontend" not in parsed
# Verify backend section exists # Verify no backend section in extension.json (moved to pyproject.toml)
assert "backend" in parsed assert "backend" not in parsed
backend = parsed["backend"]
assert backend["entryPoints"] == [
"superset_extensions.test_org.test_extension.entrypoint"
]
assert backend["files"] == [
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@pytest.mark.unit @pytest.mark.unit
@@ -97,7 +90,7 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
"include_frontend,include_backend,expected_sections", "include_frontend,include_backend,expected_sections",
[ [
(True, False, []), (True, False, []),
(False, True, ["backend"]), (False, True, []),
(False, False, []), (False, False, []),
], ],
) )
@@ -220,12 +213,7 @@ def test_template_rendering_with_different_ids(
assert parsed["publisher"] == publisher assert parsed["publisher"] == publisher
assert parsed["name"] == technical_name assert parsed["name"] == technical_name
assert parsed["displayName"] == display_name assert parsed["displayName"] == display_name
assert parsed["backend"]["entryPoints"] == [ assert "backend" not in parsed
f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
]
assert parsed["backend"]["files"] == [
f"backend/src/superset_extensions/{publisher_snake}/{name_snake}/**/*.py"
]
# Test package.json template # Test package.json template
template = jinja_env.get_template("frontend/package.json.j2") template = jinja_env.get_template("frontend/package.json.j2")

View File

@@ -587,13 +587,12 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
backend = extension.manifest.backend backend = extension.manifest.backend
if backend and (entrypoints := backend.entryPoints): if backend and backend.entrypoint:
for entrypoint in entrypoints: try:
try: eager_import(backend.entrypoint)
eager_import(entrypoint) except Exception as ex: # pylint: disable=broad-except # noqa: S110
except Exception as ex: # pylint: disable=broad-except # noqa: S110 # Surface exceptions during initialization of extensions
# Surface exceptions during initialization of extensions print(ex)
print(ex)
def init_app_in_ctx(self) -> None: def init_app_in_ctx(self) -> None:
""" """

View File

@@ -62,7 +62,6 @@ def test_extension_config_full():
"dependencies": ["other-extension"], "dependencies": ["other-extension"],
"permissions": ["can_read", "can_view"], "permissions": ["can_read", "can_view"],
"backend": { "backend": {
"entryPoints": ["query_insights.entrypoint"],
"files": ["backend/src/query_insights/**/*.py"], "files": ["backend/src/query_insights/**/*.py"],
}, },
} }
@@ -76,7 +75,6 @@ def test_extension_config_full():
assert config.dependencies == ["other-extension"] assert config.dependencies == ["other-extension"]
assert config.permissions == ["can_read", "can_view"] assert config.permissions == ["can_read", "can_view"]
assert config.backend is not None assert config.backend is not None
assert config.backend.entryPoints == ["query_insights.entrypoint"]
assert config.backend.files == ["backend/src/query_insights/**/*.py"] assert config.backend.files == ["backend/src/query_insights/**/*.py"]
@@ -221,11 +219,16 @@ def test_manifest_with_backend():
"publisher": "my-org", "publisher": "my-org",
"name": "my-extension", "name": "my-extension",
"displayName": "My Extension", "displayName": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]}, "backend": {
"entrypoint": "superset_extensions.my_org.my_extension.entrypoint"
},
} }
) )
assert manifest.backend is not None assert manifest.backend is not None
assert manifest.backend.entryPoints == ["my_extension.entrypoint"] assert (
manifest.backend.entrypoint
== "superset_extensions.my_org.my_extension.entrypoint"
)
def test_manifest_backend_no_files_field(): def test_manifest_backend_no_files_field():
@@ -236,7 +239,9 @@ def test_manifest_backend_no_files_field():
"publisher": "my-org", "publisher": "my-org",
"name": "my-extension", "name": "my-extension",
"displayName": "My Extension", "displayName": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]}, "backend": {
"entrypoint": "superset_extensions.my_org.my_extension.entrypoint"
},
} }
) )
# ManifestBackend should not have a 'files' field # ManifestBackend should not have a 'files' field
@@ -246,11 +251,20 @@ def test_manifest_backend_no_files_field():
def test_extension_config_backend_defaults(): def test_extension_config_backend_defaults():
"""Test ExtensionConfigBackend has correct defaults.""" """Test ExtensionConfigBackend has correct defaults."""
backend = ExtensionConfigBackend.model_validate({}) backend = ExtensionConfigBackend.model_validate({})
assert backend.entryPoints == []
assert backend.files == [] assert backend.files == []
def test_manifest_backend_defaults(): def test_manifest_backend_required_entrypoint():
"""Test ManifestBackend has correct defaults.""" """Test ManifestBackend requires entrypoint field."""
backend = ManifestBackend.model_validate({}) # Test positive case - entrypoint provided
assert backend.entryPoints == [] backend = ManifestBackend.model_validate(
{"entrypoint": "superset_extensions.test_org.test_extension.entrypoint"}
)
assert (
backend.entrypoint == "superset_extensions.test_org.test_extension.entrypoint"
)
# Test negative case - entrypoint missing should raise ValidationError
with pytest.raises(ValidationError) as exc_info:
ManifestBackend.model_validate({})
assert "entrypoint" in str(exc_info.value)