| # 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 json # noqa: TID251 |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import time |
| import zipfile |
| from pathlib import Path |
| from typing import Any, Callable, cast |
| |
| import click |
| import semver |
| from jinja2 import Environment, FileSystemLoader |
| from superset_core.extensions.types import Manifest, Metadata |
| from watchdog.events import FileSystemEventHandler |
| from watchdog.observers import Observer |
| |
| from superset_extensions_cli.constants import MIN_NPM_VERSION |
| from superset_extensions_cli.utils import read_json, read_toml |
| |
| REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$") |
| FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist") |
| |
| |
| def validate_npm() -> None: |
| """Abort if `npm` is not on PATH.""" |
| if shutil.which("npm") is None: |
| click.secho( |
| "❌ npm is not installed or not on your PATH.", |
| err=True, |
| fg="red", |
| ) |
| sys.exit(1) |
| |
| try: |
| result = subprocess.run( # noqa: S603 |
| ["npm", "-v"], # noqa: S607 |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| text=True, |
| ) |
| if result.returncode != 0: |
| click.secho( |
| f"❌ Failed to run `npm -v`: {result.stderr.strip()}", |
| err=True, |
| fg="red", |
| ) |
| sys.exit(1) |
| |
| npm_version = result.stdout.strip() |
| if semver.compare(npm_version, MIN_NPM_VERSION) < 0: |
| click.secho( |
| f"❌ npm version {npm_version} is lower than the required {MIN_NPM_VERSION}.", # noqa: E501 |
| err=True, |
| fg="red", |
| ) |
| sys.exit(1) |
| |
| except FileNotFoundError: |
| click.secho( |
| "❌ npm was not found when checking its version.", |
| err=True, |
| fg="red", |
| ) |
| sys.exit(1) |
| |
| |
| def init_frontend_deps(frontend_dir: Path) -> None: |
| """ |
| If node_modules is missing under `frontend_dir`, run `npm ci` if package-lock.json |
| exists, otherwise run `npm i`. |
| """ |
| node_modules = frontend_dir / "node_modules" |
| if not node_modules.exists(): |
| package_lock = frontend_dir / "package-lock.json" |
| if package_lock.exists(): |
| click.secho("⚙️ node_modules not found, running `npm ci`…", fg="cyan") |
| npm_command = ["npm", "ci"] |
| error_msg = "❌ `npm ci` failed. Aborting." |
| else: |
| click.secho("⚙️ node_modules not found, running `npm i`…", fg="cyan") |
| npm_command = ["npm", "i"] |
| error_msg = "❌ `npm i` failed. Aborting." |
| |
| validate_npm() |
| res = subprocess.run( # noqa: S603 |
| npm_command, # noqa: S607 |
| cwd=frontend_dir, |
| text=True, |
| ) |
| if res.returncode != 0: |
| click.secho(error_msg, err=True, fg="red") |
| sys.exit(1) |
| click.secho("✅ Dependencies installed", fg="green") |
| |
| |
| def clean_dist(cwd: Path) -> None: |
| dist_dir = cwd / "dist" |
| if dist_dir.exists(): |
| shutil.rmtree(dist_dir) |
| dist_dir.mkdir(parents=True) |
| |
| |
| def clean_dist_frontend(cwd: Path) -> None: |
| frontend_dist = cwd / "dist" / "frontend" |
| if frontend_dist.exists(): |
| shutil.rmtree(frontend_dist) |
| |
| |
| def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: |
| extension: Metadata = cast(Metadata, read_json(cwd / "extension.json")) |
| if not extension: |
| 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, |
| } |
| |
| if entry_points := extension.get("backend", {}).get("entryPoints"): |
| manifest["backend"] = {"entryPoints": entry_points} |
| |
| return manifest |
| |
| |
| 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) |
| ) |
| click.secho("✅ Manifest updated", fg="green") |
| |
| |
| def run_frontend_build(frontend_dir: Path) -> subprocess.CompletedProcess[str]: |
| click.echo() |
| click.secho("⚙️ Building frontend assets…", fg="cyan") |
| return subprocess.run( # noqa: S603 |
| ["npm", "run", "build"], # noqa: S607 |
| cwd=frontend_dir, |
| text=True, |
| ) |
| |
| |
| def copy_frontend_dist(cwd: Path) -> str: |
| dist_dir = cwd / "dist" |
| frontend_dist = cwd / "frontend" / "dist" |
| remote_entry: str | None = None |
| |
| for f in frontend_dist.rglob("*"): |
| if not f.is_file(): |
| continue |
| if REMOTE_ENTRY_REGEX.match(f.name): |
| remote_entry = f.name |
| tgt = dist_dir / f.relative_to(cwd) |
| tgt.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(f, tgt) |
| |
| if not remote_entry: |
| click.secho("❌ No remote entry file found.", err=True, fg="red") |
| sys.exit(1) |
| return remote_entry |
| |
| |
| def copy_backend_files(cwd: Path) -> None: |
| 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) |
| |
| for pat in extension.get("backend", {}).get("files", []): |
| for f in cwd.glob(pat): |
| if not f.is_file(): |
| continue |
| tgt = dist_dir / f.relative_to(cwd) |
| tgt.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(f, tgt) |
| |
| |
| def rebuild_frontend(cwd: Path, frontend_dir: Path) -> str | None: |
| """Clean and rebuild frontend, return the remoteEntry filename.""" |
| clean_dist_frontend(cwd) |
| |
| res = run_frontend_build(frontend_dir) |
| if res.returncode != 0: |
| click.secho("❌ Frontend build failed", fg="red") |
| return None |
| |
| remote_entry = copy_frontend_dist(cwd) |
| click.secho("✅ Frontend rebuilt", fg="green") |
| return remote_entry |
| |
| |
| def rebuild_backend(cwd: Path) -> None: |
| """Copy backend files (no manifest update).""" |
| copy_backend_files(cwd) |
| click.secho("✅ Backend files synced", fg="green") |
| |
| |
| class FrontendChangeHandler(FileSystemEventHandler): |
| def __init__(self, trigger_build: Callable[[], None]): |
| self.trigger_build = trigger_build |
| |
| def on_any_event(self, event: Any) -> None: |
| if FRONTEND_DIST_REGEX.search(event.src_path): |
| return |
| click.secho(f"🔁 Frontend change detected: {event.src_path}", fg="yellow") |
| self.trigger_build() |
| |
| |
| @click.group(help="CLI for validating and bundling Superset extensions.") |
| def app() -> None: |
| pass |
| |
| |
| @app.command() |
| def validate() -> None: |
| validate_npm() |
| |
| click.secho("✅ Validation successful", fg="green") |
| |
| |
| @app.command() |
| @click.pass_context |
| def build(ctx: click.Context) -> None: |
| ctx.invoke(validate) |
| cwd = Path.cwd() |
| frontend_dir = cwd / "frontend" |
| backend_dir = cwd / "backend" |
| |
| clean_dist(cwd) |
| |
| # Build frontend if it exists |
| remote_entry = None |
| if frontend_dir.exists(): |
| init_frontend_deps(frontend_dir) |
| remote_entry = rebuild_frontend(cwd, frontend_dir) |
| |
| # Build backend independently if it exists |
| if backend_dir.exists(): |
| pyproject = read_toml(backend_dir / "pyproject.toml") |
| if pyproject: |
| rebuild_backend(cwd) |
| |
| # Build manifest and write it |
| manifest = build_manifest(cwd, remote_entry) |
| write_manifest(cwd, manifest) |
| |
| click.secho("✅ Full build completed in dist/", fg="green") |
| |
| |
| @app.command() |
| @click.option( |
| "--output", |
| "-o", |
| type=click.Path(path_type=Path, dir_okay=True, file_okay=True, writable=True), |
| help="Optional output path or filename for the bundle.", |
| ) |
| @click.pass_context |
| def bundle(ctx: click.Context, output: Path | None) -> None: |
| ctx.invoke(build) |
| |
| cwd = Path.cwd() |
| dist_dir = cwd / "dist" |
| manifest_path = dist_dir / "manifest.json" |
| |
| if not manifest_path.exists(): |
| click.secho( |
| "❌ dist/manifest.json not found. Run `build` first.", err=True, fg="red" |
| ) |
| sys.exit(1) |
| |
| manifest = json.loads(manifest_path.read_text()) |
| id_ = manifest["id"] |
| version = manifest["version"] |
| default_filename = f"{id_}-{version}.supx" |
| |
| if output is None: |
| zip_path = Path(default_filename) |
| elif output.is_dir(): |
| zip_path = output / default_filename |
| else: |
| zip_path = output |
| |
| try: |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: |
| for file in dist_dir.rglob("*"): |
| if file.is_file(): |
| arcname = file.relative_to(dist_dir) |
| zipf.write(file, arcname) |
| except Exception as ex: |
| click.secho(f"❌ Failed to create bundle: {ex}", err=True, fg="red") |
| sys.exit(1) |
| |
| click.secho(f"✅ Bundle created: {zip_path}", fg="green") |
| |
| |
| @app.command() |
| @click.pass_context |
| def dev(ctx: click.Context) -> None: |
| cwd = Path.cwd() |
| frontend_dir = cwd / "frontend" |
| backend_dir = cwd / "backend" |
| |
| clean_dist(cwd) |
| |
| # Build frontend if it exists |
| remote_entry = None |
| if frontend_dir.exists(): |
| init_frontend_deps(frontend_dir) |
| remote_entry = rebuild_frontend(cwd, frontend_dir) |
| |
| # Build backend if it exists |
| if backend_dir.exists(): |
| rebuild_backend(cwd) |
| |
| manifest = build_manifest(cwd, remote_entry) |
| write_manifest(cwd, manifest) |
| |
| def frontend_watcher() -> None: |
| if frontend_dir.exists(): |
| if (remote_entry := rebuild_frontend(cwd, frontend_dir)) is not None: |
| manifest = build_manifest(cwd, remote_entry) |
| write_manifest(cwd, manifest) |
| |
| def backend_watcher() -> None: |
| if backend_dir.exists(): |
| rebuild_backend(cwd) |
| dist_dir = cwd / "dist" |
| manifest_path = dist_dir / "manifest.json" |
| if manifest_path.exists(): |
| manifest = json.loads(manifest_path.read_text()) |
| write_manifest(cwd, manifest) |
| |
| # Build watch message based on existing directories |
| watch_dirs = [] |
| if frontend_dir.exists(): |
| watch_dirs.append(str(frontend_dir)) |
| if backend_dir.exists(): |
| watch_dirs.append(str(backend_dir)) |
| |
| if watch_dirs: |
| click.secho(f"👀 Watching for changes in: {', '.join(watch_dirs)}", fg="green") |
| else: |
| click.secho("⚠️ No frontend or backend directories found to watch", fg="yellow") |
| |
| observer = Observer() |
| |
| # Only set up watchers for directories that exist |
| if frontend_dir.exists(): |
| frontend_handler = FrontendChangeHandler(trigger_build=frontend_watcher) |
| observer.schedule(frontend_handler, str(frontend_dir), recursive=True) |
| |
| if backend_dir.exists(): |
| backend_handler = FileSystemEventHandler() |
| backend_handler.on_any_event = lambda event: backend_watcher() |
| observer.schedule(backend_handler, str(backend_dir), recursive=True) |
| |
| if watch_dirs: |
| observer.start() |
| |
| try: |
| while True: |
| time.sleep(1) |
| except KeyboardInterrupt: |
| click.secho("\n🛑 Stopping watch mode", fg="blue") |
| observer.stop() |
| |
| observer.join() |
| else: |
| click.secho("❌ No directories to watch. Exiting.", fg="red") |
| |
| |
| @app.command() |
| def init() -> None: |
| id_ = click.prompt("Extension ID (unique identifier, alphanumeric only)", type=str) |
| if not re.match(r"^[a-zA-Z0-9_]+$", id_): |
| click.secho( |
| "❌ ID must be alphanumeric (letters, digits, underscore).", fg="red" |
| ) |
| sys.exit(1) |
| |
| name = click.prompt("Extension name (human-readable display name)", type=str) |
| version = click.prompt("Initial version", default="0.1.0") |
| license = click.prompt("License", default="Apache-2.0") |
| include_frontend = click.confirm("Include frontend?", default=True) |
| include_backend = click.confirm("Include backend?", default=True) |
| |
| target_dir = Path.cwd() / id_ |
| if target_dir.exists(): |
| click.secho(f"❌ Directory {target_dir} already exists.", fg="red") |
| sys.exit(1) |
| |
| # Set up Jinja environment |
| templates_dir = Path(__file__).parent / "templates" |
| env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701 |
| ctx = { |
| "id": id_, |
| "name": name, |
| "include_frontend": include_frontend, |
| "include_backend": include_backend, |
| "license": license, |
| "version": version, |
| } |
| |
| # Create base directory |
| target_dir.mkdir() |
| extension_json = env.get_template("extension.json.j2").render(ctx) |
| (target_dir / "extension.json").write_text(extension_json) |
| click.secho("✅ Created extension.json", fg="green") |
| |
| # Copy frontend template |
| if include_frontend: |
| frontend_dir = target_dir / "frontend" |
| frontend_dir.mkdir() |
| |
| # package.json |
| package_json = env.get_template("frontend/package.json.j2").render(ctx) |
| (frontend_dir / "package.json").write_text(package_json) |
| click.secho("✅ Created frontend folder structure", fg="green") |
| |
| # Copy backend template |
| if include_backend: |
| backend_dir = target_dir / "backend" |
| backend_dir.mkdir() |
| |
| # pyproject.toml |
| pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx) |
| (backend_dir / "pyproject.toml").write_text(pyproject_toml) |
| |
| click.secho("✅ Created backend folder structure", fg="green") |
| |
| click.secho( |
| f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan" |
| ) |
| |
| |
| if __name__ == "__main__": |
| app() |