From 40f609fdce2c4dd9f73c487a2f1c96bbf77ef56a Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:21:35 -0800 Subject: [PATCH] fix(extensions): enforce correct naming conventions (#38167) --- .../extensions/development.md | 44 +- .../extensions/quick-start.md | 56 ++- .../src/superset_core/extensions/types.py | 5 + .../src/superset_extensions_cli/cli.py | 174 +++++++- .../src/superset_extensions_cli/exceptions.py | 22 + .../templates/backend/pyproject.toml.j2 | 2 +- .../backend/src/package/entrypoint.py.j2 | 2 +- .../templates/extension.json.j2 | 5 +- .../templates/frontend/webpack.config.js.j2 | 2 +- .../src/superset_extensions_cli/types.py | 40 ++ .../src/superset_extensions_cli/utils.py | 262 ++++++++++++ superset-extensions-cli/tests/conftest.py | 8 +- .../tests/test_cli_init.py | 157 +++---- .../tests/test_name_transformations.py | 400 ++++++++++++++++++ .../tests/test_templates.py | 73 +++- .../src/extensions/ExtensionsManager.ts | 4 +- superset/extensions/utils.py | 29 ++ 17 files changed, 1118 insertions(+), 167 deletions(-) create mode 100644 superset-extensions-cli/src/superset_extensions_cli/exceptions.py create mode 100644 superset-extensions-cli/src/superset_extensions_cli/types.py create mode 100644 superset-extensions-cli/tests/test_name_transformations.py 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 `, 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', () => ), + core.registerViewProvider('hello-world.main', () => ), ); }; @@ -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 `` -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)", +) +@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", ) diff --git a/superset-extensions-cli/src/superset_extensions_cli/exceptions.py b/superset-extensions-cli/src/superset_extensions_cli/exceptions.py new file mode 100644 index 00000000000..8d03d129f4f --- /dev/null +++ b/superset-extensions-cli/src/superset_extensions_cli/exceptions.py @@ -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 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/types.py b/superset-extensions-cli/src/superset_extensions_cli/types.py new file mode 100644 index 00000000000..c7b774ab59c --- /dev/null +++ b/superset-extensions-cli/src/superset_extensions_cli/types.py @@ -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 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 @@ -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 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(), } )