This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch sl-1-extension-loading in repository https://gitbox.apache.org/repos/asf/superset.git
commit 7cb7988e80b9f9b68ee7a45c83151ab221b0d52a Author: Beto Dealmeida <[email protected]> AuthorDate: Mon Feb 2 17:23:53 2026 -0500 feat(extensions): improve extension loading with Pydantic compatibility - Add Pydantic v1/v2 compatibility for manifest validation - Simplify extension initialization in SupersetAppInitializer - Fix docker-compose-light.yml for development: - Add superset-core volume mount - Fix EXAMPLES_HOST to use db-light service - Change WEBPACK_DEVSERVER_HOST default to 0.0.0.0 - Add EXTENSIONS_PATH config support Co-Authored-By: Claude Opus 4.5 <[email protected]> --- docker-compose-light.yml | 8 ++- docker/pythonpath_dev/superset_config.py | 10 +++- superset/extensions/utils.py | 91 +++++++++++++++++++++++++++++++- superset/initialization/__init__.py | 27 ++-------- 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/docker-compose-light.yml b/docker-compose-light.yml index 09b0c65b0f8..4b805798a4e 100644 --- a/docker-compose-light.yml +++ b/docker-compose-light.yml @@ -64,9 +64,11 @@ x-superset-volumes: &superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container - ./docker:/app/docker - ./superset:/app/superset + - ./superset-core:/app/superset-core - ./superset-frontend:/app/superset-frontend - superset_home_light:/app/superset_home - ./tests:/app/tests + - ./extensions:/app/extensions x-common-build: &common-build context: . target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean` @@ -138,6 +140,10 @@ services: DATABASE_DB: superset_light POSTGRES_DB: superset_light SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py + # Override examples host to use light DB service + EXAMPLES_HOST: db-light + # Skip example loading for faster startup + SUPERSET_LOAD_EXAMPLES: "no" healthcheck: disable: true @@ -160,7 +166,7 @@ services: # configuring the dev-server to use the host.docker.internal to connect to the backend superset: "http://superset-light:8088" # Webpack dev server configuration - WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}" + WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}" WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}" ports: - "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index d88d9899c27..e647b7827ad 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -105,7 +105,15 @@ class CeleryConfig: CELERY_CONFIG = CeleryConfig -FEATURE_FLAGS = {"ALERT_REPORTS": True} +# Extensions configuration +# For local development, point to the extensions directory +# Note: If running in Docker, this path needs to be accessible from inside the container +EXTENSIONS_PATH = os.getenv("EXTENSIONS_PATH", "/app/extensions") + +FEATURE_FLAGS = { + "ALERT_REPORTS": True, + "ENABLE_EXTENSIONS": True, +} ALERT_REPORTS_NOTIFICATION_DRY_RUN = True WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501 # The base URL for the email report hyperlinks. diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index 883c9114728..e1a9f154292 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import Any, Generator, Iterable, Tuple from zipfile import ZipFile +import pydantic from flask import current_app from pydantic import ValidationError from superset_core.extensions.types import Manifest @@ -35,6 +36,22 @@ from superset.utils.core import check_is_safe_zip logger = logging.getLogger(__name__) +# Detect Pydantic version for compatibility +PYDANTIC_V2 = int(pydantic.VERSION.split(".")[0]) >= 2 + + +def _validate_manifest(data: dict[str, Any]) -> Manifest: + """ + Validate manifest data using the appropriate Pydantic method. + + Handles both Pydantic v1 and v2 compatibility. + """ + if PYDANTIC_V2: + return Manifest.model_validate(data) + else: + return Manifest.parse_obj(data) + + FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$") BACKEND_REGEX = re.compile(r"^backend/src/(.+)$") @@ -171,7 +188,7 @@ def get_loaded_extension( if filename == "manifest.json": try: manifest_data = json.loads(content) - manifest = Manifest.model_validate(manifest_data) + manifest = _validate_manifest(manifest_data) except ValidationError as e: raise Exception(f"Invalid manifest.json: {e}") from e except Exception as e: @@ -223,6 +240,76 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]: return extension_data +def load_extension_backend(extension: LoadedExtension) -> None: + """ + Load an extension's backend code and register its entry points. + + This installs the extension's Python modules in-memory and registers + any entry points declared in the manifest (e.g., semantic layers). + """ + # Install backend modules in-memory if present + if extension.backend: + install_in_memory_importer( + extension.backend, + source_base_path=extension.source_base_path, + ) + + # Register entry points from manifest + manifest = extension.manifest + if manifest.backend: + for ep_str in manifest.backend.entryPoints: + _register_entry_point(ep_str, extension.name) + + +def _register_entry_point(ep_str: str, extension_name: str) -> None: + """ + Parse and register a single entry point string. + + Entry point format: "name = module:ClassName" + """ + if "=" not in ep_str: + logger.warning( + "Invalid entry point format in extension %s: %s", + extension_name, + ep_str, + ) + return + + name, _, target = ep_str.partition("=") + name = name.strip() + target = target.strip() + + if ":" not in target: + logger.warning( + "Invalid entry point target in extension %s: %s (expected module:Class)", + extension_name, + target, + ) + return + + module_path, _, class_name = target.partition(":") + + try: + module = eager_import(module_path) + cls = getattr(module, class_name) + + # Register with semantic layer registry + from superset.semantic_layers.registry import register_semantic_layer + + register_semantic_layer(name, cls) + logger.info( + "Registered entry point '%s' from extension %s", + name, + extension_name, + ) + except Exception: + logger.exception( + "Failed to load entry point '%s' from extension %s", + name, + extension_name, + ) + + def get_extensions() -> dict[str, LoadedExtension]: extensions: dict[str, LoadedExtension] = {} @@ -234,6 +321,7 @@ def get_extensions() -> dict[str, LoadedExtension]: extension = get_loaded_extension(files, source_base_path=abs_dist_path) extension_id = extension.manifest.id extensions[extension_id] = extension + load_extension_backend(extension) logger.info( "Loading extension %s (ID: %s) from local filesystem", extension.name, @@ -248,6 +336,7 @@ def get_extensions() -> dict[str, LoadedExtension]: extension_id = extension.manifest.id if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS extensions[extension_id] = extension + load_extension_backend(extension) logger.info( "Loading extension %s (ID: %s) from discovery path", extension.name, diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index adc783c1e12..4f2eb9d7648 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -546,37 +546,18 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods self.init_extensions() def init_extensions(self) -> None: - from superset.extensions.utils import ( - eager_import, - get_extensions, - install_in_memory_importer, - ) + from superset.extensions.utils import get_extensions try: - extensions = get_extensions() + # get_extensions() discovers and loads all extensions, + # including installing in-memory importers and registering entry points + get_extensions() except Exception: # pylint: disable=broad-except # noqa: S110 # If the db hasn't been initialized yet, an exception will be raised. # It's fine to ignore this, as in this case there are no extensions # present yet. return - for extension in extensions.values(): - if backend_files := extension.backend: - install_in_memory_importer( - backend_files, - source_base_path=extension.source_base_path, - ) - - backend = extension.manifest.backend - - if backend and (entrypoints := backend.entryPoints): - for entrypoint in entrypoints: - try: - eager_import(entrypoint) - except Exception as ex: # pylint: disable=broad-except # noqa: S110 - # Surface exceptions during initialization of extensions - print(ex) - def init_app_in_ctx(self) -> None: """ Runs init logic in the context of the app
