Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/developer_docs/extensions/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ superset-extensions bundle: Packages the extension into a .supx file.
superset-extensions dev: Automatically rebuilds the extension as files change.
superset-extensions validate: Validates the extension structure and metadata.
superset-extensions validate: Validates the extension structure and metadata consistency.
superset-extensions update: Updates derived and generated files in the extension project.
Use --version [<version>] to update the version (prompts if no value given).
Use --license [<license>] to update the license (prompts if no value given).
```

When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure:
Expand Down
2 changes: 2 additions & 0 deletions requirements/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,8 @@ tabulate==0.9.0
# via
# -c requirements/base-constraint.txt
# apache-superset
tomli-w==1.2.0
# via apache-superset-extensions-cli
tomlkit==0.13.3
# via pylint
tqdm==4.67.1
Expand Down
1 change: 1 addition & 0 deletions superset-extensions-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ dependencies = [
"jinja2>=3.1.6",
"semver>=3.0.4",
"tomli>=2.2.1; python_version < '3.11'",
"tomli-w>=1.2.0",
"watchdog>=6.0.0",
]

Expand Down
161 changes: 161 additions & 0 deletions superset-extensions-cli/src/superset_extensions_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
validate_display_name,
validate_publisher,
validate_technical_name,
write_json,
write_toml,
)

REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
Expand Down Expand Up @@ -292,6 +294,7 @@ def app() -> None:

@app.command()
def validate() -> None:
"""Validate the extension structure and metadata consistency."""
validate_npm()

cwd = Path.cwd()
Expand Down Expand Up @@ -372,12 +375,167 @@ def validate() -> None:
click.secho(" Convention requires: frontend/src/index.tsx", fg="yellow")
sys.exit(1)

# Validate version and license consistency across extension.json, frontend, and backend
mismatches: list[str] = []
frontend_pkg_path = cwd / "frontend" / "package.json"
frontend_pkg = None
if frontend_pkg_path.is_file():
frontend_pkg = read_json(frontend_pkg_path)
if frontend_pkg:
if frontend_pkg.get("version") != extension.version:
mismatches.append(
f" frontend/package.json version: {frontend_pkg.get('version')} "
f"(expected {extension.version})"
)
if extension.license and frontend_pkg.get("license") != extension.license:
mismatches.append(
f" frontend/package.json license: {frontend_pkg.get('license')} "
f"(expected {extension.license})"
)

backend_pyproject_path = cwd / "backend" / "pyproject.toml"
if backend_pyproject_path.is_file():
backend_pyproject = read_toml(backend_pyproject_path)
if backend_pyproject:
project = backend_pyproject.get("project", {})
if project.get("version") != extension.version:
mismatches.append(
f" backend/pyproject.toml version: {project.get('version')} "
f"(expected {extension.version})"
)
if extension.license and project.get("license") != extension.license:
mismatches.append(
f" backend/pyproject.toml license: {project.get('license')} "
f"(expected {extension.license})"
)

if mismatches:
click.secho("❌ Metadata mismatch detected:", err=True, fg="red")
for mismatch in mismatches:
click.secho(mismatch, err=True, fg="red")
click.secho(
"Run `superset-extensions update` to sync from extension.json.",
fg="yellow",
)
sys.exit(1)

click.secho("✅ Validation successful", fg="green")


@app.command()
@click.option(
"--version",
"version_opt",
is_flag=False,
flag_value="__prompt__",
default=None,
help="Set a new version. Prompts for value if none given.",
)
@click.option(
"--license",
"license_opt",
is_flag=False,
flag_value="__prompt__",
default=None,
help="Set a new license. Prompts for value if none given.",
)
def update(version_opt: str | None, license_opt: str | None) -> None:
"""Update derived and generated files in the extension project."""
cwd = Path.cwd()

extension_json_path = cwd / "extension.json"
extension_data = read_json(extension_json_path)
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)

# Resolve version: prompt if flag used without value
if version_opt == "__prompt__":
version_opt = click.prompt("Version", default=extension.version)
target_version = (
version_opt
if version_opt and version_opt != extension.version
else extension.version
)

# Resolve license: prompt if flag used without value
if license_opt == "__prompt__":
license_opt = click.prompt("License", default=extension.license or "")
target_license = (
license_opt
if license_opt and license_opt != extension.license
else extension.license
)

updated: list[str] = []

# Update extension.json if version or license changed
ext_changed = False
if version_opt and version_opt != extension.version:
extension_data["version"] = target_version
ext_changed = True
if license_opt and license_opt != extension.license:
extension_data["license"] = target_license
ext_changed = True
if ext_changed:
try:
ExtensionConfig.model_validate(extension_data)
except Exception as e:
click.secho(f"❌ Invalid value: {e}", err=True, fg="red")
sys.exit(1)
write_json(extension_json_path, extension_data)
updated.append("extension.json")

# Update frontend/package.json
frontend_pkg_path = cwd / "frontend" / "package.json"
if frontend_pkg_path.is_file():
frontend_pkg = read_json(frontend_pkg_path)
if frontend_pkg:
pkg_changed = False
if frontend_pkg.get("version") != target_version:
frontend_pkg["version"] = target_version
pkg_changed = True
if target_license and frontend_pkg.get("license") != target_license:
frontend_pkg["license"] = target_license
pkg_changed = True
if pkg_changed:
write_json(frontend_pkg_path, frontend_pkg)
updated.append("frontend/package.json")

# Update backend/pyproject.toml
backend_pyproject_path = cwd / "backend" / "pyproject.toml"
if backend_pyproject_path.is_file():
backend_pyproject = read_toml(backend_pyproject_path)
if backend_pyproject:
project = backend_pyproject.setdefault("project", {})
toml_changed = False
if project.get("version") != target_version:
project["version"] = target_version
toml_changed = True
if target_license and project.get("license") != target_license:
project["license"] = target_license
toml_changed = True
if toml_changed:
write_toml(backend_pyproject_path, backend_pyproject)
updated.append("backend/pyproject.toml")

if updated:
for path in updated:
click.secho(f"✅ Updated {path}", fg="green")
else:
click.secho("✅ All files already up to date.", fg="green")


@app.command()
@click.pass_context
def build(ctx: click.Context) -> None:
"""Build extension assets."""
ctx.invoke(validate)
cwd = Path.cwd()
frontend_dir = cwd / "frontend"
Expand Down Expand Up @@ -413,6 +571,7 @@ def build(ctx: click.Context) -> None:
)
@click.pass_context
def bundle(ctx: click.Context, output: Path | None) -> None:
"""Package the extension into a .supx file."""
ctx.invoke(build)

cwd = Path.cwd()
Expand Down Expand Up @@ -453,6 +612,7 @@ def bundle(ctx: click.Context, output: Path | None) -> None:
@app.command()
@click.pass_context
def dev(ctx: click.Context) -> None:
"""Automatically rebuild the extension as files change."""
cwd = Path.cwd()
frontend_dir = cwd / "frontend"
backend_dir = cwd / "backend"
Expand Down Expand Up @@ -647,6 +807,7 @@ def init(
frontend_opt: bool | None,
backend_opt: bool | None,
) -> None:
"""Scaffold a new extension project."""
# Get extension names with graceful validation
names = prompt_for_extension_info(display_name_opt, publisher_opt, name_opt)

Expand Down
10 changes: 10 additions & 0 deletions superset-extensions-cli/src/superset_extensions_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from pathlib import Path
from typing import Any

import tomli_w

from superset_core.extensions.constants import (
DISPLAY_NAME_PATTERN,
PUBLISHER_PATTERN,
Expand Down Expand Up @@ -109,6 +111,14 @@ def read_json(path: Path) -> dict[str, Any] | None:
return json.loads(path.read_text())


def write_json(path: Path, data: dict[str, Any]) -> None:
path.write_text(json.dumps(data, indent=2) + "\n")


def write_toml(path: Path, data: dict[str, Any]) -> None:
path.write_text(tomli_w.dumps(data))


def _normalize_for_identifiers(name: str) -> str:
"""
Normalize display name to clean lowercase words.
Expand Down
68 changes: 68 additions & 0 deletions superset-extensions-cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

from __future__ import annotations

import json
import os
from pathlib import Path

import pytest
import tomli_w
from click.testing import CliRunner


Expand Down Expand Up @@ -138,3 +140,69 @@ def _setup(base_path: Path) -> None:
(backend_dir / "__init__.py").write_text("# init")

return _setup


@pytest.fixture
def extension_with_versions():
"""Create an extension directory structure with configurable versions and licenses."""

def _create(
base_path: Path,
ext_version: str = "1.0.0",
frontend_version: str | None = None,
backend_version: str | None = None,
ext_license: str | None = "Apache-2.0",
frontend_license: str | None = None,
backend_license: str | None = None,
) -> None:
extension_json = {
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": ext_version,
"permissions": [],
}
if ext_license is not None:
extension_json["license"] = ext_license
(base_path / "extension.json").write_text(json.dumps(extension_json))

if frontend_version is not None:
frontend_dir = base_path / "frontend"
frontend_dir.mkdir(exist_ok=True)
(frontend_dir / "src").mkdir(exist_ok=True)
(frontend_dir / "src" / "index.tsx").write_text("// entry")
pkg = {
"name": "@test-org/test-extension",
"version": frontend_version,
}
if frontend_license is not None:
pkg["license"] = frontend_license
elif ext_license is not None:
pkg["license"] = ext_license
(frontend_dir / "package.json").write_text(json.dumps(pkg, indent=2))

if backend_version is not None:
backend_dir = base_path / "backend"
backend_dir.mkdir(exist_ok=True)
src_dir = backend_dir / "src" / "test_org" / "test_extension"
src_dir.mkdir(parents=True, exist_ok=True)
(src_dir / "entrypoint.py").write_text("# entry")
project = {
"name": "test-org-test-extension",
"version": backend_version,
}
if backend_license is not None:
project["license"] = backend_license
elif ext_license is not None:
project["license"] = ext_license
pyproject = {
"project": project,
"tool": {
"apache_superset_extensions": {
"build": {"include": ["src/**/*.py"]}
}
},
}
(backend_dir / "pyproject.toml").write_text(tomli_w.dumps(pyproject))

return _create
4 changes: 2 additions & 2 deletions superset-extensions-cli/tests/test_cli_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def test_build_command_success_flow(
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_read_toml.return_value = {
"project": {"name": "test"},
"project": {"name": "test", "version": "1.0.0"},
"tool": {
"apache_superset_extensions": {
"build": {"include": ["src/test_org/test_extension/**/*.py"]}
Expand Down Expand Up @@ -162,7 +162,7 @@ def 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"},
"project": {"name": "test", "version": "1.0.0"},
"tool": {
"apache_superset_extensions": {
"build": {"include": ["src/test_org/test_extension/**/*.py"]}
Expand Down
Loading
Loading