fix(extensions): enforce correct naming conventions (#38167)

This commit is contained in:
Ville Brofeldt
2026-02-23 08:21:35 -08:00
committed by GitHub
parent 6e94a6c21a
commit 40f609fdce
17 changed files with 1118 additions and 167 deletions

View File

@@ -38,7 +38,20 @@ 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
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
from superset_extensions_cli.utils import (
generate_extension_names,
kebab_to_camel_case,
kebab_to_snake_case,
read_json,
read_toml,
to_kebab_case,
to_snake_case,
validate_extension_id,
validate_npm_package_name,
validate_python_package_name,
)
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist")
@@ -403,14 +416,127 @@ def dev(ctx: click.Context) -> None:
click.secho("❌ No directories to watch. Exiting.", fg="red")
def prompt_for_extension_name(
display_name_opt: str | None, id_opt: str | None
) -> ExtensionNames:
"""
Prompt for extension name with graceful validation and re-prompting.
Args:
display_name_opt: Display name provided via CLI option (if any)
id_opt: Extension ID provided via CLI option (if any)
Returns:
ExtensionNames: Validated extension name variants
"""
# Case 1: Both provided via CLI - validate they work together
if display_name_opt and id_opt:
try:
# Generate all names from display name for consistency
temp_names = generate_extension_names(display_name_opt)
# Check if the provided ID matches what we'd generate
if temp_names["id"] == id_opt:
return temp_names
else:
# If IDs don't match, use the provided ID but validate it
validate_extension_id(id_opt)
validate_python_package_name(to_snake_case(id_opt))
validate_npm_package_name(id_opt)
# Create names with the provided ID (derive technical names from ID)
return ExtensionNames(
name=display_name_opt,
id=id_opt,
mf_name=kebab_to_camel_case(id_opt),
backend_name=kebab_to_snake_case(id_opt),
backend_package=f"superset_extensions.{kebab_to_snake_case(id_opt)}",
backend_entry=f"superset_extensions.{kebab_to_snake_case(id_opt)}.entrypoint",
)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
# Case 2: Only display name provided - suggest ID
if display_name_opt and not id_opt:
display_name = display_name_opt
try:
suggested_names = generate_extension_names(display_name)
suggested_id = suggested_names["id"]
except ExtensionNameError:
suggested_id = to_kebab_case(display_name)
extension_id = click.prompt("Extension ID", default=suggested_id, type=str)
# Case 3: Only ID provided - ask for display name
elif id_opt and not display_name_opt:
extension_id = id_opt
# Validate the provided ID first
try:
validate_extension_id(id_opt)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
# Suggest display name from kebab ID
suggested_display = " ".join(word.capitalize() for word in id_opt.split("-"))
display_name = click.prompt(
"Extension name", default=suggested_display, type=str
)
# Case 4: Neither provided - ask for both
else:
display_name = click.prompt("Extension name (e.g. Hello World)", type=str)
try:
suggested_names = generate_extension_names(display_name)
suggested_id = suggested_names["id"]
except ExtensionNameError:
suggested_id = to_kebab_case(display_name)
extension_id = click.prompt("Extension ID", default=suggested_id, type=str)
# Final validation loop - try to use generate_extension_names for consistent results
display_name_failed = False # Track if display name validation failed
while True:
try:
# First try to generate from display name if possible and it hasn't failed before
if display_name and not display_name_failed:
temp_names = generate_extension_names(display_name)
if temp_names["id"] == extension_id:
# Perfect match - use generated names
return temp_names
# If no match or display name failed, validate manually and construct
validate_extension_id(extension_id)
validate_python_package_name(to_snake_case(extension_id))
validate_npm_package_name(extension_id)
return ExtensionNames(
name=display_name,
id=extension_id,
mf_name=kebab_to_camel_case(extension_id),
backend_name=kebab_to_snake_case(extension_id),
backend_package=f"superset_extensions.{kebab_to_snake_case(extension_id)}",
backend_entry=f"superset_extensions.{kebab_to_snake_case(extension_id)}.entrypoint",
)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
# If the error came from generate_extension_names, stop trying it
if "display_name" in str(e) or not display_name_failed:
display_name_failed = True
extension_id = click.prompt("Extension ID", type=str)
@app.command()
@click.option(
"--id",
"id_opt",
default=None,
help="Extension ID (alphanumeric and underscores only)",
help="Extension ID (kebab-case, e.g. hello-world)",
)
@click.option(
"--name", "name_opt", default=None, help="Extension display name (e.g. Hello World)"
)
@click.option("--name", "name_opt", default=None, help="Extension display name")
@click.option(
"--version", "version_opt", default=None, help="Initial version (default: 0.1.0)"
)
@@ -431,18 +557,9 @@ def init(
frontend_opt: bool | None,
backend_opt: bool | None,
) -> None:
id_ = id_opt or 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)
# Get extension names with graceful validation
names = prompt_for_extension_name(name_opt, id_opt)
name = name_opt or click.prompt(
"Extension name (human-readable display name)", type=str
)
version = version_opt or click.prompt("Initial version", default="0.1.0")
license_ = license_opt or click.prompt("License", default="Apache-2.0")
include_frontend = (
@@ -456,7 +573,7 @@ def init(
else click.confirm("Include backend?", default=True)
)
target_dir = Path.cwd() / id_
target_dir = Path.cwd() / names["id"]
if target_dir.exists():
click.secho(f"❌ Directory {target_dir} already exists.", fg="red")
sys.exit(1)
@@ -465,8 +582,7 @@ def init(
templates_dir = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701
ctx = {
"id": id_,
"name": name,
**names, # Include all name variants
"include_frontend": include_frontend,
"include_backend": include_backend,
"license": license_,
@@ -502,29 +618,41 @@ def init(
(frontend_src_dir / "index.tsx").write_text(index_tsx)
click.secho("✅ Created frontend folder structure", fg="green")
# Initialize backend files
# Initialize backend files with superset_extensions namespace
if include_backend:
backend_dir = target_dir / "backend"
backend_dir.mkdir()
backend_src_dir = backend_dir / "src"
backend_src_dir.mkdir()
backend_src_package_dir = backend_src_dir / id_
backend_src_package_dir.mkdir()
# Create superset_extensions namespace directory
namespace_dir = backend_src_dir / "superset_extensions"
namespace_dir.mkdir()
# Create extension package directory
extension_package_dir = namespace_dir / names["backend_name"]
extension_package_dir.mkdir()
# backend files
pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx)
(backend_dir / "pyproject.toml").write_text(pyproject_toml)
# Namespace package __init__.py (empty for namespace)
(namespace_dir / "__init__.py").write_text("")
# Extension package files
init_py = env.get_template("backend/src/package/__init__.py.j2").render(ctx)
(backend_src_package_dir / "__init__.py").write_text(init_py)
(extension_package_dir / "__init__.py").write_text(init_py)
entrypoint_py = env.get_template("backend/src/package/entrypoint.py.j2").render(
ctx
)
(backend_src_package_dir / "entrypoint.py").write_text(entrypoint_py)
(extension_package_dir / "entrypoint.py").write_text(entrypoint_py)
click.secho("✅ Created backend folder structure", fg="green")
click.secho(
f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan"
f"🎉 Extension {names['name']} (ID: {names['id']}) initialized at {target_dir}",
fg="cyan",
)