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

@@ -40,10 +40,10 @@ superset-extensions bundle: Packages the extension into a .supx file.
superset-extensions dev: Automatically rebuilds the extension as files change.
```
When creating a new extension with `superset-extensions init <extension-name>`, the CLI generates a standardized folder structure:
When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure:
```
dataset_references/
dataset-references/
├── extension.json
├── frontend/
│ ├── src/
@@ -52,25 +52,33 @@ dataset_references/
│ └── package.json
├── backend/
│ ├── src/
└── dataset_references/
└── superset_extensions/
│ │ └── dataset_references/
│ ├── tests/
│ ├── pyproject.toml
│ └── requirements.txt
├── dist/
│ ├── manifest.json
│ ├── frontend
└── dist/
├── remoteEntry.d7a9225d042e4ccb6354.js
└── 900.038b20cdff6d49cfa8d9.js
└── dist/
├── remoteEntry.d7a9225d042e4ccb6354.js
└── 900.038b20cdff6d49cfa8d9.js
│ └── backend
│ └── dataset_references/
── __init__.py
├── api.py
└── entrypoint.py
├── dataset_references-1.0.0.supx
│ └── superset_extensions/
── dataset_references/
├── __init__.py
├── api.py
│ └── entrypoint.py
├── dataset-references-1.0.0.supx
└── README.md
```
**Note**: The extension ID (`dataset-references`) serves as the basis for all technical names:
- Directory name: `dataset-references` (kebab-case)
- Backend Python package: `dataset_references` (snake_case)
- Frontend package name: `dataset-references` (kebab-case)
- Module Federation name: `datasetReferences` (camelCase)
The `extension.json` file serves as the declared metadata for the extension, containing the extension's name, version, author, description, and a list of capabilities. This file is essential for the host application to understand how to load and manage the extension.
The `frontend` directory contains the source code for the frontend components of the extension, including React components, styles, and assets. The `webpack.config.js` file is used to configure Webpack for building the frontend code, while the `tsconfig.json` file defines the TypeScript configuration for the project. The `package.json` file specifies the dependencies and scripts for building and testing the frontend code.
@@ -87,7 +95,8 @@ The `extension.json` file contains all metadata necessary for the host applicati
```json
{
"name": "dataset_references",
"id": "dataset-references",
"name": "Dataset References",
"version": "1.0.0",
"frontend": {
"contributions": {
@@ -95,20 +104,21 @@ The `extension.json` file contains all metadata necessary for the host applicati
"sqllab": {
"panels": [
{
"id": "dataset_references.main",
"name": "Dataset references"
"id": "dataset-references.main",
"name": "Dataset References"
}
]
}
}
},
"moduleFederation": {
"exposes": ["./index"]
"exposes": ["./index"],
"name": "datasetReferences"
}
},
"backend": {
"entryPoints": ["dataset_references.entrypoint"],
"files": ["backend/src/dataset_references/**/*.py"]
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
}
}
```

View File

@@ -54,24 +54,33 @@ superset-extensions init
The CLI will prompt you for information:
```
Extension ID (unique identifier, alphanumeric only): hello_world
Extension name (human-readable display name): Hello World
Extension name (e.g. Hello World): Hello World
Extension ID [hello-world]: hello-world
Initial version [0.1.0]: 0.1.0
License [Apache-2.0]: Apache-2.0
Include frontend? [Y/n]: Y
Include backend? [Y/n]: Y
```
**Important**: The extension ID must be **globally unique** across all Superset extensions and serves as the basis for all technical identifiers:
- **Frontend package name**: `hello-world` (same as ID, used in package.json)
- **Webpack Module Federation name**: `helloWorld` (camelCase from ID)
- **Backend package name**: `hello_world` (snake_case from ID, used in project.toml)
- **Python namespace**: `superset_extensions.hello_world`
This ensures consistent naming across all technical components, even when the display name differs significantly from the ID. Since all technical names derive from the extension ID, choosing a unique ID automatically ensures all generated names are also unique, preventing conflicts between extensions.
This creates a complete project structure:
```
hello_world/
hello-world/
├── extension.json # Extension metadata and configuration
├── backend/ # Backend Python code
│ ├── src/
│ │ └── hello_world/
│ │ ── __init__.py
│ │ └── entrypoint.py # Backend registration
│ │ └── superset_extensions/
│ │ ── hello_world/
│ │ ├── __init__.py
│ │ └── entrypoint.py # Backend registration
│ └── pyproject.toml
└── frontend/ # Frontend TypeScript/React code
├── src/
@@ -87,7 +96,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
```json
{
"id": "hello_world",
"id": "hello-world",
"name": "Hello World",
"version": "0.1.0",
"license": "Apache-2.0",
@@ -97,7 +106,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
"sqllab": {
"panels": [
{
"id": "hello_world.main",
"id": "hello-world.main",
"name": "Hello World"
}
]
@@ -105,17 +114,20 @@ The generated `extension.json` contains basic metadata. Update it to register yo
}
},
"moduleFederation": {
"exposes": ["./index"]
"exposes": ["./index"],
"name": "helloWorld"
}
},
"backend": {
"entryPoints": ["hello_world.entrypoint"],
"files": ["backend/src/hello_world/**/*.py"]
"entryPoints": ["superset_extensions.hello_world.entrypoint"],
"files": ["backend/src/superset_extensions/hello_world/**/*.py"]
},
"permissions": ["can_read"]
}
```
**Note**: The `moduleFederation.name` is automatically derived from the extension ID (`hello-world``helloWorld`), and backend entry points use the full Python namespace (`superset_extensions.hello_world`).
**Key fields:**
- `frontend.contributions.views.sqllab.panels`: Registers your panel in SQL Lab
@@ -123,9 +135,9 @@ The generated `extension.json` contains basic metadata. Update it to register yo
## Step 4: Create Backend API
The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create an API endpoint.
The CLI generated a basic `backend/src/superset_extensions/hello_world/entrypoint.py`. We'll create an API endpoint.
**Create `backend/src/hello_world/api.py`**
**Create `backend/src/superset_extensions/hello_world/api.py`**
```python
from flask import Response
@@ -174,10 +186,10 @@ class HelloWorldAPI(RestApi):
- Extends `RestApi` from `superset_core.api.types.rest_api`
- Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
- Returns responses using `self.response(status_code, result=data)`
- The endpoint will be accessible at `/extensions/hello_world/message`
- The endpoint will be accessible at `/extensions/hello-world/message`
- OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically generate interactive API documentation at `/swagger/v1`, allowing developers to explore endpoints, understand schemas, and test the API directly from the browser
**Update `backend/src/hello_world/entrypoint.py`**
**Update `backend/src/superset_extensions/hello_world/entrypoint.py`**
Replace the generated print statement with API registration:
@@ -201,7 +213,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
```json
{
"name": "hello_world",
"name": "hello-world",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
@@ -330,7 +342,7 @@ const HelloWorldPanel: React.FC = () => {
const fetchMessage = async () => {
try {
const csrfToken = await authentication.getCSRFToken();
const response = await fetch('/extensions/hello_world/message', {
const response = await fetch('/extensions/hello-world/message', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -403,7 +415,7 @@ import HelloWorldPanel from './HelloWorldPanel';
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
core.registerViewProvider('hello_world.main', () => <HelloWorldPanel />),
core.registerViewProvider('hello-world.main', () => <HelloWorldPanel />),
);
};
@@ -413,7 +425,7 @@ export const deactivate = () => {};
**Key patterns:**
- `activate` function is called when the extension loads
- `core.registerViewProvider` registers the component with ID `hello_world.main` (matching `extension.json`)
- `core.registerViewProvider` registers the component with ID `hello-world.main` (matching `extension.json`)
- `authentication.getCSRFToken()` retrieves the CSRF token for API calls
- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
- `context.disposables.push()` ensures proper cleanup
@@ -444,7 +456,7 @@ This command automatically:
- `manifest.json` - Build metadata and asset references
- `frontend/dist/` - Built frontend assets (remoteEntry.js, chunks)
- `backend/` - Python source files
- Packages everything into `hello_world-0.1.0.supx` - a zip archive with the specific structure required by Superset
- Packages everything into `hello-world-0.1.0.supx` - a zip archive with the specific structure required by Superset
## Step 8: Deploy to Superset
@@ -469,7 +481,7 @@ EXTENSIONS_PATH = "/path/to/extensions/folder"
Copy your `.supx` file to the configured extensions path:
```bash
cp hello_world-0.1.0.supx /path/to/extensions/folder/
cp hello-world-0.1.0.supx /path/to/extensions/folder/
```
**Restart Superset**
@@ -500,7 +512,7 @@ Here's what happens when your extension loads:
4. **Module Federation**: Webpack loads your extension code and resolves `@apache-superset/core` to `window.superset`
5. **Activation**: `activate()` is called, registering your view provider
6. **Rendering**: When the user opens your panel, React renders `<HelloWorldPanel />`
7. **API call**: Component fetches data from `/extensions/hello_world/message`
7. **API call**: Component fetches data from `/extensions/hello-world/message`
8. **Backend response**: Your Flask API returns the hello world message
9. **Display**: Component shows the message to the user

View File

@@ -37,6 +37,11 @@ from pydantic import BaseModel, Field # noqa: I001
class ModuleFederationConfig(BaseModel):
"""Configuration for Webpack Module Federation."""
name: str | None = Field(
default=None,
description="Module Federation container name "
"(must be valid JavaScript identifier)",
)
exposes: list[str] = Field(
default_factory=list,
description="Modules exposed by this extension",

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",
)

View File

@@ -0,0 +1,22 @@
# 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.
class ExtensionNameError(Exception):
"""Raised when extension name validation fails."""
pass

View File

@@ -1,4 +1,4 @@
[project]
name = "{{ id }}"
name = "{{ backend_package }}"
version = "{{ version }}"
license = "{{ license }}"

View File

@@ -1 +1 @@
print("{{ name }} extension registered")
print("{{ display_name }} extension registered")

View File

@@ -11,14 +11,15 @@
"menus": {}
},
"moduleFederation": {
"name": "{{ mf_name }}",
"exposes": ["./index"]
}
},
{% endif -%}
{% if include_backend -%}
"backend": {
"entryPoints": ["{{ id }}.entrypoint"],
"files": ["backend/src/{{ id }}/**/*.py"]
"entryPoints": ["{{ backend_entry }}"],
"files": ["backend/src/superset_extensions/{{ backend_name }}/**/*.py"]
},
{% endif -%}
"permissions": []

View File

@@ -39,7 +39,7 @@ module.exports = (env, argv) => {
},
plugins: [
new ModuleFederationPlugin({
name: "{{ id }}",
name: "{{ mf_name }}",
filename: "remoteEntry.[contenthash].js",
exposes: {
"./index": "./src/index.tsx",

View File

@@ -0,0 +1,40 @@
# 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.
from typing import TypedDict
class ExtensionNames(TypedDict):
"""Type definition for extension name variants following platform conventions."""
# Extension name (e.g., "Hello World")
name: str
# Extension ID - kebab-case primary identifier and npm package name (e.g., "hello-world")
id: str
# Module Federation library - camelCase JS identifier (e.g., "helloWorld")
mf_name: str
# Backend package name - snake_case (e.g., "hello_world")
backend_name: str
# Full backend package (e.g., "superset_extensions.hello_world")
backend_package: str
# Backend entry point (e.g., "superset_extensions.hello_world.entrypoint")
backend_entry: str

View File

@@ -16,15 +16,75 @@
# under the License.
import json # noqa: TID251
import re
import sys
from pathlib import Path
from typing import Any
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
# Python reserved keywords to avoid in package names
PYTHON_KEYWORDS = {
"and",
"as",
"assert",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"exec",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"not",
"or",
"pass",
"print",
"raise",
"return",
"try",
"while",
"with",
"yield",
"False",
"None",
"True",
}
# npm reserved names to avoid
NPM_RESERVED = {
"node_modules",
"favicon.ico",
"www",
"http",
"https",
"ftp",
"localhost",
"package.json",
"npm",
"yarn",
"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]+)*$")
def read_toml(path: Path) -> dict[str, Any] | None:
if not path.is_file():
@@ -40,3 +100,205 @@ def read_json(path: Path) -> dict[str, Any] | None:
return None
return json.loads(path.read_text())
def _normalize_for_identifiers(name: str) -> str:
"""
Normalize display name to clean lowercase words.
Args:
name: Raw display name (e.g., "Hello World!")
Returns:
Normalized string (e.g., "hello world")
"""
# Convert to lowercase
normalized = name.lower().strip()
# Convert underscores and existing hyphens to spaces for consistent processing
normalized = normalized.replace("_", " ").replace("-", " ")
# Remove any non-alphanumeric characters except spaces
normalized = re.sub(r"[^a-z0-9\s]", "", normalized)
# Normalize whitespace (collapse multiple spaces, strip)
normalized = " ".join(normalized.split())
return normalized
def _normalized_to_kebab(normalized: str) -> str:
"""Convert normalized string to kebab-case."""
return normalized.replace(" ", "-")
def _normalized_to_snake(normalized: str) -> str:
"""Convert normalized string to snake_case."""
return normalized.replace(" ", "_")
def _normalized_to_camel(normalized: str) -> str:
"""Convert normalized string to camelCase."""
parts = normalized.split()
if not parts:
return ""
# First part lowercase, subsequent parts capitalized
return parts[0] + "".join(word.capitalize() for word in parts[1:])
def kebab_to_camel_case(kebab_name: str) -> str:
"""Convert kebab-case to camelCase (e.g., 'hello-world' -> 'helloWorld')."""
parts = kebab_name.split("-")
if not parts:
return ""
# First part lowercase, subsequent parts capitalized
return parts[0] + "".join(word.capitalize() for word in parts[1:])
def kebab_to_snake_case(kebab_name: str) -> str:
"""Convert kebab-case to snake_case (e.g., 'hello-world' -> 'hello_world')."""
return kebab_name.replace("-", "_")
def name_to_kebab_case(name: str) -> str:
"""Convert display name directly to kebab-case (e.g., 'Hello World' -> 'hello-world')."""
normalized = _normalize_for_identifiers(name)
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).
Raises:
ExtensionNameError: If name is invalid
"""
# Check if it starts with a number (invalid for Python identifiers)
if name[0].isdigit():
raise ExtensionNameError(f"Package name '{name}' cannot start with a number")
# Check if the first part (before any underscore) is a Python keyword
if (first_part := name.split("_")[0]) in PYTHON_KEYWORDS:
raise ExtensionNameError(
f"Package name cannot start with Python keyword '{first_part}'"
)
# Check if it's a valid Python identifier
if not name.replace("_", "a").isalnum():
raise ExtensionNameError(f"'{name}' is not a valid Python package name")
def validate_npm_package_name(name: str) -> None:
"""
Validate npm package name (kebab-case format).
Raises:
ExtensionNameError: If name is invalid
"""
if name.lower() in NPM_RESERVED:
raise ExtensionNameError(f"'{name}' is a reserved npm package name")
def generate_extension_names(name: str) -> ExtensionNames:
"""
Generate all extension name variants from display name input.
Flow: Display Name -> Generate ID -> Derive Technical Names from ID
Example: "Hello World" -> "hello-world" -> "helloWorld"/"hello_world" (from ID)
Returns:
ExtensionNames: Dictionary with all name variants
Raises:
ExtensionNameError: If any generated name is invalid
"""
# Validate and normalize the extension name
name = validate_extension_name(name)
# Generate ID from display name
kebab_name = name_to_kebab_case(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)
# Validate the generated names
validate_extension_id(kebab_name)
validate_python_package_name(snake_name)
validate_npm_package_name(kebab_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",
)

View File

@@ -58,25 +58,25 @@ def extension_params():
@pytest.fixture
def cli_input_both():
"""CLI input for creating extension with both frontend and backend."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
return "Test Extension\n\n0.1.0\nApache-2.0\ny\ny\n"
@pytest.fixture
def cli_input_frontend_only():
"""CLI input for creating extension with frontend only."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\nn\n"
return "Test Extension\n\n0.1.0\nApache-2.0\ny\nn\n"
@pytest.fixture
def cli_input_backend_only():
"""CLI input for creating extension with backend only."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\ny\n"
return "Test Extension\n\n0.1.0\nApache-2.0\nn\ny\n"
@pytest.fixture
def cli_input_neither():
"""CLI input for creating extension with neither frontend nor backend."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\nn\n"
return "Test Extension\n\n0.1.0\nApache-2.0\nn\nn\n"
@pytest.fixture

View File

@@ -43,16 +43,16 @@ def test_init_creates_extension_with_both_frontend_and_backend(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
assert (
"🎉 Extension Test Extension (ID: test_extension) initialized" in result.output
"🎉 Extension Test Extension (ID: test-extension) initialized" in result.output
)
# Verify directory structure
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
assert_directory_exists(extension_path, "main extension directory")
expected_structure = create_test_extension_structure(
isolated_filesystem,
"test_extension",
"test-extension",
include_frontend=True,
include_backend=True,
)
@@ -73,7 +73,7 @@ def test_init_creates_extension_with_frontend_only(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
assert_directory_exists(extension_path)
# Should have frontend directory and package.json
@@ -96,7 +96,7 @@ def test_init_creates_extension_with_backend_only(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
assert_directory_exists(extension_path)
# Should have backend directory and pyproject.toml
@@ -119,7 +119,7 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
assert_directory_exists(extension_path)
# Should only have extension.json
@@ -130,54 +130,52 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
assert not (extension_path / "backend").exists()
@pytest.mark.cli
def test_init_accepts_any_display_name(cli_runner, isolated_filesystem):
"""Test that init accepts any display name and generates proper ID."""
cli_input = "My Awesome Extension!\n\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, f"Should accept display name: {result.output}"
assert Path("my-awesome-extension").exists(), (
"Directory for generated ID should be created"
)
@pytest.mark.cli
def test_init_accepts_mixed_alphanumeric_name(cli_runner, isolated_filesystem):
"""Test that init accepts mixed alphanumeric display names."""
cli_input = "Tool 123\n\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, (
f"Mixed alphanumeric display name should be valid: {result.output}"
)
assert Path("tool-123").exists(), "Directory for 'tool-123' should be created"
@pytest.mark.cli
@pytest.mark.parametrize(
"invalid_name,expected_error",
"display_name,expected_id",
[
("test-extension", "must be alphanumeric"),
("test extension", "must be alphanumeric"),
("test.extension", "must be alphanumeric"),
("test@extension", "must be alphanumeric"),
("", "must be alphanumeric"),
("Test Extension", "test-extension"),
("My Tool v2", "my-tool-v2"),
("Dashboard Helper", "dashboard-helper"),
("Chart Builder Pro", "chart-builder-pro"),
],
)
def test_init_validates_extension_name(
cli_runner, isolated_filesystem, invalid_name, expected_error
):
"""Test that init validates extension names according to regex pattern."""
cli_input = f"{invalid_name}\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 1, (
f"Expected command to fail for invalid name '{invalid_name}'"
)
assert expected_error in result.output
@pytest.mark.cli
def test_init_accepts_numeric_extension_name(cli_runner, isolated_filesystem):
"""Test that init accepts numeric extension ids like '123'."""
cli_input = "123\n123\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, f"Numeric id '123' should be valid: {result.output}"
assert Path("123").exists(), "Directory for '123' should be created"
@pytest.mark.cli
@pytest.mark.parametrize(
"valid_id", ["test123", "TestExtension", "test_extension_123", "MyExt_1"]
)
def test_init_with_valid_alphanumeric_names(cli_runner, valid_id):
"""Test that init accepts various valid alphanumeric names."""
def test_init_with_various_display_names(cli_runner, display_name, expected_id):
"""Test that init accepts various display names and generates proper IDs."""
with cli_runner.isolated_filesystem():
cli_input = f"{valid_id}\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
cli_input = f"{display_name}\n\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, (
f"Valid name '{valid_id}' was rejected: {result.output}"
f"Valid display name '{display_name}' was rejected: {result.output}"
)
assert Path(expected_id).exists(), (
f"Directory for '{expected_id}' was not created"
)
assert Path(valid_id).exists(), f"Directory for '{valid_id}' was not created"
@pytest.mark.cli
@@ -186,7 +184,7 @@ def test_init_fails_when_directory_already_exists(
):
"""Test that init fails gracefully when target directory already exists."""
# Create the directory first
existing_dir = isolated_filesystem / "test_extension"
existing_dir = isolated_filesystem / "test-extension"
existing_dir.mkdir()
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
@@ -203,14 +201,14 @@ def test_extension_json_content_is_correct(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
extension_json_path = extension_path / "extension.json"
# Verify the JSON structure and values
assert_json_content(
extension_json_path,
{
"id": "test_extension",
"id": "test-extension",
"name": "Test Extension",
"version": "0.1.0",
"license": "Apache-2.0",
@@ -227,15 +225,20 @@ def test_extension_json_content_is_correct(
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
"name": "testExtension",
}
# Verify backend section exists and has correct structure
assert "backend" in content
backend = content["backend"]
assert "entryPoints" in backend
assert "files" in backend
assert backend["entryPoints"] == ["test_extension.entrypoint"]
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
assert backend["entryPoints"] == ["superset_extensions.test_extension.entrypoint"]
assert backend["files"] == [
"backend/src/superset_extensions/test_extension/**/*.py"
]
@pytest.mark.cli
@@ -246,14 +249,14 @@ def test_frontend_package_json_content_is_correct(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
package_json_path = extension_path / "frontend" / "package.json"
# Verify the package.json structure and values
assert_json_content(
package_json_path,
{
"name": "test_extension",
"name": "test-extension",
"version": "0.1.0",
"license": "Apache-2.0",
},
@@ -275,14 +278,14 @@ def test_backend_pyproject_toml_is_created(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
pyproject_path = extension_path / "backend" / "pyproject.toml"
assert_file_exists(pyproject_path, "backend pyproject.toml")
# Basic content verification (without parsing TOML for now)
content = pyproject_path.read_text()
assert "test_extension" in content
assert "superset_extensions.test_extension" in content
assert "0.1.0" in content
assert "Apache-2.0" in content
@@ -300,7 +303,7 @@ def test_init_command_output_messages(cli_runner, isolated_filesystem, cli_input
assert "Created .gitignore" in output
assert "Created frontend folder structure" in output
assert "Created backend folder structure" in output
assert "Extension Test Extension (ID: test_extension) initialized" in output
assert "Extension Test Extension (ID: test-extension) initialized" in output
@pytest.mark.cli
@@ -309,7 +312,7 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-extension"
gitignore_path = extension_path / ".gitignore"
assert_file_exists(gitignore_path, ".gitignore")
@@ -329,18 +332,18 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
@pytest.mark.cli
def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
"""Test init with custom version and license parameters."""
cli_input = "my_extension\nMy Extension\n2.1.0\nMIT\ny\nn\n"
cli_input = "My Extension\n\n2.1.0\nMIT\ny\nn\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0
extension_path = isolated_filesystem / "my_extension"
extension_path = isolated_filesystem / "my-extension"
extension_json_path = extension_path / "extension.json"
assert_json_content(
extension_json_path,
{
"id": "my_extension",
"id": "my-extension",
"name": "My Extension",
"version": "2.1.0",
"license": "MIT",
@@ -353,17 +356,17 @@ def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
"""Integration test for the complete init workflow."""
# Test the complete flow with realistic user input
cli_input = "awesome_charts\nAwesome Charts\n1.0.0\nApache-2.0\ny\ny\n"
cli_input = "Awesome Charts\n\n1.0.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
# Verify success
assert result.exit_code == 0
# Verify complete directory structure
extension_path = isolated_filesystem / "awesome_charts"
extension_path = isolated_filesystem / "awesome-charts"
expected_structure = create_test_extension_structure(
isolated_filesystem,
"awesome_charts",
"awesome-charts",
include_frontend=True,
include_backend=True,
)
@@ -374,16 +377,16 @@ def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
# Verify all generated files have correct content
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["id"] == "awesome_charts"
assert extension_json["id"] == "awesome-charts"
assert extension_json["name"] == "Awesome Charts"
assert extension_json["version"] == "1.0.0"
assert extension_json["license"] == "Apache-2.0"
package_json = load_json_file(extension_path / "frontend" / "package.json")
assert package_json["name"] == "awesome_charts"
assert package_json["name"] == "awesome-charts"
pyproject_content = (extension_path / "backend" / "pyproject.toml").read_text()
assert "awesome_charts" in pyproject_content
assert "superset_extensions.awesome_charts" in pyproject_content
# Non-interactive mode tests
@@ -395,7 +398,7 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
[
"init",
"--id",
"my_ext",
"my-ext",
"--name",
"My Extension",
"--version",
@@ -408,15 +411,15 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
)
assert result.exit_code == 0, f"Command failed with output: {result.output}"
assert "🎉 Extension My Extension (ID: my_ext) initialized" in result.output
assert "🎉 Extension My Extension (ID: my-ext) initialized" in result.output
extension_path = isolated_filesystem / "my_ext"
extension_path = isolated_filesystem / "my-ext"
assert_directory_exists(extension_path)
assert_directory_exists(extension_path / "frontend")
assert_directory_exists(extension_path / "backend")
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["id"] == "my_ext"
assert extension_json["id"] == "my-ext"
assert extension_json["name"] == "My Extension"
assert extension_json["version"] == "1.0.0"
assert extension_json["license"] == "MIT"
@@ -430,7 +433,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
[
"init",
"--id",
"frontend_ext",
"frontend-ext",
"--name",
"Frontend Extension",
"--version",
@@ -444,7 +447,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "frontend_ext"
extension_path = isolated_filesystem / "frontend-ext"
assert_directory_exists(extension_path / "frontend")
assert not (extension_path / "backend").exists()
@@ -457,7 +460,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
[
"init",
"--id",
"backend_ext",
"backend-ext",
"--name",
"Backend Extension",
"--version",
@@ -471,7 +474,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "backend_ext"
extension_path = isolated_filesystem / "backend-ext"
assert not (extension_path / "frontend").exists()
assert_directory_exists(extension_path / "backend")
@@ -485,7 +488,7 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
[
"init",
"--id",
"default_ext",
"default-ext",
"--name",
"Default Extension",
"--frontend",
@@ -496,7 +499,7 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "default_ext"
extension_path = isolated_filesystem / "default-ext"
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["version"] == "0.1.0"
assert extension_json["license"] == "Apache-2.0"
@@ -510,7 +513,7 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
[
"init",
"--id",
"invalid-id",
"invalid_name",
"--name",
"Invalid Extension",
"--frontend",
@@ -519,4 +522,4 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
)
assert result.exit_code == 1
assert "must be alphanumeric" in result.output
assert "Use lowercase letters, numbers, and hyphens only" in result.output

View File

@@ -0,0 +1,400 @@
# 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 pytest
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.utils import (
generate_extension_names,
kebab_to_camel_case,
kebab_to_snake_case,
name_to_kebab_case,
to_snake_case, # Keep this for backward compatibility testing only
validate_extension_name,
validate_extension_id,
validate_npm_package_name,
validate_python_package_name,
)
class TestNameTransformations:
"""Test name transformation functions."""
@pytest.mark.parametrize(
"display_name,expected",
[
("Hello World", "hello-world"),
("Data Explorer", "data-explorer"),
("My Extension", "my-extension"),
("hello-world", "hello-world"), # Already normalized
("Hello@World!", "helloworld"), # Special chars removed
(
"Data_Explorer",
"data-explorer",
), # Underscores become spaces then hyphens
("My Extension", "my-extension"), # Multiple spaces normalized
(" Hello World ", "hello-world"), # Trimmed
("API v2 Client", "api-v2-client"), # Numbers preserved
("Simple", "simple"), # Single word
],
)
def test_name_to_kebab_case(self, display_name, expected):
"""Test direct kebab case conversion from display names."""
assert name_to_kebab_case(display_name) == expected
@pytest.mark.parametrize(
"kebab_name,expected",
[
("hello-world", "helloWorld"),
("data-explorer", "dataExplorer"),
("my-extension", "myExtension"),
("api-v2-client", "apiV2Client"),
("simple", "simple"), # Single word
("chart-tool", "chartTool"),
("dashboard-helper", "dashboardHelper"),
],
)
def test_kebab_to_camel_case(self, kebab_name, expected):
"""Test kebab-case to camelCase conversion."""
assert kebab_to_camel_case(kebab_name) == expected
@pytest.mark.parametrize(
"kebab_name,expected",
[
("hello-world", "hello_world"),
("data-explorer", "data_explorer"),
("my-extension", "my_extension"),
("api-v2-client", "api_v2_client"),
("simple", "simple"), # Single word
("chart-tool", "chart_tool"),
("dashboard-helper", "dashboard_helper"),
],
)
def test_kebab_to_snake_case(self, kebab_name, expected):
"""Test kebab-case to snake_case conversion."""
assert kebab_to_snake_case(kebab_name) == expected
# Backward compatibility test for remaining legacy function
@pytest.mark.parametrize(
"input_name,expected",
[
("hello-world", "hello_world"),
("data-explorer", "data_explorer"),
("my-extension-name", "my_extension_name"),
],
)
def test_to_snake_case_legacy(self, input_name, expected):
"""Test legacy kebab-to-snake conversion function."""
assert to_snake_case(input_name) == expected
class TestValidation:
"""Test validation functions."""
@pytest.mark.parametrize(
"valid_display",
[
"Hello World",
"Data Explorer",
"My Extension",
"Simple",
" Extra Spaces ", # Gets normalized
],
)
def test_validate_extension_name_valid(self, valid_display):
"""Test valid display names."""
result = validate_extension_name(valid_display)
assert result # Should return normalized name
assert " " not in result # No double spaces
@pytest.mark.parametrize(
"invalid_display,error_match",
[
("", "cannot be empty"),
(" ", "cannot be empty"),
("@#$%", "must contain at least one letter or number"),
],
)
def test_validate_extension_name_invalid(self, invalid_display, error_match):
"""Test invalid extension names."""
with pytest.raises(ExtensionNameError, match=error_match):
validate_extension_name(invalid_display)
@pytest.mark.parametrize(
"valid_id",
[
"hello-world",
"data-explorer",
"myext",
"chart123",
"my-tool-v2",
"a", # Single character
"extension-with-many-parts",
],
)
def test_validate_extension_id_valid(self, valid_id):
"""Test valid extension IDs."""
# Should not raise exceptions
validate_extension_id(valid_id)
@pytest.mark.parametrize(
"invalid_id,error_match",
[
("", "cannot be empty"),
("Hello-World", "Use lowercase"),
("-hello", "cannot start with hyphens"),
("hello-", "cannot end with hyphens"),
("hello--world", "consecutive hyphens"),
],
)
def test_validate_extension_id_invalid(self, invalid_id, error_match):
"""Test invalid extension IDs."""
with pytest.raises(ExtensionNameError, match=error_match):
validate_extension_id(invalid_id)
@pytest.mark.parametrize(
"valid_package",
[
"hello_world",
"data_explorer",
"myext",
"test123",
"package_with_many_parts",
],
)
def test_validate_python_package_name_valid(self, valid_package):
"""Test valid Python package names."""
# Should not raise exceptions
validate_python_package_name(valid_package)
@pytest.mark.parametrize(
"keyword",
[
"class",
"import",
"def",
"return",
"if",
"else",
"for",
"while",
"try",
"except",
"finally",
"with",
"as",
"lambda",
"yield",
"False",
"None",
"True",
],
)
def test_validate_python_package_name_keywords(self, keyword):
"""Test that Python reserved keywords are rejected."""
with pytest.raises(
ExtensionNameError, match="Package name cannot start with Python keyword"
):
validate_python_package_name(keyword)
@pytest.mark.parametrize(
"invalid_package",
[
"hello-world", # Hyphens not allowed in Python identifiers
],
)
def test_validate_python_package_name_invalid(self, invalid_package):
"""Test invalid Python package names."""
with pytest.raises(ExtensionNameError, match="not a valid Python package"):
validate_python_package_name(invalid_package)
@pytest.mark.parametrize(
"valid_npm",
[
"hello-world",
"data-explorer",
"myext",
"package-with-many-parts",
],
)
def test_validate_npm_package_name_valid(self, valid_npm):
"""Test valid npm package names."""
# Should not raise exceptions
validate_npm_package_name(valid_npm)
@pytest.mark.parametrize(
"reserved_name",
["node_modules", "npm", "yarn", "package.json", "localhost", "favicon.ico"],
)
def test_validate_npm_package_name_reserved(self, reserved_name):
"""Test that npm reserved names are rejected."""
with pytest.raises(ExtensionNameError, match="reserved npm package name"):
validate_npm_package_name(reserved_name)
class TestNameGeneration:
"""Test complete name generation."""
@pytest.mark.parametrize(
"display_name,expected_kebab,expected_snake,expected_camel",
[
("Hello World", "hello-world", "hello_world", "helloWorld"),
("Data Explorer", "data-explorer", "data_explorer", "dataExplorer"),
("My Extension v2", "my-extension-v2", "my_extension_v2", "myExtensionV2"),
("Chart Tool", "chart-tool", "chart_tool", "chartTool"),
("Simple", "simple", "simple", "simple"),
("API v2 Client", "api-v2-client", "api_v2_client", "apiV2Client"),
(
"Dashboard Helper",
"dashboard-helper",
"dashboard_helper",
"dashboardHelper",
),
],
)
def test_generate_extension_names_complete_flow(
self, display_name, expected_kebab, expected_snake, expected_camel
):
"""Test complete name generation flow from display name to all variants."""
names = generate_extension_names(display_name)
# Test all transformations from single source
assert names["name"] == display_name
assert names["id"] == expected_kebab # Extension ID (kebab-case)
assert names["mf_name"] == expected_camel # Module Federation (camelCase)
assert names["backend_name"] == expected_snake # Python package (snake_case)
assert names["backend_package"] == f"superset_extensions.{expected_snake}"
assert (
names["backend_entry"] == f"superset_extensions.{expected_snake}.entrypoint"
)
@pytest.mark.parametrize(
"invalid_display",
[
"Class Helper", # Would create 'class_helper' - reserved keyword
"Import Tool", # Would create 'import_tool' - reserved keyword
"@#$%", # All special chars - becomes empty
"123 Tool", # Starts with number after kebab conversion
],
)
def test_generate_extension_names_invalid(self, invalid_display):
"""Test invalid name generation scenarios."""
with pytest.raises(ExtensionNameError):
generate_extension_names(invalid_display)
def test_generate_extension_names_unicode(self):
"""Test handling of unicode characters."""
names = generate_extension_names("Café Extension")
assert "é" not in names["id"]
assert names["id"] == "caf-extension"
assert names["name"] == "Café Extension" # Original preserved
def test_generate_extension_names_special_chars(self):
"""Test name generation with special characters."""
names = generate_extension_names("My@Extension!")
assert names["name"] == "My@Extension!"
assert names["id"] == "myextension"
assert names["backend_name"] == "myextension"
def test_generate_extension_names_case_preservation(self):
"""Test that display name case is preserved."""
names = generate_extension_names("CamelCase Extension")
assert names["name"] == "CamelCase Extension"
assert names["id"] == "camelcase-extension"
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
@pytest.mark.parametrize(
"edge_case",
[
"", # Empty string
" ", # Only spaces
"---", # Only hyphens
"___", # Only underscores
],
)
def test_empty_or_invalid_inputs(self, edge_case):
"""Test inputs that become empty or invalid after processing."""
with pytest.raises(ExtensionNameError):
generate_extension_names(edge_case)
def test_minimal_valid_input(self):
"""Test minimal valid input."""
names = generate_extension_names("A Extension")
assert names["id"] == "a-extension"
assert names["backend_name"] == "a_extension"
def test_numbers_handling(self):
"""Test handling of numbers in names."""
names = generate_extension_names("Tool 123 v2")
assert names["id"] == "tool-123-v2"
assert names["backend_name"] == "tool_123_v2"
def test_id_based_name_generation(self):
"""Test that technical names are derived from ID, not display name."""
# Simulate manual ExtensionNames construction with custom ID
display_name = "My Awesome Chart Builder Pro"
extension_id = "chart-builder" # Much shorter than display name
# Create names using ID-based generation (new behavior)
from superset_extensions_cli.types import ExtensionNames
names = ExtensionNames(
name=display_name,
id=extension_id,
mf_name=kebab_to_camel_case(extension_id), # From ID: "chartBuilder"
backend_name=kebab_to_snake_case(extension_id), # From ID: "chart_builder"
backend_package=f"superset_extensions.{kebab_to_snake_case(extension_id)}",
backend_entry=f"superset_extensions.{kebab_to_snake_case(extension_id)}.entrypoint",
)
# Verify technical names come from ID, not display name
assert names["name"] == "My Awesome Chart Builder Pro" # Display name preserved
assert names["id"] == "chart-builder" # Extension ID
assert (
names["mf_name"] == "chartBuilder"
) # From ID, not "myAwesomeChartBuilderPro"
assert (
names["backend_name"] == "chart_builder"
) # From ID, not "my_awesome_chart_builder_pro"
assert names["backend_package"] == "superset_extensions.chart_builder"
assert names["backend_entry"] == "superset_extensions.chart_builder.entrypoint"
def test_generate_names_uses_id_based_technical_names(self):
"""Test that generate_extension_names uses ID-based generation for technical names."""
display_name = "Hello World"
# Generated names should use ID-based technical name generation
names = generate_extension_names(display_name)
# Verify the ID was generated from display name
assert names["id"] == "hello-world"
# Verify technical names were generated from the ID, not original display name
assert names["mf_name"] == kebab_to_camel_case("hello-world") # "helloWorld"
assert names["backend_name"] == kebab_to_snake_case(
"hello-world"
) # "hello_world"
# For this simple case, the results are the same as before, but the path is different:
# Old path: Display Name -> camelCase directly
# New path: Display Name -> ID -> camelCase from ID
assert names["mf_name"] == "helloWorld"
assert names["backend_name"] == "hello_world"

View File

@@ -42,8 +42,12 @@ def jinja_env(templates_dir):
def template_context():
"""Default template context for testing."""
return {
"id": "test_extension",
"name": "Test Extension",
"id": "test-extension",
"mf_name": "testExtension",
"backend_name": "test_extension",
"backend_package": "superset_extensions.test_extension",
"backend_entry": "superset_extensions.test_extension.entrypoint",
"version": "0.1.0",
"license": "Apache-2.0",
"include_frontend": True,
@@ -64,7 +68,7 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
parsed = json.loads(rendered)
# Verify basic fields
assert parsed["id"] == "test_extension"
assert parsed["id"] == "test-extension"
assert parsed["name"] == "Test Extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
@@ -76,13 +80,18 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
"name": "testExtension",
}
# Verify backend section exists
assert "backend" in parsed
backend = parsed["backend"]
assert backend["entryPoints"] == ["test_extension.entrypoint"]
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
assert backend["entryPoints"] == ["superset_extensions.test_extension.entrypoint"]
assert backend["files"] == [
"backend/src/superset_extensions/test_extension/**/*.py"
]
@pytest.mark.unit
@@ -127,7 +136,7 @@ def test_frontend_package_json_template_renders_correctly(jinja_env, template_co
parsed = json.loads(rendered)
# Verify basic package info
assert parsed["name"] == "test_extension"
assert parsed["name"] == "test-extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
assert parsed["private"] is True
@@ -169,19 +178,34 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
# Template Rendering with Different Parameters Tests
@pytest.mark.unit
@pytest.mark.parametrize(
"id_,name",
"extension_id,name,backend_name",
[
("simple_extension", "Simple Extension"),
("MyExtension123", "My Extension 123"),
("complex_extension_name_123", "Complex Extension Name 123"),
("ext", "Ext"),
("simple-extension", "Simple Extension", "simple_extension"),
("my-extension-123", "My Extension 123", "my_extension_123"),
(
"complex-extension-name-123",
"Complex Extension Name 123",
"complex_extension_name_123",
),
("ext", "Ext", "ext"),
],
)
def test_template_rendering_with_different_ids(jinja_env, id_, name):
def test_template_rendering_with_different_ids(
jinja_env, extension_id, name, backend_name
):
"""Test templates render correctly with various extension ids/names."""
# Generate camelCase name for webpack from extension ID (new ID-based approach)
from superset_extensions_cli.utils import kebab_to_camel_case
mf_name = kebab_to_camel_case(extension_id)
context = {
"id": id_,
"id": extension_id,
"name": name,
"mf_name": mf_name,
"backend_name": backend_name,
"backend_package": f"superset_extensions.{backend_name}",
"backend_entry": f"superset_extensions.{backend_name}.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": True,
@@ -193,23 +217,27 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
rendered = template.render(context)
parsed = json.loads(rendered)
assert parsed["id"] == id_
assert parsed["id"] == extension_id
assert parsed["name"] == name
assert parsed["backend"]["entryPoints"] == [f"{id_}.entrypoint"]
assert parsed["backend"]["files"] == [f"backend/src/{id_}/**/*.py"]
assert parsed["backend"]["entryPoints"] == [
f"superset_extensions.{backend_name}.entrypoint"
]
assert parsed["backend"]["files"] == [
f"backend/src/superset_extensions/{backend_name}/**/*.py"
]
# Test package.json template
template = jinja_env.get_template("frontend/package.json.j2")
rendered = template.render(context)
parsed = json.loads(rendered)
assert parsed["name"] == id_
assert parsed["name"] == extension_id
# Test pyproject.toml template
template = jinja_env.get_template("backend/pyproject.toml.j2")
rendered = template.render(context)
assert id_ in rendered
assert f"superset_extensions.{backend_name}" in rendered
@pytest.mark.unit
@@ -219,6 +247,7 @@ def test_template_rendering_with_different_versions(jinja_env, version):
context = {
"id": "test_ext",
"name": "Test Extension",
"mf_name": "testExtension",
"version": version,
"license": "Apache-2.0",
"include_frontend": True,
@@ -248,6 +277,10 @@ def test_template_rendering_with_different_licenses(jinja_env, license_type):
context = {
"id": "test_ext",
"name": "Test Extension",
"mf_name": "testExtension",
"backend_name": "test_ext",
"backend_package": "superset_extensions.test_ext",
"backend_entry": "superset_extensions.test_ext.entrypoint",
"version": "1.0.0",
"license": license_type,
"include_frontend": True,
@@ -314,6 +347,10 @@ def test_template_context_edge_cases(jinja_env):
minimal_context = {
"id": "minimal",
"name": "Minimal",
"mf_name": "minimal",
"backend_name": "minimal",
"backend_package": "superset_extensions.minimal",
"backend_entry": "superset_extensions.minimal.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": False,

View File

@@ -160,7 +160,9 @@ class ExtensionsManager {
// Initialize Webpack module federation
// @ts-expect-error
await __webpack_init_sharing__('default');
const container = (window as any)[id];
// Use moduleFederationName (camelCase) for webpack container access, fallback to id for compatibility
const containerName = (extension as any).moduleFederationName || id;
const container = (window as any)[containerName];
// @ts-expect-error
await container.init(__webpack_share_scopes__.default);

View File

@@ -80,6 +80,34 @@ class InMemoryFinder(importlib.abc.MetaPathFinder):
self.modules[mod_name] = (content, is_package, full_path)
# Create namespace packages for all parent modules
# This ensures 'superset_extensions' namespace package exists
namespace_packages: set[str] = set()
for mod_name in list(self.modules.keys()):
parts = mod_name.split(".")
for i in range(1, len(parts)):
namespace_name = ".".join(parts[:i])
if namespace_name not in self.modules:
namespace_packages.add(namespace_name)
# Add namespace packages
for ns_name in namespace_packages:
# Create a virtual __init__.py path for the namespace package
if is_virtual_path:
ns_path = f"{source_base_path}/backend/src/"
f"{ns_name.replace('.', '/')}/__init__.py"
else:
ns_path = str(
Path(source_base_path)
/ "backend"
/ "src"
/ ns_name.replace(".", "/")
/ "__init__.py"
)
# Namespace packages have empty content
self.modules[ns_name] = (b"", True, ns_path)
def _get_module_name(self, file_path: str) -> Tuple[str, bool]:
parts = list(Path(file_path).parts)
is_package = parts[-1] == "__init__.py"
@@ -217,6 +245,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
{
"remoteEntry": remote_entry_url,
"exposedModules": module_federation.exposes,
"moduleFederationName": module_federation.name,
"contributions": frontend.contributions.model_dump(),
}
)