feat(extensions): add mandatory publisher field to extension metadata (#38200)

This commit is contained in:
Ville Brofeldt
2026-02-24 09:42:17 -08:00
committed by GitHub
parent 7b04d251d6
commit 35c135852e
19 changed files with 1246 additions and 812 deletions

View File

@@ -21,6 +21,11 @@ import sys
from pathlib import Path
from typing import Any
from superset_core.extensions.constants import (
DISPLAY_NAME_PATTERN,
PUBLISHER_PATTERN,
TECHNICAL_NAME_PATTERN,
)
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
@@ -82,8 +87,10 @@ NPM_RESERVED = {
"bower_components",
}
# Extension name pattern: lowercase, start with letter or number, alphanumeric + hyphens
EXTENSION_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9]*(?:-[a-z0-9]+)*$")
# Compiled patterns for publisher/name validation
PUBLISHER_REGEX = re.compile(PUBLISHER_PATTERN)
TECHNICAL_NAME_REGEX = re.compile(TECHNICAL_NAME_PATTERN)
DISPLAY_NAME_REGEX = re.compile(DISPLAY_NAME_PATTERN)
def read_toml(path: Path) -> dict[str, Any] | None:
@@ -166,73 +173,6 @@ def name_to_kebab_case(name: str) -> str:
return _normalized_to_kebab(normalized)
# Legacy functions for backward compatibility
def to_kebab_case(name: str) -> str:
"""Convert display name to kebab-case. For new code, use name_to_kebab_case."""
return name_to_kebab_case(name)
def to_snake_case(kebab_name: str) -> str:
"""Convert kebab-case to snake_case. For new code, use kebab_to_snake_case."""
return kebab_to_snake_case(kebab_name)
def validate_extension_id(extension_id: str) -> None:
"""
Validate extension ID format (kebab-case).
Raises:
ExtensionNameError: If ID is invalid
"""
if not extension_id:
raise ExtensionNameError("Extension ID cannot be empty")
# Check for leading/trailing hyphens first
if extension_id.startswith("-"):
raise ExtensionNameError("Extension ID cannot start with hyphens")
if extension_id.endswith("-"):
raise ExtensionNameError("Extension ID cannot end with hyphens")
# Check for consecutive hyphens
if "--" in extension_id:
raise ExtensionNameError("Extension ID cannot have consecutive hyphens")
# Check overall pattern
if not EXTENSION_NAME_PATTERN.match(extension_id):
raise ExtensionNameError(
"Use lowercase letters, numbers, and hyphens only (e.g. hello-world)"
)
def validate_extension_name(name: str) -> str:
"""
Validate and normalize extension name (human-readable).
Args:
extension_name: Raw extension name input
Returns:
Cleaned extension name
Raises:
ExtensionNameError: If extension name is invalid
"""
if not name or not name.strip():
raise ExtensionNameError("Extension name cannot be empty")
# Normalize whitespace: strip and collapse multiple spaces
normalized = " ".join(name.strip().split())
# Check for only whitespace/special chars after normalization
if not any(c.isalnum() for c in normalized):
raise ExtensionNameError(
"Extension name must contain at least one letter or number"
)
return normalized
def validate_python_package_name(name: str) -> None:
"""
Validate Python package name (snake_case format).
@@ -266,39 +206,177 @@ def validate_npm_package_name(name: str) -> None:
raise ExtensionNameError(f"'{name}' is a reserved npm package name")
def generate_extension_names(name: str) -> ExtensionNames:
def validate_publisher(publisher: str) -> None:
"""
Generate all extension name variants from display name input.
Validate publisher namespace format.
Flow: Display Name -> Generate ID -> Derive Technical Names from ID
Example: "Hello World" -> "hello-world" -> "helloWorld"/"hello_world" (from ID)
Args:
publisher: Publisher namespace (e.g., 'my-org')
Raises:
ExtensionNameError: If publisher is invalid
"""
if not publisher:
raise ExtensionNameError("Publisher cannot be empty")
if not PUBLISHER_REGEX.match(publisher):
raise ExtensionNameError(
"Publisher must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'my-org')"
)
def validate_technical_name(name: str) -> None:
"""
Validate technical extension name format.
Args:
name: Technical extension name (e.g., 'dashboard-widgets')
Raises:
ExtensionNameError: If name is invalid
"""
if not name:
raise ExtensionNameError("Extension name cannot be empty")
if not TECHNICAL_NAME_REGEX.match(name):
raise ExtensionNameError(
"Extension name must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'dashboard-widgets')"
)
def validate_display_name(display_name: str) -> str:
"""
Validate and normalize display name format.
Args:
display_name: Human-readable extension name
Returns:
Cleaned display name
Raises:
ExtensionNameError: If display name is invalid
"""
if not display_name or not display_name.strip():
raise ExtensionNameError("Display name cannot be empty")
# Normalize whitespace: strip and collapse multiple spaces
normalized = " ".join(display_name.strip().split())
if not DISPLAY_NAME_REGEX.match(normalized):
raise ExtensionNameError(
"Display name must start with a letter and can contain letters, numbers, spaces, hyphens, underscores, and dots (e.g., 'Dashboard Widgets')"
)
# Check for only whitespace/special chars after normalization
if not any(c.isalnum() for c in normalized):
raise ExtensionNameError(
"Display name must contain at least one letter or number"
)
return normalized
def suggest_technical_name(display_name: str) -> str:
"""
Suggest technical name from display name.
Args:
display_name: Human-readable name (e.g., "Dashboard Widgets!")
Returns:
Technical name suggestion (e.g., "dashboard-widgets")
"""
# Normalize for identifiers
normalized = _normalize_for_identifiers(display_name)
# Convert to kebab-case
technical_name = _normalized_to_kebab(normalized)
# Remove any leading/trailing hyphens that might result from edge cases
technical_name = technical_name.strip("-")
# Ensure we have something left
if not technical_name:
raise ExtensionNameError(
"Display name must contain at least one letter or number"
)
return technical_name
def get_module_federation_name(publisher: str, name: str) -> str:
"""
Generate Module Federation container name.
Args:
publisher: Publisher namespace (e.g., 'my-org')
name: Technical name (e.g., 'dashboard-widgets')
Returns:
Module Federation name (e.g., 'myOrg_dashboardWidgets')
"""
publisher_camel = kebab_to_camel_case(publisher)
name_camel = kebab_to_camel_case(name)
return f"{publisher_camel}_{name_camel}"
def generate_extension_names(
display_name: str, publisher: str, technical_name: str | None = None
) -> ExtensionNames:
"""
Generate all extension name variants from input.
Args:
display_name: Human-readable name (e.g., "Dashboard Widgets")
publisher: Publisher namespace (e.g., "my-org")
technical_name: Technical name override, or None to auto-generate
Returns:
ExtensionNames: Dictionary with all name variants
Raises:
ExtensionNameError: If any generated name is invalid
ExtensionNameError: If any name is invalid
"""
# Validate and normalize the extension name
name = validate_extension_name(name)
# Validate and normalize inputs
display_name = validate_display_name(display_name)
validate_publisher(publisher)
# Generate ID from display name
kebab_name = name_to_kebab_case(name)
# Use provided technical name or generate from display name
if technical_name is None:
technical_name = suggest_technical_name(display_name)
else:
validate_technical_name(technical_name)
# Derive all technical names from the generated ID (not display name)
snake_name = kebab_to_snake_case(kebab_name)
camel_name = kebab_to_camel_case(kebab_name)
# Generate composite ID
composite_id = f"{publisher}.{technical_name}"
# Generate NPM package name
npm_name = f"@{publisher}/{technical_name}"
# Generate Module Federation name
mf_name = get_module_federation_name(publisher, technical_name)
# Generate backend names with collision protection
publisher_snake = kebab_to_snake_case(publisher)
name_snake = kebab_to_snake_case(technical_name)
backend_package = f"{publisher_snake}-{name_snake}"
backend_path = f"superset_extensions.{publisher_snake}.{name_snake}"
backend_entry = f"{backend_path}.entrypoint"
# Validate the generated names
validate_extension_id(kebab_name)
validate_python_package_name(snake_name)
validate_npm_package_name(kebab_name)
validate_python_package_name(publisher_snake)
validate_python_package_name(name_snake)
validate_npm_package_name(technical_name)
return ExtensionNames(
name=name,
id=kebab_name,
mf_name=camel_name,
backend_name=snake_name,
backend_package=f"superset_extensions.{snake_name}",
backend_entry=f"superset_extensions.{snake_name}.entrypoint",
publisher=publisher,
name=technical_name,
display_name=display_name,
id=composite_id,
npm_name=npm_name,
mf_name=mf_name,
backend_package=backend_package,
backend_path=backend_path,
backend_entry=backend_entry,
)