mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
chore(extensions): clean up backend entrypoints and file globs (#38360)
This commit is contained in:
@@ -91,7 +91,7 @@ The `README.md` file provides documentation and instructions for using the exten
|
||||
|
||||
## 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
|
||||
{
|
||||
@@ -100,15 +100,36 @@ The `extension.json` file contains the metadata necessary for the host applicati
|
||||
"displayName": "Dataset References",
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"backend": {
|
||||
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
|
||||
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
|
||||
},
|
||||
"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
|
||||
|
||||
|
||||
@@ -87,10 +87,6 @@ class BaseExtension(BaseModel):
|
||||
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",
|
||||
@@ -131,10 +127,7 @@ class ManifestFrontend(BaseModel):
|
||||
class ManifestBackend(BaseModel):
|
||||
"""Backend section in manifest.json."""
|
||||
|
||||
entryPoints: list[str] = Field( # noqa: N815
|
||||
default_factory=list,
|
||||
description="Python module entry points to load",
|
||||
)
|
||||
entrypoint: str
|
||||
|
||||
|
||||
class Manifest(BaseExtension):
|
||||
|
||||
@@ -162,8 +162,13 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
|
||||
)
|
||||
|
||||
backend: ManifestBackend | None = None
|
||||
if extension.backend and extension.backend.entryPoints:
|
||||
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
|
||||
backend_dir = cwd / "backend"
|
||||
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(
|
||||
id=composite_id,
|
||||
@@ -217,17 +222,34 @@ def copy_frontend_dist(cwd: Path) -> str:
|
||||
|
||||
|
||||
def copy_backend_files(cwd: Path) -> None:
|
||||
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
|
||||
dist_dir = cwd / "dist"
|
||||
extension = read_json(cwd / "extension.json")
|
||||
if not extension:
|
||||
click.secho("❌ No extension.json file found.", err=True, fg="red")
|
||||
sys.exit(1)
|
||||
backend_dir = cwd / "backend"
|
||||
|
||||
for pat in extension.get("backend", {}).get("files", []):
|
||||
for f in cwd.glob(pat):
|
||||
# Read build config from pyproject.toml
|
||||
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():
|
||||
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)
|
||||
shutil.copy2(f, tgt)
|
||||
|
||||
@@ -272,6 +294,89 @@ def app() -> None:
|
||||
def validate() -> None:
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -2,3 +2,10 @@
|
||||
name = "{{ backend_package }}"
|
||||
version = "{{ version }}"
|
||||
license = "{{ license }}"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
# Files to include in the extension build/bundle
|
||||
include = [
|
||||
"src/{{ backend_path|replace('.', '/') }}/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
@@ -4,11 +4,5 @@
|
||||
"displayName": "{{ display_name }}",
|
||||
"version": "{{ version }}",
|
||||
"license": "{{ license }}",
|
||||
{% if include_backend -%}
|
||||
"backend": {
|
||||
"entryPoints": ["{{ backend_entry }}"],
|
||||
"files": ["backend/src/{{ backend_path|replace('.', '/') }}/**/*.py"]
|
||||
},
|
||||
{% endif -%}
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
@@ -46,10 +46,50 @@ def extension_with_build_structure():
|
||||
frontend_dir = base_path / "frontend"
|
||||
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:
|
||||
backend_dir = base_path / "backend"
|
||||
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
|
||||
extension_json = {
|
||||
"publisher": "test-org",
|
||||
@@ -59,13 +99,6 @@ def extension_with_build_structure():
|
||||
"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))
|
||||
|
||||
return {
|
||||
@@ -96,7 +129,18 @@ def test_build_command_success_flow(
|
||||
"""Test build command success flow."""
|
||||
# Setup mocks
|
||||
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
|
||||
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.init_frontend_deps")
|
||||
@patch("superset_extensions_cli.cli.rebuild_frontend")
|
||||
@patch("superset_extensions_cli.cli.read_toml")
|
||||
def test_build_command_handles_frontend_build_failure(
|
||||
mock_read_toml,
|
||||
mock_rebuild_frontend,
|
||||
mock_init_frontend_deps,
|
||||
mock_validate_npm,
|
||||
@@ -128,6 +174,18 @@ def test_build_command_handles_frontend_build_failure(
|
||||
"""Test build command handles frontend build failure."""
|
||||
# Setup mocks
|
||||
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
|
||||
extension_with_build_structure(isolated_filesystem)
|
||||
@@ -225,9 +283,16 @@ def test_init_frontend_deps_exits_on_npm_ci_failure(
|
||||
|
||||
# Build Manifest Tests
|
||||
@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."""
|
||||
# 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 = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
@@ -235,9 +300,6 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
|
||||
"version": "1.0.0",
|
||||
"permissions": ["read_data"],
|
||||
"dependencies": ["some_dep"],
|
||||
"backend": {
|
||||
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"]
|
||||
},
|
||||
}
|
||||
extension_json = isolated_filesystem / "extension.json"
|
||||
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.moduleFederationName == "testOrg_testExtension"
|
||||
|
||||
# Verify backend section
|
||||
# Verify backend section and conventional entrypoint
|
||||
assert manifest.backend is not None
|
||||
assert manifest.backend.entryPoints == [
|
||||
"superset_extensions.test_org.test_extension.entrypoint"
|
||||
]
|
||||
assert (
|
||||
manifest.backend.entrypoint
|
||||
== "superset_extensions.test_org.test_extension.entrypoint"
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
"""Test copy_backend_files skips directories and non-files."""
|
||||
# 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 / "__init__.py").write_text("# init")
|
||||
|
||||
@@ -421,16 +485,27 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
|
||||
subdir = backend_src / "subdir"
|
||||
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 = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
"backend": {
|
||||
"files": ["backend/src/test_ext/**/*"] # Will match both files and dirs
|
||||
},
|
||||
}
|
||||
(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
|
||||
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
|
||||
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
|
||||
if copied_subdir.exists():
|
||||
assert list(copied_subdir.iterdir()) == []
|
||||
@@ -452,21 +543,35 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
|
||||
|
||||
@pytest.mark.unit
|
||||
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
|
||||
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 / "__init__.py").write_text("# init")
|
||||
(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 = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
"backend": {"files": ["backend/src/test_ext/**/*.py"]},
|
||||
}
|
||||
(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
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py")
|
||||
assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "main.py")
|
||||
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"
|
||||
/ "main.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_handles_no_backend_config(isolated_filesystem):
|
||||
"""Test copy_backend_files handles extension.json without backend config."""
|
||||
def test_copy_backend_files_handles_various_glob_patterns(isolated_filesystem):
|
||||
"""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 = {
|
||||
"publisher": "frontend-org",
|
||||
"name": "frontend-only",
|
||||
"displayName": "Frontend Only Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
# Create dist directory
|
||||
clean_dist(isolated_filesystem)
|
||||
|
||||
# Should not raise error
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
# Verify files were copied according to patterns
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_exits_when_extension_json_missing(isolated_filesystem):
|
||||
"""Test copy_backend_files exits when extension.json is missing."""
|
||||
clean_dist(isolated_filesystem)
|
||||
# config.py (pattern: "config.py")
|
||||
assert_file_exists(dist_dir / "backend" / "config.py")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
copy_backend_files(isolated_filesystem)
|
||||
# All .py files should be included (pattern: "**/*.py")
|
||||
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
|
||||
|
||||
@@ -226,17 +226,8 @@ def test_extension_json_content_is_correct(
|
||||
# Verify frontend section is not present (contributions are code-first)
|
||||
assert "frontend" not in content
|
||||
|
||||
# Verify backend section exists and has correct structure
|
||||
assert "backend" 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"
|
||||
]
|
||||
# Verify no backend section in extension.json (moved to pyproject.toml)
|
||||
assert "backend" not in content
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
|
||||
@@ -25,8 +25,20 @@ from superset_extensions_cli.cli import app, validate_npm
|
||||
|
||||
# Validate Command Tests
|
||||
@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."""
|
||||
# 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:
|
||||
result = cli_runner.invoke(app, ["validate"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
assert "frontend" not in parsed
|
||||
|
||||
# Verify backend section exists
|
||||
assert "backend" 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"
|
||||
]
|
||||
# Verify no backend section in extension.json (moved to pyproject.toml)
|
||||
assert "backend" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -97,7 +90,7 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
|
||||
"include_frontend,include_backend,expected_sections",
|
||||
[
|
||||
(True, False, []),
|
||||
(False, True, ["backend"]),
|
||||
(False, True, []),
|
||||
(False, False, []),
|
||||
],
|
||||
)
|
||||
@@ -220,12 +213,7 @@ def test_template_rendering_with_different_ids(
|
||||
assert parsed["publisher"] == publisher
|
||||
assert parsed["name"] == technical_name
|
||||
assert parsed["displayName"] == display_name
|
||||
assert parsed["backend"]["entryPoints"] == [
|
||||
f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
|
||||
]
|
||||
assert parsed["backend"]["files"] == [
|
||||
f"backend/src/superset_extensions/{publisher_snake}/{name_snake}/**/*.py"
|
||||
]
|
||||
assert "backend" not in parsed
|
||||
|
||||
# Test package.json template
|
||||
template = jinja_env.get_template("frontend/package.json.j2")
|
||||
|
||||
@@ -587,13 +587,12 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
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)
|
||||
if backend and backend.entrypoint:
|
||||
try:
|
||||
eager_import(backend.entrypoint)
|
||||
except Exception as ex: # pylint: disable=broad-except # noqa: S110
|
||||
# Surface exceptions during initialization of extensions
|
||||
print(ex)
|
||||
|
||||
def init_app_in_ctx(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -62,7 +62,6 @@ def test_extension_config_full():
|
||||
"dependencies": ["other-extension"],
|
||||
"permissions": ["can_read", "can_view"],
|
||||
"backend": {
|
||||
"entryPoints": ["query_insights.entrypoint"],
|
||||
"files": ["backend/src/query_insights/**/*.py"],
|
||||
},
|
||||
}
|
||||
@@ -76,7 +75,6 @@ def test_extension_config_full():
|
||||
assert config.dependencies == ["other-extension"]
|
||||
assert config.permissions == ["can_read", "can_view"]
|
||||
assert config.backend is not None
|
||||
assert config.backend.entryPoints == ["query_insights.entrypoint"]
|
||||
assert config.backend.files == ["backend/src/query_insights/**/*.py"]
|
||||
|
||||
|
||||
@@ -221,11 +219,16 @@ def test_manifest_with_backend():
|
||||
"publisher": "my-org",
|
||||
"name": "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.entryPoints == ["my_extension.entrypoint"]
|
||||
assert (
|
||||
manifest.backend.entrypoint
|
||||
== "superset_extensions.my_org.my_extension.entrypoint"
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_backend_no_files_field():
|
||||
@@ -236,7 +239,9 @@ def test_manifest_backend_no_files_field():
|
||||
"publisher": "my-org",
|
||||
"name": "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
|
||||
@@ -246,11 +251,20 @@ def test_manifest_backend_no_files_field():
|
||||
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 == []
|
||||
def test_manifest_backend_required_entrypoint():
|
||||
"""Test ManifestBackend requires entrypoint field."""
|
||||
# Test positive case - entrypoint provided
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user