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 35c135852e feat(extensions): add mandatory publisher field to
extension metadata (#38200)
35c135852e is described below
commit 35c135852e858297c3e54c925919658b9310bb50
Author: Ville Brofeldt <[email protected]>
AuthorDate: Tue Feb 24 09:42:17 2026 -0800
feat(extensions): add mandatory publisher field to extension metadata
(#38200)
---
docs/developer_portal/extensions/quick-start.md | 73 +-
.../src/superset_core/extensions/constants.py | 33 +-
.../src/superset_core/extensions/types.py | 26 +-
.../src/superset_extensions_cli/cli.py | 197 +++--
.../templates/extension.json.j2 | 8 +-
.../templates/frontend/package.json.j2 | 2 +-
.../templates/frontend/webpack.config.js.j2 | 2 +-
.../src/superset_extensions_cli/types.py | 25 +-
.../src/superset_extensions_cli/utils.py | 226 ++++--
superset-extensions-cli/tests/conftest.py | 39 +-
superset-extensions-cli/tests/test_cli_build.py | 85 ++-
superset-extensions-cli/tests/test_cli_bundle.py | 19 +-
superset-extensions-cli/tests/test_cli_dev.py | 29 +-
superset-extensions-cli/tests/test_cli_init.py | 158 ++--
.../tests/test_name_transformations.py | 834 ++++++++++++---------
superset-extensions-cli/tests/test_templates.py | 138 ++--
superset/extensions/api.py | 35 +-
superset/extensions/utils.py | 11 +-
tests/unit_tests/extensions/test_types.py | 108 ++-
19 files changed, 1221 insertions(+), 827 deletions(-)
diff --git a/docs/developer_portal/extensions/quick-start.md
b/docs/developer_portal/extensions/quick-start.md
index a3a08566f1..f14e0be23b 100644
--- a/docs/developer_portal/extensions/quick-start.md
+++ b/docs/developer_portal/extensions/quick-start.md
@@ -51,36 +51,39 @@ Use the CLI to scaffold a new extension project. Extensions
can include frontend
superset-extensions init
```
-The CLI will prompt you for information:
+The CLI will prompt you for information using a three-step publisher workflow:
```
-Extension name (e.g. Hello World): Hello World
-Extension ID [hello-world]: hello-world
+Extension display name: Hello World
+Extension name (hello-world): hello-world
+Publisher (e.g., my-org): my-org
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`
+**Publisher Namespaces**: Extensions use organizational namespaces similar to
VS Code extensions, providing collision-safe naming across organizations:
+- **NPM package**: `@my-org/hello-world` (scoped package for frontend
distribution)
+- **Module Federation name**: `myOrg_helloWorld` (collision-safe JavaScript
identifier)
+- **Backend package**: `my_org-hello_world` (collision-safe Python
distribution name)
+- **Python namespace**: `superset_extensions.my_org.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 approach ensures that extensions from different organizations cannot
conflict, even if they use the same technical name (e.g., both
`acme.dashboard-widgets` and `corp.dashboard-widgets` can coexist).
This creates a complete project structure:
```
-hello-world/
+my-org.hello-world/
├── extension.json # Extension metadata and configuration
├── backend/ # Backend Python code
│ ├── src/
│ │ └── superset_extensions/
-│ │ └── hello_world/
+│ │ └── my_org/
│ │ ├── __init__.py
-│ │ └── entrypoint.py # Backend registration
+│ │ └── hello_world/
+│ │ ├── __init__.py
+│ │ └── entrypoint.py # Backend registration
│ └── pyproject.toml
└── frontend/ # Frontend TypeScript/React code
├── src/
@@ -96,8 +99,9 @@ The generated `extension.json` contains basic metadata.
Update it to register yo
```json
{
- "id": "hello-world",
- "name": "Hello World",
+ "publisher": "my-org",
+ "name": "hello-world",
+ "displayName": "Hello World",
"version": "0.1.0",
"license": "Apache-2.0",
"frontend": {
@@ -106,7 +110,7 @@ The generated `extension.json` contains basic metadata.
Update it to register yo
"sqllab": {
"panels": [
{
- "id": "hello-world.main",
+ "id": "my-org.hello-world.main",
"name": "Hello World"
}
]
@@ -115,29 +119,32 @@ The generated `extension.json` contains basic metadata.
Update it to register yo
},
"moduleFederation": {
"exposes": ["./index"],
- "name": "helloWorld"
+ "name": "myOrg_helloWorld"
}
},
"backend": {
- "entryPoints": ["superset_extensions.hello_world.entrypoint"],
- "files": ["backend/src/superset_extensions/hello_world/**/*.py"]
+ "entryPoints": ["superset_extensions.my_org.hello_world.entrypoint"],
+ "files": ["backend/src/superset_extensions/my_org/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`).
+**Note**: The `moduleFederation.name` uses collision-safe naming
(`myOrg_helloWorld`), and backend entry points use the full nested Python
namespace (`superset_extensions.my_org.hello_world`).
**Key fields:**
+- `publisher`: Organizational namespace for the extension
+- `name`: Technical identifier (kebab-case)
+- `displayName`: Human-readable name shown to users
- `frontend.contributions.views.sqllab.panels`: Registers your panel in SQL Lab
- `backend.entryPoints`: Python modules to load eagerly when extension starts
## Step 4: Create Backend API
-The CLI generated a basic
`backend/src/superset_extensions/hello_world/entrypoint.py`. We'll create an
API endpoint.
+The CLI generated a basic
`backend/src/superset_extensions/my_org/hello_world/entrypoint.py`. We'll
create an API endpoint.
-**Create `backend/src/superset_extensions/hello_world/api.py`**
+**Create `backend/src/superset_extensions/my_org/hello_world/api.py`**
```python
from flask import Response
@@ -186,10 +193,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/my-org/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/superset_extensions/hello_world/entrypoint.py`**
+**Update `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`**
Replace the generated print statement with API registration:
@@ -213,7 +220,7 @@ The `@apache-superset/core` package must be listed in both
`peerDependencies` (t
```json
{
- "name": "hello-world",
+ "name": "@my-org/hello-world",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
@@ -264,7 +271,7 @@ module.exports = (env, argv) => {
chunkFilename: "[name].[contenthash].js",
clean: true,
path: path.resolve(__dirname, "dist"),
- publicPath: `/api/v1/extensions/${packageConfig.name}/`,
+ publicPath: `/api/v1/extensions/my-org/hello-world/`,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
@@ -285,7 +292,7 @@ module.exports = (env, argv) => {
},
plugins: [
new ModuleFederationPlugin({
- name: packageConfig.name,
+ name: "myOrg_helloWorld",
filename: "remoteEntry.[contenthash].js",
exposes: {
"./index": "./src/index.tsx",
@@ -342,7 +349,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/my-org/hello-world/message',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -415,7 +422,7 @@ import HelloWorldPanel from './HelloWorldPanel';
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
- core.registerViewProvider('hello-world.main', () => <HelloWorldPanel />),
+ core.registerViewProvider('my-org.hello-world.main', () =>
<HelloWorldPanel />),
);
};
@@ -425,9 +432,9 @@ 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
`my-org.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
+- Fetch calls to `/extensions/{publisher}/{name}/{endpoint}` reach your
backend API
- `context.disposables.push()` ensures proper cleanup
## Step 6: Install Dependencies
@@ -456,7 +463,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 `my-org.hello-world-0.1.0.supx` - a zip archive
with the specific structure required by Superset
## Step 8: Deploy to Superset
@@ -481,7 +488,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 my-org.hello-world-0.1.0.supx /path/to/extensions/folder/
```
**Restart Superset**
@@ -512,7 +519,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/my-org/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-extensions-cli/src/superset_extensions_cli/types.py
b/superset-core/src/superset_core/extensions/constants.py
similarity index 52%
copy from superset-extensions-cli/src/superset_extensions_cli/types.py
copy to superset-core/src/superset_core/extensions/constants.py
index c7b774ab59..965270d04c 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/types.py
+++ b/superset-core/src/superset_core/extensions/constants.py
@@ -15,26 +15,21 @@
# specific language governing permissions and limitations
# under the License.
-from typing import TypedDict
+"""
+Constants for extension validation and naming.
+"""
+# Publisher validation pattern: lowercase letters, numbers, hyphens; must
start with
+# letter; no consecutive hyphens or trailing hyphens
+PUBLISHER_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
-class ExtensionNames(TypedDict):
- """Type definition for extension name variants following platform
conventions."""
+# Technical name validation pattern: lowercase letters, numbers, hyphens; must
start
+# with letter; no consecutive hyphens or trailing hyphens
+TECHNICAL_NAME_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
- # Extension name (e.g., "Hello World")
- name: str
+# Display name validation pattern: must start with letter, can contain letters,
+# numbers, spaces, hyphens, underscores, dots
+DISPLAY_NAME_PATTERN = r"^[a-zA-Z][a-zA-Z0-9\s\-_\.]*$"
- # 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
+# Version pattern for semantic versioning
+VERSION_PATTERN = r"^\d+\.\d+\.\d+$"
diff --git a/superset-core/src/superset_core/extensions/types.py
b/superset-core/src/superset_core/extensions/types.py
index 41ab83d40c..49f3eefe74 100644
--- a/superset-core/src/superset_core/extensions/types.py
+++ b/superset-core/src/superset_core/extensions/types.py
@@ -29,6 +29,13 @@ from typing import Any
from pydantic import BaseModel, Field # noqa: I001
+from superset_core.extensions.constants import (
+ DISPLAY_NAME_PATTERN,
+ PUBLISHER_PATTERN,
+ TECHNICAL_NAME_PATTERN,
+ VERSION_PATTERN,
+)
+
# =============================================================================
# Shared components
# =============================================================================
@@ -102,20 +109,28 @@ class ContributionConfig(BaseModel):
class BaseExtension(BaseModel):
"""Base fields shared by ExtensionConfig and Manifest."""
- id: str = Field(
+ publisher: str = Field(
...,
- description="Unique extension identifier",
+ description="Publisher/organization namespace",
min_length=1,
+ pattern=PUBLISHER_PATTERN,
)
name: str = Field(
+ ...,
+ description="Technical extension identifier",
+ min_length=1,
+ pattern=TECHNICAL_NAME_PATTERN,
+ )
+ displayName: str = Field( # noqa: N815
...,
description="Human-readable extension name",
min_length=1,
+ pattern=DISPLAY_NAME_PATTERN,
)
version: str = Field(
default="0.0.0",
description="Semantic version string",
- pattern=r"^\d+\.\d+\.\d+$",
+ pattern=VERSION_PATTERN,
)
license: str | None = Field(
default=None,
@@ -221,6 +236,11 @@ class Manifest(BaseExtension):
This file is generated by the build tool from extension.json.
"""
+ id: str = Field(
+ ...,
+ description="Composite extension ID (publisher.name)",
+ min_length=1,
+ )
frontend: ManifestFrontend | None = Field(
default=None,
description="Frontend manifest",
diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py
b/superset-extensions-cli/src/superset_extensions_cli/cli.py
index 6f333ec694..b26a0eb87d 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/cli.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py
@@ -42,15 +42,13 @@ 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,
+ suggest_technical_name,
+ validate_display_name,
+ validate_publisher,
+ validate_technical_name,
)
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
@@ -150,6 +148,9 @@ def build_manifest(cwd: Path, remote_entry: str | None) ->
Manifest:
extension = ExtensionConfig.model_validate(extension_data)
+ # Generate composite ID from publisher and name
+ composite_id = f"{extension.publisher}.{extension.name}"
+
frontend: ManifestFrontend | None = None
if extension.frontend and remote_entry:
frontend = ManifestFrontend(
@@ -163,8 +164,10 @@ def build_manifest(cwd: Path, remote_entry: str | None) ->
Manifest:
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
return Manifest(
- id=extension.id,
+ id=composite_id,
+ publisher=extension.publisher,
name=extension.name,
+ displayName=extension.displayName,
version=extension.version,
permissions=extension.permissions,
dependencies=extension.dependencies,
@@ -416,126 +419,110 @@ 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
+def prompt_for_extension_info(
+ display_name_opt: str | None,
+ publisher_opt: str | None,
+ technical_name_opt: str | None,
) -> ExtensionNames:
"""
- Prompt for extension name with graceful validation and re-prompting.
+ Prompt for extension info 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)
+ publisher_opt: Publisher provided via CLI option (if any)
+ technical_name_opt: Technical name 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:
+ # Step 1: Get display name
+ if display_name_opt:
+ display_name = display_name_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",
- )
+ display_name = validate_display_name(display_name)
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
+ else:
+ while True:
+ display_name = click.prompt("Extension display name", type=str)
+ try:
+ display_name = validate_display_name(display_name)
+ break
+ except ExtensionNameError as e:
+ click.secho(f"❌ {e}", fg="red")
+
+ # Step 2: Get technical name (with suggestion from display name)
+ if technical_name_opt:
+ technical_name = technical_name_opt
try:
- validate_extension_id(id_opt)
+ validate_technical_name(technical_name)
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)
+ # Suggest technical name from display name
try:
- suggested_names = generate_extension_names(display_name)
- suggested_id = suggested_names["id"]
+ suggested_technical = suggest_technical_name(display_name)
except ExtensionNameError:
- suggested_id = to_kebab_case(display_name)
-
- extension_id = click.prompt("Extension ID", default=suggested_id,
type=str)
+ suggested_technical = "extension"
- # 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",
+ while True:
+ technical_name = click.prompt(
+ f"Extension name ({suggested_technical})",
+ default=suggested_technical,
+ type=str,
)
-
+ try:
+ validate_technical_name(technical_name)
+ break
+ except ExtensionNameError as e:
+ click.secho(f"❌ {e}", fg="red")
+
+ # Step 3: Get publisher
+ if publisher_opt:
+ publisher = publisher_opt
+ try:
+ validate_publisher(publisher)
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)
+ sys.exit(1)
+ else:
+ while True:
+ publisher = click.prompt("Publisher (e.g., my-org)", type=str)
+ try:
+ validate_publisher(publisher)
+ break
+ except ExtensionNameError as e:
+ click.secho(f"❌ {e}", fg="red")
+
+ # Generate all name variants
+ try:
+ return generate_extension_names(display_name, publisher,
technical_name)
+ except ExtensionNameError as e:
+ click.secho(f"❌ {e}", fg="red")
+ sys.exit(1)
@app.command()
@click.option(
- "--id",
- "id_opt",
+ "--publisher",
+ "publisher_opt",
+ default=None,
+ help="Publisher namespace (kebab-case, e.g. my-org)",
+)
[email protected](
+ "--name",
+ "name_opt",
default=None,
- help="Extension ID (kebab-case, e.g. hello-world)",
+ help="Technical extension name (kebab-case, e.g. dashboard-widgets)",
)
@click.option(
- "--name", "name_opt", default=None, help="Extension display name (e.g.
Hello World)"
+ "--display-name",
+ "display_name_opt",
+ default=None,
+ help="Extension display name (e.g. Dashboard Widgets)",
)
@click.option(
"--version", "version_opt", default=None, help="Initial version (default:
0.1.0)"
@@ -550,15 +537,16 @@ def prompt_for_extension_name(
"--backend/--no-backend", "backend_opt", default=None, help="Include
backend"
)
def init(
- id_opt: str | None,
+ publisher_opt: str | None,
name_opt: str | None,
+ display_name_opt: str | None,
version_opt: str | None,
license_opt: str | None,
frontend_opt: bool | None,
backend_opt: bool | None,
) -> None:
# Get extension names with graceful validation
- names = prompt_for_extension_name(name_opt, id_opt)
+ names = prompt_for_extension_info(display_name_opt, publisher_opt,
name_opt)
version = version_opt or click.prompt("Initial version", default="0.1.0")
license_ = license_opt or click.prompt("License", default="Apache-2.0")
@@ -618,7 +606,7 @@ def init(
(frontend_src_dir / "index.tsx").write_text(index_tsx)
click.secho("✅ Created frontend folder structure", fg="green")
- # Initialize backend files with superset_extensions namespace
+ # Initialize backend files with superset_extensions.publisher.name
structure
if include_backend:
backend_dir = target_dir / "backend"
backend_dir.mkdir()
@@ -629,8 +617,14 @@ def init(
namespace_dir = backend_src_dir / "superset_extensions"
namespace_dir.mkdir()
- # Create extension package directory
- extension_package_dir = namespace_dir / names["backend_name"]
+ # Create publisher directory (e.g., superset_extensions/my_org)
+ publisher_snake = kebab_to_snake_case(names["publisher"])
+ publisher_dir = namespace_dir / publisher_snake
+ publisher_dir.mkdir()
+
+ # Create extension package directory (e.g.,
superset_extensions/my_org/dashboard_widgets)
+ name_snake = kebab_to_snake_case(names["name"])
+ extension_package_dir = publisher_dir / name_snake
extension_package_dir.mkdir()
# backend files
@@ -639,6 +633,7 @@ def init(
# Namespace package __init__.py (empty for namespace)
(namespace_dir / "__init__.py").write_text("")
+ (publisher_dir / "__init__.py").write_text("")
# Extension package files
init_py =
env.get_template("backend/src/package/__init__.py.j2").render(ctx)
@@ -651,7 +646,7 @@ def init(
click.secho("✅ Created backend folder structure", fg="green")
click.secho(
- f"🎉 Extension {names['name']} (ID: {names['id']}) initialized at
{target_dir}",
+ f"🎉 Extension {names['display_name']} (ID: {names['id']}) initialized
at {target_dir}",
fg="cyan",
)
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 4f4fb32e16..08410b6d6d 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
@@ -1,6 +1,7 @@
{
- "id": "{{ id }}",
+ "publisher": "{{ publisher }}",
"name": "{{ name }}",
+ "displayName": "{{ display_name }}",
"version": "{{ version }}",
"license": "{{ license }}",
{% if include_frontend -%}
@@ -8,7 +9,8 @@
"contributions": {
"commands": [],
"views": {},
- "menus": {}
+ "menus": {},
+ "editors": []
},
"moduleFederation": {
"name": "{{ mf_name }}",
@@ -19,7 +21,7 @@
{% if include_backend -%}
"backend": {
"entryPoints": ["{{ backend_entry }}"],
- "files": ["backend/src/superset_extensions/{{ backend_name }}/**/*.py"]
+ "files": ["backend/src/{{ backend_path|replace('.', '/') }}/**/*.py"]
},
{% endif -%}
"permissions": []
diff --git
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
index 6b602b9845..be4d844aa2 100644
---
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
+++
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
@@ -1,5 +1,5 @@
{
- "name": "{{ id }}",
+ "name": "{{ npm_name }}",
"version": "{{ version }}",
"main": "dist/main.js",
"types": "dist/publicAPI.d.ts",
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 5a2342ed69..b3bf513989 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
@@ -19,7 +19,7 @@ module.exports = (env, argv) => {
filename: isProd ? undefined : "[name].[contenthash].js",
chunkFilename: "[name].[contenthash].js",
path: path.resolve(__dirname, "dist"),
- publicPath: `/api/v1/extensions/${packageConfig.name}/`,
+ publicPath: `/api/v1/extensions/{{ publisher }}/{{ name }}/`,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
diff --git a/superset-extensions-cli/src/superset_extensions_cli/types.py
b/superset-extensions-cli/src/superset_extensions_cli/types.py
index c7b774ab59..740b48eb55 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/types.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/types.py
@@ -21,20 +21,29 @@ from typing import TypedDict
class ExtensionNames(TypedDict):
"""Type definition for extension name variants following platform
conventions."""
- # Extension name (e.g., "Hello World")
+ # Publisher namespace (e.g., "my-org")
+ publisher: str
+
+ # Technical extension name (e.g., "dashboard-widgets")
name: str
- # Extension ID - kebab-case primary identifier and npm package name (e.g.,
"hello-world")
+ # Human-readable display name (e.g., "Dashboard Widgets")
+ display_name: str
+
+ # Composite extension ID - publisher.name (e.g.,
"my-org.dashboard-widgets")
id: str
- # Module Federation library - camelCase JS identifier (e.g., "helloWorld")
- mf_name: str
+ # NPM package name - @publisher/name (e.g., "@my-org/dashboard-widgets")
+ npm_name: str
- # Backend package name - snake_case (e.g., "hello_world")
- backend_name: str
+ # Module Federation library - publisherCamel_nameCamel (e.g.,
"myOrg_dashboardWidgets")
+ mf_name: str
- # Full backend package (e.g., "superset_extensions.hello_world")
+ # Backend package name with hyphens for distribution (e.g.,
"my_org-dashboard_widgets")
backend_package: str
- # Backend entry point (e.g., "superset_extensions.hello_world.entrypoint")
+ # Full backend import path (e.g.,
"superset_extensions.my_org.dashboard_widgets")
+ backend_path: str
+
+ # Backend entry point (e.g.,
"superset_extensions.my_org.dashboard_widgets.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 9aa17e0936..26c1f28584 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/utils.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/utils.py
@@ -21,6 +21,11 @@ import sys
from pathlib import Path
from typing import Any
+from superset_core.extensions.constants import (
+ DISPLAY_NAME_PATTERN,
+ PUBLISHER_PATTERN,
+ TECHNICAL_NAME_PATTERN,
+)
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
@@ -82,8 +87,10 @@ NPM_RESERVED = {
"bower_components",
}
-# Extension name pattern: lowercase, start with letter or number, alphanumeric
+ hyphens
-EXTENSION_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9]*(?:-[a-z0-9]+)*$")
+# Compiled patterns for publisher/name validation
+PUBLISHER_REGEX = re.compile(PUBLISHER_PATTERN)
+TECHNICAL_NAME_REGEX = re.compile(TECHNICAL_NAME_PATTERN)
+DISPLAY_NAME_REGEX = re.compile(DISPLAY_NAME_PATTERN)
def read_toml(path: Path) -> dict[str, Any] | None:
@@ -166,139 +173,210 @@ def name_to_kebab_case(name: str) -> str:
return _normalized_to_kebab(normalized)
-# Legacy functions for backward compatibility
-def to_kebab_case(name: str) -> str:
- """Convert display name to kebab-case. For new code, use
name_to_kebab_case."""
- return name_to_kebab_case(name)
+def 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).
-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)
+ Raises:
+ ExtensionNameError: If name is invalid
+ """
+ if name.lower() in NPM_RESERVED:
+ raise ExtensionNameError(f"'{name}' is a reserved npm package name")
-def validate_extension_id(extension_id: str) -> None:
+def validate_publisher(publisher: str) -> None:
"""
- Validate extension ID format (kebab-case).
+ Validate publisher namespace format.
+
+ Args:
+ publisher: Publisher namespace (e.g., 'my-org')
Raises:
- ExtensionNameError: If ID is invalid
+ ExtensionNameError: If publisher is invalid
"""
- if not extension_id:
- raise ExtensionNameError("Extension ID cannot be empty")
+ if not publisher:
+ raise ExtensionNameError("Publisher cannot be empty")
- # Check for leading/trailing hyphens first
- if extension_id.startswith("-"):
- raise ExtensionNameError("Extension ID cannot start with hyphens")
+ if not PUBLISHER_REGEX.match(publisher):
+ raise ExtensionNameError(
+ "Publisher must start with a letter and contain only lowercase
letters, numbers, and hyphens (e.g., 'my-org')"
+ )
- 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")
+def validate_technical_name(name: str) -> None:
+ """
+ Validate technical extension name format.
+
+ Args:
+ name: Technical extension name (e.g., 'dashboard-widgets')
+
+ Raises:
+ ExtensionNameError: If name is invalid
+ """
+ if not name:
+ raise ExtensionNameError("Extension name cannot be empty")
- # Check overall pattern
- if not EXTENSION_NAME_PATTERN.match(extension_id):
+ if not TECHNICAL_NAME_REGEX.match(name):
raise ExtensionNameError(
- "Use lowercase letters, numbers, and hyphens only (e.g.
hello-world)"
+ "Extension name must start with a letter and contain only
lowercase letters, numbers, and hyphens (e.g., 'dashboard-widgets')"
)
-def validate_extension_name(name: str) -> str:
+def validate_display_name(display_name: str) -> str:
"""
- Validate and normalize extension name (human-readable).
+ Validate and normalize display name format.
Args:
- extension_name: Raw extension name input
+ display_name: Human-readable extension name
Returns:
- Cleaned extension name
+ Cleaned display name
Raises:
- ExtensionNameError: If extension name is invalid
+ ExtensionNameError: If display name is invalid
"""
- if not name or not name.strip():
- raise ExtensionNameError("Extension name cannot be empty")
+ if not display_name or not display_name.strip():
+ raise ExtensionNameError("Display name cannot be empty")
# Normalize whitespace: strip and collapse multiple spaces
- normalized = " ".join(name.strip().split())
+ normalized = " ".join(display_name.strip().split())
+
+ if not DISPLAY_NAME_REGEX.match(normalized):
+ raise ExtensionNameError(
+ "Display name must start with a letter and can contain letters,
numbers, spaces, hyphens, underscores, and dots (e.g., 'Dashboard Widgets')"
+ )
# Check for only whitespace/special chars after normalization
if not any(c.isalnum() for c in normalized):
raise ExtensionNameError(
- "Extension name must contain at least one letter or number"
+ "Display name must contain at least one letter or number"
)
return normalized
-def validate_python_package_name(name: str) -> None:
+def suggest_technical_name(display_name: str) -> str:
"""
- Validate Python package name (snake_case format).
+ Suggest technical name from display name.
- Raises:
- ExtensionNameError: If name is invalid
+ Args:
+ display_name: Human-readable name (e.g., "Dashboard Widgets!")
+
+ Returns:
+ Technical name suggestion (e.g., "dashboard-widgets")
"""
- # 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")
+ # Normalize for identifiers
+ normalized = _normalize_for_identifiers(display_name)
- # Check if the first part (before any underscore) is a Python keyword
- if (first_part := name.split("_")[0]) in PYTHON_KEYWORDS:
+ # Convert to kebab-case
+ technical_name = _normalized_to_kebab(normalized)
+
+ # Remove any leading/trailing hyphens that might result from edge cases
+ technical_name = technical_name.strip("-")
+
+ # Ensure we have something left
+ if not technical_name:
raise ExtensionNameError(
- f"Package name cannot start with Python keyword '{first_part}'"
+ "Display name must contain at least one letter or number"
)
- # 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")
+ return technical_name
-def validate_npm_package_name(name: str) -> None:
+def get_module_federation_name(publisher: str, name: str) -> str:
"""
- Validate npm package name (kebab-case format).
+ Generate Module Federation container name.
- Raises:
- ExtensionNameError: If name is invalid
+ Args:
+ publisher: Publisher namespace (e.g., 'my-org')
+ name: Technical name (e.g., 'dashboard-widgets')
+
+ Returns:
+ Module Federation name (e.g., 'myOrg_dashboardWidgets')
"""
- if name.lower() in NPM_RESERVED:
- raise ExtensionNameError(f"'{name}' is a reserved npm package name")
+ publisher_camel = kebab_to_camel_case(publisher)
+ name_camel = kebab_to_camel_case(name)
+ return f"{publisher_camel}_{name_camel}"
-def generate_extension_names(name: str) -> ExtensionNames:
+def generate_extension_names(
+ display_name: str, publisher: str, technical_name: str | None = None
+) -> ExtensionNames:
"""
- Generate all extension name variants from display name input.
+ Generate all extension name variants from input.
- Flow: Display Name -> Generate ID -> Derive Technical Names from ID
- Example: "Hello World" -> "hello-world" -> "helloWorld"/"hello_world"
(from ID)
+ Args:
+ display_name: Human-readable name (e.g., "Dashboard Widgets")
+ publisher: Publisher namespace (e.g., "my-org")
+ technical_name: Technical name override, or None to auto-generate
Returns:
ExtensionNames: Dictionary with all name variants
Raises:
- ExtensionNameError: If any generated name is invalid
+ ExtensionNameError: If any name is invalid
"""
- # Validate and normalize the extension name
- name = validate_extension_name(name)
+ # Validate and normalize inputs
+ display_name = validate_display_name(display_name)
+ validate_publisher(publisher)
+
+ # Use provided technical name or generate from display name
+ if technical_name is None:
+ technical_name = suggest_technical_name(display_name)
+ else:
+ validate_technical_name(technical_name)
+
+ # Generate composite ID
+ composite_id = f"{publisher}.{technical_name}"
+
+ # Generate NPM package name
+ npm_name = f"@{publisher}/{technical_name}"
- # Generate ID from display name
- kebab_name = name_to_kebab_case(name)
+ # Generate Module Federation name
+ mf_name = get_module_federation_name(publisher, technical_name)
- # Derive all technical names from the generated ID (not display name)
- snake_name = kebab_to_snake_case(kebab_name)
- camel_name = kebab_to_camel_case(kebab_name)
+ # Generate backend names with collision protection
+ publisher_snake = kebab_to_snake_case(publisher)
+ name_snake = kebab_to_snake_case(technical_name)
+ backend_package = f"{publisher_snake}-{name_snake}"
+ backend_path = f"superset_extensions.{publisher_snake}.{name_snake}"
+ backend_entry = f"{backend_path}.entrypoint"
# Validate the generated names
- validate_extension_id(kebab_name)
- validate_python_package_name(snake_name)
- validate_npm_package_name(kebab_name)
+ validate_python_package_name(publisher_snake)
+ validate_python_package_name(name_snake)
+ validate_npm_package_name(technical_name)
return ExtensionNames(
- name=name,
- id=kebab_name,
- mf_name=camel_name,
- backend_name=snake_name,
- backend_package=f"superset_extensions.{snake_name}",
- backend_entry=f"superset_extensions.{snake_name}.entrypoint",
+ publisher=publisher,
+ name=technical_name,
+ display_name=display_name,
+ id=composite_id,
+ npm_name=npm_name,
+ mf_name=mf_name,
+ backend_package=backend_package,
+ backend_path=backend_path,
+ backend_entry=backend_entry,
)
diff --git a/superset-extensions-cli/tests/conftest.py
b/superset-extensions-cli/tests/conftest.py
index 7292428c4e..2e683835ac 100644
--- a/superset-extensions-cli/tests/conftest.py
+++ b/superset-extensions-cli/tests/conftest.py
@@ -46,8 +46,9 @@ def isolated_filesystem(tmp_path):
def extension_params():
"""Default parameters for extension creation."""
return {
- "id": "test_extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "0.1.0",
"license": "Apache-2.0",
"include_frontend": True,
@@ -58,25 +59,25 @@ def extension_params():
@pytest.fixture
def cli_input_both():
"""CLI input for creating extension with both frontend and backend."""
- return "Test Extension\n\n0.1.0\nApache-2.0\ny\ny\n"
+ return "Test Extension\n\ntest-org\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\n\n0.1.0\nApache-2.0\ny\nn\n"
+ return "Test Extension\n\ntest-org\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\n\n0.1.0\nApache-2.0\nn\ny\n"
+ return "Test Extension\n\ntest-org\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\n\n0.1.0\nApache-2.0\nn\nn\n"
+ return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\nn\nn\n"
@pytest.fixture
@@ -86,10 +87,11 @@ def extension_setup_for_dev():
def _setup(base_path: Path) -> None:
import json
- # Create extension.json
+ # Create extension.json with new structure
extension_json = {
- "id": "test_extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -113,10 +115,12 @@ def extension_setup_for_bundling():
dist_dir = base_path / "dist"
dist_dir.mkdir(parents=True)
- # Create manifest.json
+ # Create manifest.json with composite ID
manifest = {
- "id": "test_extension",
- "name": "Test Extension",
+ "id": "test-org.test-extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -128,8 +132,15 @@ def extension_setup_for_bundling():
(frontend_dir / "remoteEntry.abc123.js").write_text("// remote entry")
(frontend_dir / "main.js").write_text("// main js")
- # Create some backend files
- backend_dir = dist_dir / "backend" / "src" / "test_extension"
+ # Create some backend files - updated path structure
+ backend_dir = (
+ dist_dir
+ / "backend"
+ / "src"
+ / "superset_extensions"
+ / "test_org"
+ / "test_extension"
+ )
backend_dir.mkdir(parents=True)
(backend_dir / "__init__.py").write_text("# init")
diff --git a/superset-extensions-cli/tests/test_cli_build.py
b/superset-extensions-cli/tests/test_cli_build.py
index 7704a2dccd..94116e76b5 100644
--- a/superset-extensions-cli/tests/test_cli_build.py
+++ b/superset-extensions-cli/tests/test_cli_build.py
@@ -52,20 +52,33 @@ def extension_with_build_structure():
# Create extension.json
extension_json = {
- "id": "test_extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
if include_frontend:
extension_json["frontend"] = {
- "contributions": {"commands": []},
- "moduleFederation": {"exposes": ["./index"]},
+ "contributions": {
+ "commands": [],
+ "views": {},
+ "menus": {},
+ "editors": [],
+ },
+ "moduleFederation": {
+ "exposes": ["./index"],
+ "name": "testOrg_testExtension",
+ },
}
if include_backend:
- extension_json["backend"] = {"entryPoints":
["test_extension.entrypoint"]}
+ extension_json["backend"] = {
+ "entryPoints": [
+ "superset_extensions.test_org.test_extension.entrypoint"
+ ]
+ }
(base_path / "extension.json").write_text(json.dumps(extension_json))
@@ -230,16 +243,27 @@ def
test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
"""Test build_manifest creates correct manifest from extension.json."""
# Create extension.json
extension_data = {
- "id": "test_extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": ["read_data"],
"dependencies": ["some_dep"],
"frontend": {
- "contributions": {"commands": [{"id": "test_command", "title":
"Test"}]},
- "moduleFederation": {"exposes": ["./index"]},
+ "contributions": {
+ "commands": [{"id": "test_command", "title": "Test"}],
+ "views": {},
+ "menus": {},
+ "editors": [],
+ },
+ "moduleFederation": {
+ "exposes": ["./index"],
+ "name": "testOrg_testExtension",
+ },
+ },
+ "backend": {
+ "entryPoints":
["superset_extensions.test_org.test_extension.entrypoint"]
},
- "backend": {"entryPoints": ["test_extension.entrypoint"]},
}
extension_json = isolated_filesystem / "extension.json"
extension_json.write_text(json.dumps(extension_data))
@@ -247,8 +271,10 @@ def
test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js")
# Verify manifest structure
- assert manifest.id == "test_extension"
- assert manifest.name == "Test Extension"
+ assert manifest.id == "test-org.test-extension" # Composite ID
+ assert manifest.publisher == "test-org"
+ assert manifest.name == "test-extension"
+ assert manifest.displayName == "Test Extension"
assert manifest.version == "1.0.0"
assert manifest.permissions == ["read_data"]
assert manifest.dependencies == ["some_dep"]
@@ -263,15 +289,18 @@ def
test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
# Verify backend section
assert manifest.backend is not None
- assert manifest.backend.entryPoints == ["test_extension.entrypoint"]
+ assert manifest.backend.entryPoints == [
+ "superset_extensions.test_org.test_extension.entrypoint"
+ ]
@pytest.mark.unit
def test_build_manifest_handles_minimal_extension(isolated_filesystem):
"""Test build_manifest with minimal extension.json (no
frontend/backend)."""
extension_data = {
- "id": "minimal_extension",
- "name": "Minimal Extension",
+ "publisher": "minimal-org",
+ "name": "minimal-extension",
+ "displayName": "Minimal Extension",
"version": "0.1.0",
"permissions": [],
}
@@ -280,8 +309,10 @@ def
test_build_manifest_handles_minimal_extension(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, None)
- assert manifest.id == "minimal_extension"
- assert manifest.name == "Minimal Extension"
+ assert manifest.id == "minimal-org.minimal-extension" # Composite ID
+ assert manifest.publisher == "minimal-org"
+ assert manifest.name == "minimal-extension"
+ assert manifest.displayName == "Minimal Extension"
assert manifest.version == "0.1.0"
assert manifest.permissions == []
assert manifest.dependencies == [] # Default empty list
@@ -393,8 +424,9 @@ def
test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem):
# Create extension.json
extension_json = {
- "id": "test",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -420,8 +452,9 @@ def
test_copy_backend_files_skips_non_files(isolated_filesystem):
# Create extension.json with backend file patterns
extension_data = {
- "id": "test_ext",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-ext",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
"backend": {
@@ -457,8 +490,9 @@ def
test_copy_backend_files_copies_matched_files(isolated_filesystem):
# Create extension.json with backend file patterns
extension_data = {
- "id": "test_ext",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-ext",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
"backend": {"files": ["backend/src/test_ext/**/*.py"]},
@@ -480,8 +514,9 @@ def
test_copy_backend_files_copies_matched_files(isolated_filesystem):
def test_copy_backend_files_handles_no_backend_config(isolated_filesystem):
"""Test copy_backend_files handles extension.json without backend
config."""
extension_data = {
- "id": "frontend_only",
- "name": "Frontend Only Extension",
+ "publisher": "frontend-org",
+ "name": "frontend-only",
+ "displayName": "Frontend Only Extension",
"version": "1.0.0",
"permissions": [],
}
diff --git a/superset-extensions-cli/tests/test_cli_bundle.py
b/superset-extensions-cli/tests/test_cli_bundle.py
index a9c4ffd625..3b09c59a2f 100644
--- a/superset-extensions-cli/tests/test_cli_bundle.py
+++ b/superset-extensions-cli/tests/test_cli_bundle.py
@@ -43,10 +43,10 @@ def test_bundle_command_creates_zip_with_default_name(
result = cli_runner.invoke(app, ["bundle"])
assert result.exit_code == 0
- assert "✅ Bundle created: test_extension-1.0.0.supx" in result.output
+ assert "✅ Bundle created: test-org.test-extension-1.0.0.supx" in
result.output
# Verify zip file was created
- zip_path = isolated_filesystem / "test_extension-1.0.0.supx"
+ zip_path = isolated_filesystem / "test-org.test-extension-1.0.0.supx"
assert_file_exists(zip_path)
# Verify zip contents
@@ -55,7 +55,10 @@ def test_bundle_command_creates_zip_with_default_name(
assert "manifest.json" in file_list
assert "frontend/dist/remoteEntry.abc123.js" in file_list
assert "frontend/dist/main.js" in file_list
- assert "backend/src/test_extension/__init__.py" in file_list
+ assert (
+
"backend/src/superset_extensions/test_org/test_extension/__init__.py"
+ in file_list
+ )
@pytest.mark.cli
@@ -100,7 +103,7 @@ def test_bundle_command_with_output_directory(
assert result.exit_code == 0
# Verify zip file was created in output directory
- expected_path = output_dir / "test_extension-1.0.0.supx"
+ expected_path = output_dir / "test-org.test-extension-1.0.0.supx"
assert_file_exists(expected_path)
assert f"✅ Bundle created: {expected_path}" in result.output
@@ -159,8 +162,10 @@ def test_bundle_includes_all_files_recursively(
# Manifest
manifest = {
- "id": "complex_extension",
- "name": "Complex Extension",
+ "id": "complex-org.complex-extension",
+ "publisher": "complex-org",
+ "name": "complex-extension",
+ "displayName": "Complex Extension",
"version": "2.1.0",
"permissions": [],
}
@@ -191,7 +196,7 @@ def test_bundle_includes_all_files_recursively(
assert result.exit_code == 0
# Verify zip file and contents
- zip_path = isolated_filesystem / "complex_extension-2.1.0.supx"
+ zip_path = isolated_filesystem / "complex-org.complex-extension-2.1.0.supx"
assert_file_exists(zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
diff --git a/superset-extensions-cli/tests/test_cli_dev.py
b/superset-extensions-cli/tests/test_cli_dev.py
index 8d4d4f42a8..1a186907d9 100644
--- a/superset-extensions-cli/tests/test_cli_dev.py
+++ b/superset-extensions-cli/tests/test_cli_dev.py
@@ -49,7 +49,13 @@ def test_dev_command_starts_watchers(
"""Test dev command starts file watchers."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
- mock_build_manifest.return_value = Manifest(id="test", name="test",
version="1.0.0")
+ mock_build_manifest.return_value = Manifest(
+ id="test-org.test-extension",
+ publisher="test-org",
+ name="test-extension",
+ displayName="Test Extension",
+ version="1.0.0",
+ )
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
@@ -101,7 +107,13 @@ def test_dev_command_initial_build(
"""Test dev command performs initial build setup."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
- mock_build_manifest.return_value = Manifest(id="test", name="test",
version="1.0.0")
+ mock_build_manifest.return_value = Manifest(
+ id="test-org.test-extension",
+ publisher="test-org",
+ name="test-extension",
+ displayName="Test Extension",
+ version="1.0.0",
+ )
extension_setup_for_dev(isolated_filesystem)
@@ -178,8 +190,9 @@ def
test_frontend_watcher_function_coverage(isolated_filesystem):
"""Test frontend watcher function for coverage."""
# Create extension.json
extension_json = {
- "id": "test_extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -189,7 +202,13 @@ def
test_frontend_watcher_function_coverage(isolated_filesystem):
dist_dir = isolated_filesystem / "dist"
dist_dir.mkdir()
- mock_manifest = Manifest(id="test", name="test", version="1.0.0")
+ mock_manifest = Manifest(
+ id="test-org.test-extension",
+ publisher="test-org",
+ name="test-extension",
+ displayName="Test Extension",
+ version="1.0.0",
+ )
with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild:
with patch("superset_extensions_cli.cli.build_manifest") as mock_build:
with patch("superset_extensions_cli.cli.write_manifest") as
mock_write:
diff --git a/superset-extensions-cli/tests/test_cli_init.py
b/superset-extensions-cli/tests/test_cli_init.py
index 78e0d6888c..7d9d5f284b 100644
--- a/superset-extensions-cli/tests/test_cli_init.py
+++ b/superset-extensions-cli/tests/test_cli_init.py
@@ -43,16 +43,17 @@ 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-org.test-extension) initialized"
+ in result.output
)
# Verify directory structure
- extension_path = isolated_filesystem / "test-extension"
+ extension_path = isolated_filesystem / "test-org.test-extension"
assert_directory_exists(extension_path, "main extension directory")
expected_structure = create_test_extension_structure(
isolated_filesystem,
- "test-extension",
+ "test-org.test-extension",
include_frontend=True,
include_backend=True,
)
@@ -73,7 +74,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-org.test-extension"
assert_directory_exists(extension_path)
# Should have frontend directory and package.json
@@ -96,7 +97,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-org.test-extension"
assert_directory_exists(extension_path)
# Should have backend directory and pyproject.toml
@@ -119,7 +120,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-org.test-extension"
assert_directory_exists(extension_path)
# Should only have extension.json
@@ -131,43 +132,45 @@ def
test_init_creates_extension_with_neither_frontend_nor_backend(
@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"
+def test_init_accepts_valid_display_name(cli_runner, isolated_filesystem):
+ """Test that init accepts valid display names and generates proper ID."""
+ cli_input = "My Awesome Extension\n\ntest-org\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"
+ assert Path("test-org.my-awesome-extension").exists(), (
+ "Directory for generated composite 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"
+ cli_input = "Tool 123\n\ntest-org\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"
+ assert Path("test-org.tool-123").exists(), (
+ "Directory for 'test-org.tool-123' should be created"
+ )
@pytest.mark.cli
@pytest.mark.parametrize(
"display_name,expected_id",
[
- ("Test Extension", "test-extension"),
- ("My Tool v2", "my-tool-v2"),
- ("Dashboard Helper", "dashboard-helper"),
- ("Chart Builder Pro", "chart-builder-pro"),
+ ("Test Extension", "test-org.test-extension"),
+ ("My Tool v2", "test-org.my-tool-v2"),
+ ("Dashboard Helper", "test-org.dashboard-helper"),
+ ("Chart Builder Pro", "test-org.chart-builder-pro"),
],
)
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"{display_name}\n\n0.1.0\nApache-2.0\ny\ny\n"
+ cli_input = f"{display_name}\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, (
@@ -184,7 +187,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-org.test-extension"
existing_dir.mkdir()
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
@@ -201,15 +204,16 @@ 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-org.test-extension"
extension_json_path = extension_path / "extension.json"
# Verify the JSON structure and values
assert_json_content(
extension_json_path,
{
- "id": "test-extension",
- "name": "Test Extension",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "displayName": "Test Extension",
"version": "0.1.0",
"license": "Apache-2.0",
"permissions": [],
@@ -224,10 +228,15 @@ def test_extension_json_content_is_correct(
frontend = content["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
- assert frontend["contributions"] == {"commands": [], "views": {}, "menus":
{}}
+ assert frontend["contributions"] == {
+ "commands": [],
+ "views": {},
+ "menus": {},
+ "editors": [],
+ }
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
- "name": "testExtension",
+ "name": "testOrg_testExtension",
}
# Verify backend section exists and has correct structure
@@ -235,9 +244,11 @@ def test_extension_json_content_is_correct(
backend = content["backend"]
assert "entryPoints" in backend
assert "files" in backend
- assert backend["entryPoints"] ==
["superset_extensions.test_extension.entrypoint"]
+ assert backend["entryPoints"] == [
+ "superset_extensions.test_org.test_extension.entrypoint"
+ ]
assert backend["files"] == [
- "backend/src/superset_extensions/test_extension/**/*.py"
+ "backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@@ -249,14 +260,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-org.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-org/test-extension",
"version": "0.1.0",
"license": "Apache-2.0",
},
@@ -278,14 +289,16 @@ 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-org.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 "superset_extensions.test_extension" in content
+ assert (
+ "test_org-test_extension" in content
+ ) # Package name uses collision-safe naming
assert "0.1.0" in content
assert "Apache-2.0" in content
@@ -303,7 +316,9 @@ 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-org.test-extension) initialized"
in output
+ )
@pytest.mark.cli
@@ -312,7 +327,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-org.test-extension"
gitignore_path = extension_path / ".gitignore"
assert_file_exists(gitignore_path, ".gitignore")
@@ -332,19 +347,20 @@ 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\n\n2.1.0\nMIT\ny\nn\n"
+ cli_input = "My Extension\n\ntest-org\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 / "test-org.my-extension"
extension_json_path = extension_path / "extension.json"
assert_json_content(
extension_json_path,
{
- "id": "my-extension",
- "name": "My Extension",
+ "publisher": "test-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
"version": "2.1.0",
"license": "MIT",
},
@@ -356,17 +372,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\n\n1.0.0\nApache-2.0\ny\ny\n"
+ cli_input = "Awesome Charts\n\nawesome-org\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-org.awesome-charts"
expected_structure = create_test_extension_structure(
isolated_filesystem,
- "awesome-charts",
+ "awesome-org.awesome-charts",
include_frontend=True,
include_backend=True,
)
@@ -377,16 +393,19 @@ 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["name"] == "Awesome Charts"
+ assert extension_json["publisher"] == "awesome-org"
+ assert extension_json["name"] == "awesome-charts"
+ assert extension_json["displayName"] == "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-org/awesome-charts"
pyproject_content = (extension_path / "backend" /
"pyproject.toml").read_text()
- assert "superset_extensions.awesome_charts" in pyproject_content
+ assert (
+ "awesome_org-awesome_charts" in pyproject_content
+ ) # Package name uses collision-safe naming
# Non-interactive mode tests
@@ -397,9 +416,11 @@ def test_init_non_interactive_with_all_options(cli_runner,
isolated_filesystem):
app,
[
"init",
- "--id",
- "my-ext",
+ "--publisher",
+ "my-org",
"--name",
+ "my-ext",
+ "--display-name",
"My Extension",
"--version",
"1.0.0",
@@ -411,16 +432,17 @@ 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-org.my-ext) initialized" in
result.output
- extension_path = isolated_filesystem / "my-ext"
+ extension_path = isolated_filesystem / "my-org.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["name"] == "My Extension"
+ assert extension_json["publisher"] == "my-org"
+ assert extension_json["name"] == "my-ext"
+ assert extension_json["displayName"] == "My Extension"
assert extension_json["version"] == "1.0.0"
assert extension_json["license"] == "MIT"
@@ -432,9 +454,11 @@ def test_init_frontend_only_with_cli_options(cli_runner,
isolated_filesystem):
app,
[
"init",
- "--id",
- "frontend-ext",
+ "--publisher",
+ "frontend-org",
"--name",
+ "frontend-ext",
+ "--display-name",
"Frontend Extension",
"--version",
"1.0.0",
@@ -447,7 +471,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-org.frontend-ext"
assert_directory_exists(extension_path / "frontend")
assert not (extension_path / "backend").exists()
@@ -459,9 +483,11 @@ def test_init_backend_only_with_cli_options(cli_runner,
isolated_filesystem):
app,
[
"init",
- "--id",
- "backend-ext",
+ "--publisher",
+ "backend-org",
"--name",
+ "backend-ext",
+ "--display-name",
"Backend Extension",
"--version",
"1.0.0",
@@ -474,7 +500,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-org.backend-ext"
assert not (extension_path / "frontend").exists()
assert_directory_exists(extension_path / "backend")
@@ -482,14 +508,16 @@ def test_init_backend_only_with_cli_options(cli_runner,
isolated_filesystem):
@pytest.mark.cli
def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
"""Test that init prompts for options not provided via CLI and uses
defaults."""
- # Provide id and name via CLI, but version/license will be prompted
(accept defaults)
+ # Provide publisher, name, and display-name via CLI, but version/license
will be prompted (accept defaults)
result = cli_runner.invoke(
app,
[
"init",
- "--id",
- "default-ext",
+ "--publisher",
+ "default-org",
"--name",
+ "default-ext",
+ "--display-name",
"Default Extension",
"--frontend",
"--backend",
@@ -499,22 +527,24 @@ 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-org.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"
@pytest.mark.cli
-def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
- """Test that non-interactive mode validates extension ID."""
+def test_init_non_interactive_validates_technical_name(cli_runner,
isolated_filesystem):
+ """Test that non-interactive mode validates technical name."""
result = cli_runner.invoke(
app,
[
"init",
- "--id",
- "invalid_name",
+ "--publisher",
+ "test-org",
"--name",
+ "invalid_name",
+ "--display-name",
"Invalid Extension",
"--frontend",
"--backend",
@@ -522,4 +552,4 @@ def test_init_non_interactive_validates_id(cli_runner,
isolated_filesystem):
)
assert result.exit_code == 1
- assert "Use lowercase letters, numbers, and hyphens only" in result.output
+ assert "must start with a letter" in result.output.lower()
diff --git a/superset-extensions-cli/tests/test_name_transformations.py
b/superset-extensions-cli/tests/test_name_transformations.py
index d376c04e17..caa490c28d 100644
--- a/superset-extensions-cli/tests/test_name_transformations.py
+++ b/superset-extensions-cli/tests/test_name_transformations.py
@@ -20,381 +20,483 @@ import pytest
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.utils import (
generate_extension_names,
+ get_module_federation_name,
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,
+ suggest_technical_name,
+ validate_display_name,
validate_npm_package_name,
+ validate_publisher,
validate_python_package_name,
+ validate_technical_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",
+# Name transformation tests
+
+
[email protected](
+ ("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",
- "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
+ ), # 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(display_name, expected):
+ """Test direct kebab case conversion from display names."""
+ assert name_to_kebab_case(display_name) == expected
+
+
[email protected](
+ ("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(kebab_name, expected):
+ """Test kebab-case to camelCase conversion."""
+ assert kebab_to_camel_case(kebab_name) == expected
+
+
[email protected](
+ ("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(kebab_name, expected):
+ """Test kebab-case to snake_case conversion."""
+ assert kebab_to_snake_case(kebab_name) == expected
+
+
+# Display name validation tests
+
+
[email protected](
+ ("valid_display", "expected_normalized"),
+ [
+ ("Hello World", "Hello World"),
+ ("Data Explorer", "Data Explorer"),
+ ("My Extension", "My Extension"),
+ ("Simple", "Simple"),
+ (" Extra Spaces ", "Extra Spaces"), # Gets normalized
+ ("Dashboard Widgets", "Dashboard Widgets"),
+ ("Chart Builder Pro", "Chart Builder Pro"),
+ ("API Client v2.0", "API Client v2.0"),
+ ("Tool_123", "Tool_123"), # Underscores allowed
+ ("My-Extension", "My-Extension"), # Hyphens allowed
+ ],
+)
+def test_validate_display_name_valid(valid_display, expected_normalized):
+ """Test valid display names return correctly normalized output."""
+ result = validate_display_name(valid_display)
+ assert result == expected_normalized
+
+
[email protected](
+ ("invalid_display", "error_match"),
+ [
+ ("", "cannot be empty"),
+ (" ", "cannot be empty"),
+ ("@#$%", "must start with a letter"),
+ ("123 Tool", "must start with a letter"),
+ ("-My Extension", "must start with a letter"),
+ ],
+)
+def test_validate_display_name_invalid(invalid_display, error_match):
+ """Test invalid display names."""
+ with pytest.raises(ExtensionNameError, match=error_match):
+ validate_display_name(invalid_display)
+
+
+# Python package name validation tests
+
+
[email protected](
+ ("valid_package",),
+ [
+ ("hello_world",),
+ ("data_explorer",),
+ ("myext",),
+ ("test123",),
+ ("package_with_many_parts",),
+ ],
+)
+def test_validate_python_package_name_valid(valid_package):
+ """Test valid Python package names."""
+ # Should not raise exceptions
+ validate_python_package_name(valid_package)
+
+
[email protected](
+ ("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(keyword):
+ """Test that Python reserved keywords are rejected."""
+ with pytest.raises(
+ ExtensionNameError, match="Package name cannot start with Python
keyword"
):
- """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
- ],
+ validate_python_package_name(keyword)
+
+
[email protected](
+ ("invalid_package",),
+ [
+ ("hello-world",), # Hyphens not allowed in Python identifiers
+ ],
+)
+def test_validate_python_package_name_invalid(invalid_package):
+ """Test invalid Python package names."""
+ with pytest.raises(ExtensionNameError, match="not a valid Python package"):
+ validate_python_package_name(invalid_package)
+
+
+# NPM package validation tests
+
+
[email protected](
+ ("valid_npm",),
+ [
+ ("hello-world",),
+ ("data-explorer",),
+ ("myext",),
+ ("package-with-many-parts",),
+ ],
+)
+def test_validate_npm_package_name_valid(valid_npm):
+ """Test valid npm package names."""
+ # Should not raise exceptions
+ validate_npm_package_name(valid_npm)
+
+
[email protected](
+ ("reserved_name",),
+ [
+ ("node_modules",),
+ ("npm",),
+ ("yarn",),
+ ("package.json",),
+ ("localhost",),
+ ("favicon.ico",),
+ ],
+)
+def test_validate_npm_package_name_reserved(reserved_name):
+ """Test that npm reserved names are rejected."""
+ with pytest.raises(ExtensionNameError, match="reserved npm package name"):
+ validate_npm_package_name(reserved_name)
+
+
+# Publisher validation tests
+
+
[email protected](
+ ("valid_publisher",),
+ [
+ ("my-org",),
+ ("acme",),
+ ("apache-superset",),
+ ("test123",),
+ ("a",), # Single character
+ ("publisher-with-many-parts",),
+ ],
+)
+def test_validate_publisher_valid(valid_publisher):
+ """Test valid publisher namespaces."""
+ # Should not raise exceptions
+ validate_publisher(valid_publisher)
+
+
[email protected](
+ ("invalid_publisher", "error_match"),
+ [
+ ("", "cannot be empty"),
+ ("My-Org", "must start with a letter and contain only lowercase
letters"),
+ ("-publisher", "must start with a letter and contain only lowercase
letters"),
+ ("publisher-", "must start with a letter and contain only lowercase
letters"),
+ ("pub--lisher", "must start with a letter and contain only lowercase
letters"),
+ ],
+)
+def test_validate_publisher_invalid(invalid_publisher, error_match):
+ """Test invalid publisher namespaces."""
+ with pytest.raises(ExtensionNameError, match=error_match):
+ validate_publisher(invalid_publisher)
+
+
+# Technical name validation tests
+
+
[email protected](
+ ("valid_name",),
+ [
+ ("dashboard-widgets",),
+ ("chart-builder",),
+ ("simple",),
+ ("api-client-v2",),
+ ("tool123",),
+ ],
+)
+def test_validate_technical_name_valid(valid_name):
+ """Test valid technical names."""
+ # Should not raise exceptions
+ validate_technical_name(valid_name)
+
+
[email protected](
+ ("invalid_name", "error_match"),
+ [
+ ("", "cannot be empty"),
+ (
+ "Dashboard-Widgets",
+ "must start with a letter and contain only lowercase letters",
+ ),
+ ("-name", "must start with a letter and contain only lowercase
letters"),
+ ("name-", "must start with a letter and contain only lowercase
letters"),
+ ("na--me", "must start with a letter and contain only lowercase
letters"),
+ ],
+)
+def test_validate_technical_name_invalid(invalid_name, error_match):
+ """Test invalid technical names."""
+ with pytest.raises(ExtensionNameError, match=error_match):
+ validate_technical_name(invalid_name)
+
+
+# Name suggestion tests
+
+
[email protected](
+ ("display_name", "expected_technical"),
+ [
+ ("Dashboard Widgets", "dashboard-widgets"),
+ ("Chart Builder Pro!", "chart-builder-pro"),
+ ("My@Tool#123", "mytool123"),
+ (" Spaced Out ", "spaced-out"),
+ ("API v2 Client", "api-v2-client"),
+ ],
+)
+def test_suggest_technical_name(display_name, expected_technical):
+ """Test technical name suggestion from display names."""
+ result = suggest_technical_name(display_name)
+ assert result == expected_technical
+
+
[email protected](
+ ("publisher", "name", "expected_mf"),
+ [
+ ("my-org", "dashboard-widgets", "myOrg_dashboardWidgets"),
+ ("acme", "chart-builder", "acme_chartBuilder"),
+ ("test-company", "simple", "testCompany_simple"),
+ ],
+)
+def test_get_module_federation_name(publisher, name, expected_mf):
+ """Test Module Federation name generation."""
+ result = get_module_federation_name(publisher, name)
+ assert result == expected_mf
+
+
+# Complete name generation tests
+
+
[email protected](
+ ("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(
+ display_name, expected_kebab, expected_snake, expected_camel
+):
+ """Test complete name generation flow with publisher concept."""
+ publisher = "test-org"
+ names = generate_extension_names(display_name, publisher, expected_kebab)
+
+ # Test all transformations with publisher concept
+ assert names["display_name"] == display_name
+ assert names["publisher"] == publisher
+ assert names["name"] == expected_kebab # Technical name
+ assert names["id"] == f"{publisher}.{expected_kebab}" # Composite ID
+ assert names["npm_name"] == f"@{publisher}/{expected_kebab}" # NPM scoped
+ assert (
+ names["mf_name"] == f"testOrg_{expected_camel}"
+ ) # Module Federation with publisher prefix
+ assert (
+ names["backend_package"] == f"{publisher.replace('-',
'_')}-{expected_snake}"
+ ) # Collision-safe
+ assert (
+ names["backend_path"]
+ == f"superset_extensions.{publisher.replace('-',
'_')}.{expected_snake}"
)
- 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
- ],
+ assert (
+ names["backend_entry"]
+ == f"superset_extensions.{publisher.replace('-',
'_')}.{expected_snake}.entrypoint"
)
- 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"
+
+
[email protected](
+ ("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(invalid_display):
+ """Test invalid name generation scenarios."""
+ with pytest.raises(ExtensionNameError):
+ generate_extension_names(invalid_display, "test-org")
+
+
+def test_generate_extension_names_unicode():
+ """Test handling of unicode characters."""
+ # Use a simpler approach - the display name validation now requires
starting with letter
+ names = generate_extension_names("Cafe Extension", "test-org",
"cafe-extension")
+ assert names["id"] == "test-org.cafe-extension"
+ assert names["display_name"] == "Cafe Extension" # Original preserved
+
+
+def test_generate_extension_names_special_chars():
+ """Test name generation with special characters."""
+ # Use manual technical name since display validation is stricter
+ names = generate_extension_names("My Extension", "test-org",
"my-extension")
+
+ assert names["display_name"] == "My Extension"
+ assert names["id"] == "test-org.my-extension"
+ assert names["backend_package"] == "test_org-my_extension"
+
+
+def test_generate_extension_names_case_preservation():
+ """Test that display name case is preserved."""
+ names = generate_extension_names("CamelCase Extension", "test-org")
+ assert names["display_name"] == "CamelCase Extension"
+ assert names["id"] == "test-org.camelcase-extension"
+
+
+# Edge case tests
+
+
[email protected](
+ ("edge_case",),
+ [
+ ("",), # Empty string
+ (" ",), # Only spaces
+ ("---",), # Only hyphens
+ ("___",), # Only underscores
+ ],
+)
+def test_empty_or_invalid_inputs(edge_case):
+ """Test inputs that become empty or invalid after processing."""
+ with pytest.raises(ExtensionNameError):
+ generate_extension_names(edge_case, "test-org")
+
+
+def test_minimal_valid_input():
+ """Test minimal valid input."""
+ names = generate_extension_names("A Extension", "test-org")
+ assert names["id"] == "test-org.a-extension"
+ assert names["backend_package"] == "test_org-a_extension"
+
+
+def test_numbers_handling():
+ """Test handling of numbers in names."""
+ names = generate_extension_names("Tool 123 v2", "test-org")
+ assert names["id"] == "test-org.tool-123-v2"
+ assert names["backend_package"] == "test_org-tool_123_v2"
+
+
+def test_manual_technical_name_override():
+ """Test using manual technical name instead of auto-generated."""
+ display_name = "My Awesome Chart Builder Pro"
+ publisher = "acme"
+ technical_name = "chart-builder" # Much shorter than display name
+
+ # Create names using manual technical name
+ names = generate_extension_names(display_name, publisher, technical_name)
+
+ # Verify technical names come from provided technical name, not display
name
+ assert (
+ names["display_name"] == "My Awesome Chart Builder Pro"
+ ) # Display name preserved
+ assert names["publisher"] == "acme"
+ assert names["name"] == "chart-builder" # Technical name used
+ assert names["id"] == "acme.chart-builder" # Composite ID
+ assert names["mf_name"] == "acme_chartBuilder" # Module Federation format
+ assert names["backend_package"] == "acme-chart_builder" # Collision-safe
+ assert names["backend_path"] == "superset_extensions.acme.chart_builder"
+ assert names["backend_entry"] ==
"superset_extensions.acme.chart_builder.entrypoint"
+
+
+def test_generate_names_uses_suggested_technical_names():
+ """Test that generate_extension_names can auto-suggest technical names."""
+ display_name = "Hello World"
+ publisher = "test-org"
+
+ # Generated names should use suggested technical name generation
+ names = generate_extension_names(display_name, publisher)
+
+ # Verify the technical name was suggested from display name
+ assert names["name"] == "hello-world"
+ assert names["id"] == "test-org.hello-world"
+
+ # Verify other names were generated from the technical name and publisher
+ assert names["mf_name"] == get_module_federation_name(
+ "test-org", "hello-world"
+ ) # "testOrg_helloWorld"
+ assert names["backend_package"] == "test_org-hello_world"
+
+ # Module Federation name should use underscore format with camelCase
+ assert names["mf_name"] == "testOrg_helloWorld"
diff --git a/superset-extensions-cli/tests/test_templates.py
b/superset-extensions-cli/tests/test_templates.py
index 30d2115be7..29858e494a 100644
--- a/superset-extensions-cli/tests/test_templates.py
+++ b/superset-extensions-cli/tests/test_templates.py
@@ -42,12 +42,15 @@ def jinja_env(templates_dir):
def template_context():
"""Default template context for testing."""
return {
- "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",
+ "publisher": "test-org",
+ "name": "test-extension",
+ "display_name": "Test Extension",
+ "id": "test-org.test-extension",
+ "npm_name": "@test-org/test-extension",
+ "mf_name": "testOrg_testExtension",
+ "backend_package": "test_org-test_extension",
+ "backend_path": "superset_extensions.test_org.test_extension",
+ "backend_entry":
"superset_extensions.test_org.test_extension.entrypoint",
"version": "0.1.0",
"license": "Apache-2.0",
"include_frontend": True,
@@ -68,8 +71,9 @@ 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["name"] == "Test Extension"
+ assert parsed["publisher"] == "test-org"
+ assert parsed["name"] == "test-extension"
+ assert parsed["displayName"] == "Test Extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
assert parsed["permissions"] == []
@@ -79,18 +83,25 @@ def
test_extension_json_template_renders_with_both_frontend_and_backend(
frontend = parsed["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
- assert frontend["contributions"] == {"commands": [], "views": {}, "menus":
{}}
+ assert frontend["contributions"] == {
+ "commands": [],
+ "views": {},
+ "menus": {},
+ "editors": [],
+ }
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
- "name": "testExtension",
+ "name": "testOrg_testExtension",
}
# Verify backend section exists
assert "backend" in parsed
backend = parsed["backend"]
- assert backend["entryPoints"] ==
["superset_extensions.test_extension.entrypoint"]
+ assert backend["entryPoints"] == [
+ "superset_extensions.test_org.test_extension.entrypoint"
+ ]
assert backend["files"] == [
- "backend/src/superset_extensions/test_extension/**/*.py"
+ "backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@@ -136,7 +147,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-org/test-extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
assert parsed["private"] is True
@@ -170,7 +181,7 @@ def
test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
rendered = template.render(template_context)
# Basic content verification (without full TOML parsing)
- assert "test_extension" in rendered
+ assert "test_org-test_extension" in rendered
assert "0.1.0" in rendered
assert "Apache-2.0" in rendered
@@ -178,34 +189,36 @@ def
test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
# Template Rendering with Different Parameters Tests
@pytest.mark.unit
@pytest.mark.parametrize(
- "extension_id,name,backend_name",
+ "publisher,technical_name,display_name",
[
- ("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"),
+ ("test-org", "simple-extension", "Simple Extension"),
+ ("acme", "my-extension-123", "My Extension 123"),
+ ("company", "complex-extension-name-123", "Complex Extension Name
123"),
+ ("pub", "ext", "Ext"),
],
)
def test_template_rendering_with_different_ids(
- jinja_env, extension_id, name, backend_name
+ jinja_env, publisher, technical_name, display_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
+ """Test templates render correctly with various publisher/name
combinations."""
+ from superset_extensions_cli.utils import (
+ get_module_federation_name,
+ kebab_to_snake_case,
+ )
- mf_name = kebab_to_camel_case(extension_id)
+ publisher_snake = kebab_to_snake_case(publisher)
+ name_snake = kebab_to_snake_case(technical_name)
context = {
- "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",
+ "publisher": publisher,
+ "name": technical_name,
+ "display_name": display_name,
+ "id": f"{publisher}.{technical_name}",
+ "npm_name": f"@{publisher}/{technical_name}",
+ "mf_name": get_module_federation_name(publisher, technical_name),
+ "backend_package": f"{publisher_snake}-{name_snake}",
+ "backend_path": f"superset_extensions.{publisher_snake}.{name_snake}",
+ "backend_entry":
f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": True,
@@ -217,13 +230,14 @@ def test_template_rendering_with_different_ids(
rendered = template.render(context)
parsed = json.loads(rendered)
- assert parsed["id"] == extension_id
- assert parsed["name"] == name
+ assert parsed["publisher"] == publisher
+ assert parsed["name"] == technical_name
+ assert parsed["displayName"] == display_name
assert parsed["backend"]["entryPoints"] == [
- f"superset_extensions.{backend_name}.entrypoint"
+ f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
]
assert parsed["backend"]["files"] == [
- f"backend/src/superset_extensions/{backend_name}/**/*.py"
+
f"backend/src/superset_extensions/{publisher_snake}/{name_snake}/**/*.py"
]
# Test package.json template
@@ -231,13 +245,13 @@ def test_template_rendering_with_different_ids(
rendered = template.render(context)
parsed = json.loads(rendered)
- assert parsed["name"] == extension_id
+ assert parsed["name"] == f"@{publisher}/{technical_name}"
# Test pyproject.toml template
template = jinja_env.get_template("backend/pyproject.toml.j2")
rendered = template.render(context)
- assert f"superset_extensions.{backend_name}" in rendered
+ assert f"{publisher_snake}-{name_snake}" in rendered
@pytest.mark.unit
@@ -245,9 +259,12 @@ def test_template_rendering_with_different_ids(
def test_template_rendering_with_different_versions(jinja_env, version):
"""Test templates render correctly with various version formats."""
context = {
- "id": "test_ext",
- "name": "Test Extension",
- "mf_name": "testExtension",
+ "publisher": "test-pub",
+ "name": "test-ext",
+ "display_name": "Test Extension",
+ "id": "test-pub.test-ext",
+ "npm_name": "@test-pub/test-ext",
+ "mf_name": "testPub_testExt",
"version": version,
"license": "Apache-2.0",
"include_frontend": True,
@@ -275,12 +292,15 @@ def
test_template_rendering_with_different_versions(jinja_env, version):
def test_template_rendering_with_different_licenses(jinja_env, license_type):
"""Test templates render correctly with various license types."""
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",
+ "publisher": "test-pub",
+ "name": "test-ext",
+ "display_name": "Test Extension",
+ "id": "test-pub.test-ext",
+ "npm_name": "@test-pub/test-ext",
+ "mf_name": "testPub_testExt",
+ "backend_package": "test_pub-test_ext",
+ "backend_path": "superset_extensions.test_pub.test_ext",
+ "backend_entry": "superset_extensions.test_pub.test_ext.entrypoint",
"version": "1.0.0",
"license": license_type,
"include_frontend": True,
@@ -345,12 +365,15 @@ def test_template_context_edge_cases(jinja_env):
"""Test template rendering with edge case contexts."""
# Test with minimal context
minimal_context = {
- "id": "minimal",
- "name": "Minimal",
- "mf_name": "minimal",
- "backend_name": "minimal",
- "backend_package": "superset_extensions.minimal",
- "backend_entry": "superset_extensions.minimal.entrypoint",
+ "publisher": "min",
+ "name": "minimal",
+ "display_name": "Minimal",
+ "id": "min.minimal",
+ "npm_name": "@min/minimal",
+ "mf_name": "min_minimal",
+ "backend_package": "min-minimal",
+ "backend_path": "superset_extensions.min.minimal",
+ "backend_entry": "superset_extensions.min.minimal.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": False,
@@ -362,7 +385,8 @@ def test_template_context_edge_cases(jinja_env):
parsed = json.loads(rendered)
# Should still be valid JSON with basic fields
- assert parsed["id"] == "minimal"
- assert parsed["name"] == "Minimal"
+ assert parsed["publisher"] == "min"
+ assert parsed["name"] == "minimal"
+ assert parsed["displayName"] == "Minimal"
assert "frontend" not in parsed
assert "backend" not in parsed
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index 2368dce11b..fc386d60bf 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -117,17 +117,21 @@ class ExtensionsRestApi(BaseApi):
@protect()
@safe
- @expose("/<id>", methods=("GET",))
- def get(self, id: str, **kwargs: Any) -> Response:
- """Get an extension by its id.
+ @expose("/<publisher>/<name>", methods=("GET",))
+ def get(self, publisher: str, name: str, **kwargs: Any) -> Response:
+ """Get an extension by its publisher and name.
---
get:
- summary: Get an extension by its id.
+ summary: Get an extension by its publisher and name.
parameters:
- in: path
schema:
type: string
- name: id
+ name: publisher
+ - in: path
+ schema:
+ type: string
+ name: name
responses:
200:
description: Extension details
@@ -154,8 +158,10 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
+ # Reconstruct composite ID from publisher and name
+ composite_id = f"{publisher}.{name}"
extensions = get_extensions()
- extension = extensions.get(id)
+ extension = extensions.get(composite_id)
if not extension:
return self.response_404()
extension_data = build_extension_data(extension)
@@ -163,8 +169,8 @@ class ExtensionsRestApi(BaseApi):
@protect()
@safe
- @expose("/<id>/<file>", methods=("GET",))
- def content(self, id: str, file: str) -> Response:
+ @expose("/<publisher>/<name>/<file>", methods=("GET",))
+ def content(self, publisher: str, name: str, file: str) -> Response:
"""Get a frontend chunk of an extension.
---
get:
@@ -173,8 +179,13 @@ class ExtensionsRestApi(BaseApi):
- in: path
schema:
type: string
- name: id
- description: id of the extension
+ name: publisher
+ description: publisher of the extension
+ - in: path
+ schema:
+ type: string
+ name: name
+ description: technical name of the extension
- in: path
schema:
type: string
@@ -199,8 +210,10 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
+ # Reconstruct composite ID from publisher and name
+ composite_id = f"{publisher}.{name}"
extensions = get_extensions()
- extension = extensions.get(id)
+ extension = extensions.get(composite_id)
if not extension:
return self.response_404()
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 6449adae8e..7927009e83 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -94,8 +94,10 @@ class InMemoryFinder(importlib.abc.MetaPathFinder):
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"
+ ns_path = (
+ f"{source_base_path}/backend/src/"
+ f"{ns_name.replace('.', '/')}/__init__.py"
+ )
else:
ns_path = str(
Path(source_base_path)
@@ -240,7 +242,10 @@ def build_extension_data(extension: LoadedExtension) ->
dict[str, Any]:
if manifest.frontend:
frontend = manifest.frontend
module_federation = frontend.moduleFederation
- remote_entry_url =
f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}"
+ remote_entry_url = (
+ f"/api/v1/extensions/{manifest.publisher}/"
+ f"{manifest.name}/{frontend.remoteEntry}"
+ )
extension_data.update(
{
"remoteEntry": remote_entry_url,
diff --git a/tests/unit_tests/extensions/test_types.py
b/tests/unit_tests/extensions/test_types.py
index dea22ee19c..0df29ac362 100644
--- a/tests/unit_tests/extensions/test_types.py
+++ b/tests/unit_tests/extensions/test_types.py
@@ -38,12 +38,14 @@ def test_extension_config_minimal():
"""Test ExtensionConfig with minimal required fields."""
config = ExtensionConfig.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
}
)
- assert config.id == "my-extension"
- assert config.name == "My Extension"
+ assert config.publisher == "my-org"
+ assert config.name == "my-extension"
+ assert config.displayName == "My Extension"
assert config.version == "0.0.0"
assert config.dependencies == []
assert config.permissions == []
@@ -55,8 +57,9 @@ def test_extension_config_full():
"""Test ExtensionConfig with all fields populated."""
config = ExtensionConfig.model_validate(
{
- "id": "query_insights",
- "name": "Query Insights",
+ "publisher": "acme-corp",
+ "name": "query-insights",
+ "displayName": "Query Insights",
"version": "1.0.0",
"license": "Apache-2.0",
"description": "A query insights extension",
@@ -83,8 +86,9 @@ def test_extension_config_full():
},
}
)
- assert config.id == "query_insights"
- assert config.name == "Query Insights"
+ assert config.publisher == "acme-corp"
+ assert config.name == "query-insights"
+ assert config.displayName == "Query Insights"
assert config.version == "1.0.0"
assert config.license == "Apache-2.0"
assert config.description == "A query insights extension"
@@ -97,32 +101,50 @@ def test_extension_config_full():
assert config.backend.files == ["backend/src/query_insights/**/*.py"]
-def test_extension_config_missing_id():
- """Test ExtensionConfig raises error when id is missing."""
+def test_extension_config_missing_publisher():
+ """Test ExtensionConfig raises error when publisher is missing."""
with pytest.raises(ValidationError) as exc_info:
- ExtensionConfig.model_validate({"name": "My Extension"})
- assert "id" in str(exc_info.value)
+ ExtensionConfig.model_validate(
+ {"name": "my-extension", "displayName": "My Extension"}
+ )
+ assert "publisher" in str(exc_info.value)
def test_extension_config_missing_name():
"""Test ExtensionConfig raises error when name is missing."""
with pytest.raises(ValidationError) as exc_info:
- ExtensionConfig.model_validate({"id": "my-extension"})
+ ExtensionConfig.model_validate(
+ {"publisher": "my-org", "displayName": "My Extension"}
+ )
assert "name" in str(exc_info.value)
-def test_extension_config_empty_id():
- """Test ExtensionConfig raises error when id is empty."""
+def test_extension_config_missing_display_name():
+ """Test ExtensionConfig raises error when displayName is missing."""
with pytest.raises(ValidationError) as exc_info:
- ExtensionConfig.model_validate({"id": "", "name": "My Extension"})
- assert "id" in str(exc_info.value)
+ ExtensionConfig.model_validate({"publisher": "my-org", "name":
"my-extension"})
+ assert "displayName" in str(exc_info.value)
+
+
+def test_extension_config_empty_publisher():
+ """Test ExtensionConfig raises error when publisher is empty."""
+ with pytest.raises(ValidationError) as exc_info:
+ ExtensionConfig.model_validate(
+ {"publisher": "", "name": "my-extension", "displayName": "My
Extension"}
+ )
+ assert "publisher" in str(exc_info.value)
def test_extension_config_invalid_version():
"""Test ExtensionConfig raises error for invalid version format."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate(
- {"id": "my-extension", "name": "My Extension", "version":
"invalid"}
+ {
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
+ "version": "invalid",
+ }
)
assert "version" in str(exc_info.value)
@@ -131,7 +153,12 @@ def test_extension_config_valid_versions():
"""Test ExtensionConfig accepts valid semantic versions (major.minor.patch
only)."""
for version in ["1.0.0", "0.1.0", "10.20.30"]:
config = ExtensionConfig.model_validate(
- {"id": "my-extension", "name": "My Extension", "version": version}
+ {
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
+ "version": version,
+ }
)
assert config.version == version
@@ -140,7 +167,12 @@ def test_extension_config_prerelease_version_rejected():
"""Test ExtensionConfig rejects prerelease versions."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate(
- {"id": "my-extension", "name": "My Extension", "version":
"1.0.0-beta"}
+ {
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
+ "version": "1.0.0-beta",
+ }
)
assert "version" in str(exc_info.value)
@@ -154,12 +186,16 @@ def test_manifest_minimal():
"""Test Manifest with minimal required fields."""
manifest = Manifest.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "id": "my-org.my-extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
}
)
- assert manifest.id == "my-extension"
- assert manifest.name == "My Extension"
+ assert manifest.id == "my-org.my-extension"
+ assert manifest.publisher == "my-org"
+ assert manifest.name == "my-extension"
+ assert manifest.displayName == "My Extension"
assert manifest.frontend is None
assert manifest.backend is None
@@ -168,8 +204,10 @@ def test_manifest_with_frontend():
"""Test Manifest with frontend section requires remoteEntry."""
manifest = Manifest.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "id": "my-org.my-extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
"frontend": {
"remoteEntry": "remoteEntry.abc123.js",
"contributions": {},
@@ -187,8 +225,10 @@ def test_manifest_frontend_missing_remote_entry():
with pytest.raises(ValidationError) as exc_info:
Manifest.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "id": "my-org.my-extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
"frontend": {"contributions": {}, "moduleFederation": {}},
}
)
@@ -199,8 +239,10 @@ def test_manifest_with_backend():
"""Test Manifest with backend section."""
manifest = Manifest.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "id": "my-org.my-extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]},
}
)
@@ -212,8 +254,10 @@ def test_manifest_backend_no_files_field():
"""Test ManifestBackend does not have files field (only in
ExtensionConfig)."""
manifest = Manifest.model_validate(
{
- "id": "my-extension",
- "name": "My Extension",
+ "id": "my-org.my-extension",
+ "publisher": "my-org",
+ "name": "my-extension",
+ "displayName": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]},
}
)