mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(extensions): add mandatory publisher field to extension metadata (#38200)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user