mirror of
https://github.com/apache/superset.git
synced 2026-04-11 04:15:33 +00:00
472 lines
15 KiB
Python
472 lines
15 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 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()
|