mirror of
https://github.com/apache/superset.git
synced 2026-04-09 19:35:21 +00:00
225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import importlib.abc
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Generator, Iterable, Tuple
|
|
from zipfile import ZipFile
|
|
|
|
from flask import current_app
|
|
from superset_core.extensions.types import Manifest
|
|
|
|
from superset.extensions.types import BundleFile, LoadedExtension
|
|
from superset.utils import json
|
|
from superset.utils.core import check_is_safe_zip
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
|
|
BACKEND_REGEX = re.compile(r"^backend/src/(.+)$")
|
|
|
|
|
|
class InMemoryLoader(importlib.abc.Loader):
|
|
def __init__(
|
|
self, module_name: str, source: str, is_package: bool, origin: str
|
|
) -> None:
|
|
self.module_name = module_name
|
|
self.source = source
|
|
self.is_package = is_package
|
|
self.origin = origin
|
|
|
|
def exec_module(self, module: Any) -> None:
|
|
module.__file__ = self.origin
|
|
module.__package__ = (
|
|
self.module_name if self.is_package else self.module_name.rpartition(".")[0]
|
|
)
|
|
if self.is_package:
|
|
module.__path__ = []
|
|
exec(self.source, module.__dict__) # noqa: S102
|
|
|
|
|
|
class InMemoryFinder(importlib.abc.MetaPathFinder):
|
|
def __init__(self, file_dict: dict[str, bytes]) -> None:
|
|
self.modules: dict[str, Tuple[Any, Any, Any]] = {}
|
|
for path, content in file_dict.items():
|
|
mod_name, is_package = self._get_module_name(path)
|
|
self.modules[mod_name] = (content, is_package, path)
|
|
|
|
def _get_module_name(self, file_path: str) -> Tuple[str, bool]:
|
|
parts = list(Path(file_path).parts)
|
|
is_package = parts[-1] == "__init__.py"
|
|
if is_package:
|
|
parts = parts[:-1]
|
|
else:
|
|
parts[-1] = Path(parts[-1]).stem
|
|
|
|
mod_name = ".".join(parts)
|
|
return mod_name, is_package
|
|
|
|
def find_spec(self, fullname: str, path: Any, target: Any = None) -> Any | None:
|
|
if fullname in self.modules:
|
|
source, is_package, origin = self.modules[fullname]
|
|
return importlib.util.spec_from_loader(
|
|
fullname,
|
|
InMemoryLoader(fullname, source, is_package, origin),
|
|
origin=origin,
|
|
is_package=is_package,
|
|
)
|
|
return None
|
|
|
|
|
|
def install_in_memory_importer(file_dict: dict[str, bytes]) -> None:
|
|
finder = InMemoryFinder(file_dict)
|
|
sys.meta_path.insert(0, finder)
|
|
|
|
|
|
def eager_import(module_name: str) -> Any:
|
|
if module_name in sys.modules:
|
|
return sys.modules[module_name]
|
|
return importlib.import_module(module_name)
|
|
|
|
|
|
def get_bundle_files_from_zip(zip_file: ZipFile) -> Generator[BundleFile, None, None]:
|
|
check_is_safe_zip(zip_file)
|
|
for name in zip_file.namelist():
|
|
content = zip_file.read(name)
|
|
yield BundleFile(name=name, content=content)
|
|
|
|
|
|
def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, None]:
|
|
dist_path = os.path.join(base_path, "dist")
|
|
|
|
if not os.path.isdir(dist_path):
|
|
raise Exception("Expected directory %s does not exist." % dist_path)
|
|
|
|
for root, _, files in os.walk(dist_path):
|
|
for file in files:
|
|
full_path = os.path.join(root, file)
|
|
rel_path = os.path.relpath(full_path, dist_path).replace(os.sep, "/")
|
|
with open(full_path, "rb") as f:
|
|
content = f.read()
|
|
yield BundleFile(name=rel_path, content=content)
|
|
|
|
|
|
def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
|
|
manifest: Manifest = {}
|
|
frontend: dict[str, bytes] = {}
|
|
backend: dict[str, bytes] = {}
|
|
|
|
for file in files:
|
|
filename = file.name
|
|
content = file.content
|
|
|
|
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")
|
|
except Exception as e:
|
|
raise Exception("Invalid manifest.json: %s" % e) from e
|
|
|
|
elif (match := FRONTEND_REGEX.match(filename)) is not None:
|
|
frontend[match.group(1)] = content
|
|
|
|
elif (match := BACKEND_REGEX.match(filename)) is not None:
|
|
backend[match.group(1)] = content
|
|
|
|
else:
|
|
raise Exception("Unexpected file in bundle: %s" % filename)
|
|
|
|
id_ = manifest["id"]
|
|
name = manifest["name"]
|
|
version = manifest["version"]
|
|
return LoadedExtension(
|
|
id=id_,
|
|
name=name,
|
|
manifest=manifest,
|
|
frontend=frontend,
|
|
backend=backend,
|
|
version=version,
|
|
)
|
|
|
|
|
|
def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
|
manifest: Manifest = extension.manifest
|
|
extension_data: dict[str, Any] = {
|
|
"id": manifest["id"],
|
|
"name": extension.name,
|
|
"version": extension.version,
|
|
"description": manifest.get("description", ""),
|
|
"dependencies": manifest.get("dependencies", []),
|
|
"extensionDependencies": manifest.get("extensionDependencies", []),
|
|
}
|
|
if frontend := manifest.get("frontend"):
|
|
module_federation = frontend.get("moduleFederation", {})
|
|
remote_entry = 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", {}),
|
|
}
|
|
)
|
|
return extension_data
|
|
|
|
|
|
def get_extensions() -> dict[str, LoadedExtension]:
|
|
extensions: dict[str, LoadedExtension] = {}
|
|
|
|
# Load extensions from LOCAL_EXTENSIONS configuration (filesystem paths)
|
|
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"]
|
|
extensions[extension_id] = extension
|
|
logger.info(
|
|
"Loading extension %s (ID: %s) from local filesystem",
|
|
extension.name,
|
|
extension_id,
|
|
)
|
|
|
|
# Load extensions from discovery path (.supx files)
|
|
if extensions_path := current_app.config.get("EXTENSIONS_PATH"):
|
|
from superset.extensions.discovery import discover_and_load_extensions
|
|
|
|
for extension in discover_and_load_extensions(extensions_path):
|
|
extension_id = extension.manifest["id"]
|
|
if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS
|
|
extensions[extension_id] = extension
|
|
logger.info(
|
|
"Loading extension %s (ID: %s) from discovery path",
|
|
extension.name,
|
|
extension_id,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"Extension %s (ID: %s) already loaded from LOCAL_EXTENSIONS, "
|
|
"skipping discovery version",
|
|
extension.name,
|
|
extension_id,
|
|
)
|
|
|
|
return extensions
|