This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 40f609fdce2 fix(extensions): enforce correct naming conventions
(#38167)
40f609fdce2 is described below
commit 40f609fdce2c4dd9f73c487a2f1c96bbf77ef56a
Author: Ville Brofeldt <[email protected]>
AuthorDate: Mon Feb 23 08:21:35 2026 -0800
fix(extensions): enforce correct naming conventions (#38167)
---
docs/developer_portal/extensions/development.md | 44 ++-
docs/developer_portal/extensions/quick-start.md | 56 +--
.../src/superset_core/extensions/types.py | 5 +
.../src/superset_extensions_cli/cli.py | 174 +++++++--
.../{utils.py => exceptions.py} | 26 +-
.../templates/backend/pyproject.toml.j2 | 2 +-
.../templates/backend/src/package/entrypoint.py.j2 | 2 +-
.../templates/extension.json.j2 | 5 +-
.../templates/frontend/webpack.config.js.j2 | 2 +-
.../superset_extensions_cli/{utils.py => types.py} | 34 +-
.../src/superset_extensions_cli/utils.py | 262 ++++++++++++++
superset-extensions-cli/tests/conftest.py | 8 +-
superset-extensions-cli/tests/test_cli_init.py | 139 +++----
.../tests/test_name_transformations.py | 400 +++++++++++++++++++++
superset-extensions-cli/tests/test_templates.py | 73 +++-
.../src/extensions/ExtensionsManager.ts | 4 +-
superset/extensions/utils.py | 29 ++
17 files changed, 1066 insertions(+), 199 deletions(-)
diff --git a/docs/developer_portal/extensions/development.md
b/docs/developer_portal/extensions/development.md
index 12fe9351ce0..a6c143a3796 100644
--- a/docs/developer_portal/extensions/development.md
+++ b/docs/developer_portal/extensions/development.md
@@ -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"]
}
}
```
diff --git a/docs/developer_portal/extensions/quick-start.md
b/docs/developer_portal/extensions/quick-start.md
index 60cf23acf9a..a3a08566f1b 100644
--- a/docs/developer_portal/extensions/quick-start.md
+++ b/docs/developer_portal/extensions/quick-start.md
@@ -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
diff --git a/superset-core/src/superset_core/extensions/types.py
b/superset-core/src/superset_core/extensions/types.py
index 82f4d5a85eb..41ab83d40c9 100644
--- a/superset-core/src/superset_core/extensions/types.py
+++ b/superset-core/src/superset_core/extensions/types.py
@@ -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",
diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py
b/superset-extensions-cli/src/superset_extensions_cli/cli.py
index 8f15df6da64..6f333ec6948 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/cli.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py
@@ -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)",
+)
[email protected](
+ "--name", "name_opt", default=None, help="Extension display name (e.g.
Hello World)"
)
[email protected]("--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",
)
diff --git a/superset-extensions-cli/src/superset_extensions_cli/utils.py
b/superset-extensions-cli/src/superset_extensions_cli/exceptions.py
similarity index 61%
copy from superset-extensions-cli/src/superset_extensions_cli/utils.py
copy to superset-extensions-cli/src/superset_extensions_cli/exceptions.py
index 7dc739d9dba..8d03d129f4f 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/utils.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/exceptions.py
@@ -15,28 +15,8 @@
# specific language governing permissions and limitations
# under the License.
-import json # noqa: TID251
-import sys
-from pathlib import Path
-from typing import Any
-if sys.version_info >= (3, 11):
- import tomllib
-else:
- import tomli as tomllib
+class ExtensionNameError(Exception):
+ """Raised when extension name validation fails."""
-
-def read_toml(path: Path) -> dict[str, Any] | None:
- if not path.is_file():
- return None
-
- with path.open("rb") as f:
- return tomllib.load(f)
-
-
-def read_json(path: Path) -> dict[str, Any] | None:
- path = Path(path)
- if not path.is_file():
- return None
-
- return json.loads(path.read_text())
+ pass
diff --git
a/superset-extensions-cli/src/superset_extensions_cli/templates/backend/pyproject.toml.j2
b/superset-extensions-cli/src/superset_extensions_cli/templates/backend/pyproject.toml.j2
index cbe78bd8b29..135f45e28a9 100644
---
a/superset-extensions-cli/src/superset_extensions_cli/templates/backend/pyproject.toml.j2
+++
b/superset-extensions-cli/src/superset_extensions_cli/templates/backend/pyproject.toml.j2
@@ -1,4 +1,4 @@
[project]
-name = "{{ id }}"
+name = "{{ backend_package }}"
version = "{{ version }}"
license = "{{ license }}"
diff --git
a/superset-extensions-cli/src/superset_extensions_cli/templates/backend/src/package/entrypoint.py.j2
b/superset-extensions-cli/src/superset_extensions_cli/templates/backend/src/package/entrypoint.py.j2
index 2ff158bf997..2adca9ab7a9 100644
---
a/superset-extensions-cli/src/superset_extensions_cli/templates/backend/src/package/entrypoint.py.j2
+++
b/superset-extensions-cli/src/superset_extensions_cli/templates/backend/src/package/entrypoint.py.j2
@@ -1 +1 @@
-print("{{ name }} extension registered")
+print("{{ display_name }} extension registered")
diff --git
a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
index fa3e1f93b2a..4f4fb32e169 100644
---
a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
+++
b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
@@ -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": []
diff --git
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
index 25f1b5c0be6..5a2342ed69f 100644
---
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
+++
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
@@ -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",
diff --git a/superset-extensions-cli/src/superset_extensions_cli/utils.py
b/superset-extensions-cli/src/superset_extensions_cli/types.py
similarity index 53%
copy from superset-extensions-cli/src/superset_extensions_cli/utils.py
copy to superset-extensions-cli/src/superset_extensions_cli/types.py
index 7dc739d9dba..c7b774ab59c 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/utils.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/types.py
@@ -15,28 +15,26 @@
# specific language governing permissions and limitations
# under the License.
-import json # noqa: TID251
-import sys
-from pathlib import Path
-from typing import Any
+from typing import TypedDict
-if sys.version_info >= (3, 11):
- import tomllib
-else:
- import tomli as tomllib
+class ExtensionNames(TypedDict):
+ """Type definition for extension name variants following platform
conventions."""
-def read_toml(path: Path) -> dict[str, Any] | None:
- if not path.is_file():
- return None
+ # Extension name (e.g., "Hello World")
+ name: str
- with path.open("rb") as f:
- return tomllib.load(f)
+ # 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
-def read_json(path: Path) -> dict[str, Any] | None:
- path = Path(path)
- if not path.is_file():
- return None
+ # Backend package name - snake_case (e.g., "hello_world")
+ backend_name: str
- return json.loads(path.read_text())
+ # 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
diff --git a/superset-extensions-cli/src/superset_extensions_cli/utils.py
b/superset-extensions-cli/src/superset_extensions_cli/utils.py
index 7dc739d9dba..9aa17e09365 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/utils.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/utils.py
@@ -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",
+ )
diff --git a/superset-extensions-cli/tests/conftest.py
b/superset-extensions-cli/tests/conftest.py
index fdb4cd17b90..7292428c4ee 100644
--- a/superset-extensions-cli/tests/conftest.py
+++ b/superset-extensions-cli/tests/conftest.py
@@ -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
diff --git a/superset-extensions-cli/tests/test_cli_init.py
b/superset-extensions-cli/tests/test_cli_init.py
index 8bf00d3c778..78e0d6888c7 100644
--- a/superset-extensions-cli/tests/test_cli_init.py
+++ b/superset-extensions-cli/tests/test_cli_init.py
@@ -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
@@ -131,53 +131,51 @@ def
test_init_creates_extension_with_neither_frontend_nor_backend(
@pytest.mark.cli
[email protected](
- "invalid_name,expected_error",
- [
- ("test-extension", "must be alphanumeric"),
- ("test extension", "must be alphanumeric"),
- ("test.extension", "must be alphanumeric"),
- ("test@extension", "must be alphanumeric"),
- ("", "must be alphanumeric"),
- ],
-)
-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"
+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 == 1, (
- f"Expected command to fail for invalid name '{invalid_name}'"
+ 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"
)
- 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"
+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"Numeric id '123' should be valid:
{result.output}"
- assert Path("123").exists(), "Directory for '123' should be created"
+ 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(
- "valid_id", ["test123", "TestExtension", "test_extension_123", "MyExt_1"]
+ "display_name,expected_id",
+ [
+ ("Test Extension", "test-extension"),
+ ("My Tool v2", "my-tool-v2"),
+ ("Dashboard Helper", "dashboard-helper"),
+ ("Chart Builder Pro", "chart-builder-pro"),
+ ],
)
-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
diff --git a/superset-extensions-cli/tests/test_name_transformations.py
b/superset-extensions-cli/tests/test_name_transformations.py
new file mode 100644
index 00000000000..d376c04e170
--- /dev/null
+++ b/superset-extensions-cli/tests/test_name_transformations.py
@@ -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"
diff --git a/superset-extensions-cli/tests/test_templates.py
b/superset-extensions-cli/tests/test_templates.py
index 249d36034e7..30d2115be74 100644
--- a/superset-extensions-cli/tests/test_templates.py
+++ b/superset-extensions-cli/tests/test_templates.py
@@ -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,
diff --git a/superset-frontend/src/extensions/ExtensionsManager.ts
b/superset-frontend/src/extensions/ExtensionsManager.ts
index 2c606553ed7..d546b72debc 100644
--- a/superset-frontend/src/extensions/ExtensionsManager.ts
+++ b/superset-frontend/src/extensions/ExtensionsManager.ts
@@ -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);
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 883c9114728..1350d92c785 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -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(),
}
)