mirror of
https://github.com/apache/superset.git
synced 2026-06-01 21:59:26 +00:00
fix(extensions): enforce correct naming conventions (#38167)
This commit is contained in:
@@ -40,10 +40,10 @@ superset-extensions bundle: Packages the extension into a .supx file.
|
||||
superset-extensions dev: Automatically rebuilds the extension as files change.
|
||||
```
|
||||
|
||||
When creating a new extension with `superset-extensions init <extension-name>`, the CLI generates a standardized folder structure:
|
||||
When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure:
|
||||
|
||||
```
|
||||
dataset_references/
|
||||
dataset-references/
|
||||
├── extension.json
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
@@ -52,25 +52,33 @@ dataset_references/
|
||||
│ └── package.json
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ └── dataset_references/
|
||||
│ │ └── superset_extensions/
|
||||
│ │ └── dataset_references/
|
||||
│ ├── tests/
|
||||
│ ├── pyproject.toml
|
||||
│ └── requirements.txt
|
||||
├── dist/
|
||||
│ ├── manifest.json
|
||||
│ ├── frontend
|
||||
│ └── dist/
|
||||
│ ├── remoteEntry.d7a9225d042e4ccb6354.js
|
||||
│ └── 900.038b20cdff6d49cfa8d9.js
|
||||
│ │ └── dist/
|
||||
│ │ ├── remoteEntry.d7a9225d042e4ccb6354.js
|
||||
│ │ └── 900.038b20cdff6d49cfa8d9.js
|
||||
│ └── backend
|
||||
│ └── dataset_references/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py
|
||||
│ └── entrypoint.py
|
||||
├── dataset_references-1.0.0.supx
|
||||
│ └── superset_extensions/
|
||||
│ └── dataset_references/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py
|
||||
│ └── entrypoint.py
|
||||
├── dataset-references-1.0.0.supx
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Note**: The extension ID (`dataset-references`) serves as the basis for all technical names:
|
||||
- Directory name: `dataset-references` (kebab-case)
|
||||
- Backend Python package: `dataset_references` (snake_case)
|
||||
- Frontend package name: `dataset-references` (kebab-case)
|
||||
- Module Federation name: `datasetReferences` (camelCase)
|
||||
|
||||
The `extension.json` file serves as the declared metadata for the extension, containing the extension's name, version, author, description, and a list of capabilities. This file is essential for the host application to understand how to load and manage the extension.
|
||||
|
||||
The `frontend` directory contains the source code for the frontend components of the extension, including React components, styles, and assets. The `webpack.config.js` file is used to configure Webpack for building the frontend code, while the `tsconfig.json` file defines the TypeScript configuration for the project. The `package.json` file specifies the dependencies and scripts for building and testing the frontend code.
|
||||
@@ -87,7 +95,8 @@ The `extension.json` file contains all metadata necessary for the host applicati
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dataset_references",
|
||||
"id": "dataset-references",
|
||||
"name": "Dataset References",
|
||||
"version": "1.0.0",
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
@@ -95,20 +104,21 @@ The `extension.json` file contains all metadata necessary for the host applicati
|
||||
"sqllab": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "dataset_references.main",
|
||||
"name": "Dataset references"
|
||||
"id": "dataset-references.main",
|
||||
"name": "Dataset References"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"exposes": ["./index"]
|
||||
"exposes": ["./index"],
|
||||
"name": "datasetReferences"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entryPoints": ["dataset_references.entrypoint"],
|
||||
"files": ["backend/src/dataset_references/**/*.py"]
|
||||
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
|
||||
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -54,24 +54,33 @@ superset-extensions init
|
||||
The CLI will prompt you for information:
|
||||
|
||||
```
|
||||
Extension ID (unique identifier, alphanumeric only): hello_world
|
||||
Extension name (human-readable display name): Hello World
|
||||
Extension name (e.g. Hello World): Hello World
|
||||
Extension ID [hello-world]: hello-world
|
||||
Initial version [0.1.0]: 0.1.0
|
||||
License [Apache-2.0]: Apache-2.0
|
||||
Include frontend? [Y/n]: Y
|
||||
Include backend? [Y/n]: Y
|
||||
```
|
||||
|
||||
**Important**: The extension ID must be **globally unique** across all Superset extensions and serves as the basis for all technical identifiers:
|
||||
- **Frontend package name**: `hello-world` (same as ID, used in package.json)
|
||||
- **Webpack Module Federation name**: `helloWorld` (camelCase from ID)
|
||||
- **Backend package name**: `hello_world` (snake_case from ID, used in project.toml)
|
||||
- **Python namespace**: `superset_extensions.hello_world`
|
||||
|
||||
This ensures consistent naming across all technical components, even when the display name differs significantly from the ID. Since all technical names derive from the extension ID, choosing a unique ID automatically ensures all generated names are also unique, preventing conflicts between extensions.
|
||||
|
||||
This creates a complete project structure:
|
||||
|
||||
```
|
||||
hello_world/
|
||||
hello-world/
|
||||
├── extension.json # Extension metadata and configuration
|
||||
├── backend/ # Backend Python code
|
||||
│ ├── src/
|
||||
│ │ └── hello_world/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── entrypoint.py # Backend registration
|
||||
│ │ └── superset_extensions/
|
||||
│ │ └── hello_world/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── entrypoint.py # Backend registration
|
||||
│ └── pyproject.toml
|
||||
└── frontend/ # Frontend TypeScript/React code
|
||||
├── src/
|
||||
@@ -87,7 +96,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello_world",
|
||||
"id": "hello-world",
|
||||
"name": "Hello World",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
@@ -97,7 +106,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
"sqllab": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "hello_world.main",
|
||||
"id": "hello-world.main",
|
||||
"name": "Hello World"
|
||||
}
|
||||
]
|
||||
@@ -105,17 +114,20 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"exposes": ["./index"]
|
||||
"exposes": ["./index"],
|
||||
"name": "helloWorld"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entryPoints": ["hello_world.entrypoint"],
|
||||
"files": ["backend/src/hello_world/**/*.py"]
|
||||
"entryPoints": ["superset_extensions.hello_world.entrypoint"],
|
||||
"files": ["backend/src/superset_extensions/hello_world/**/*.py"]
|
||||
},
|
||||
"permissions": ["can_read"]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `moduleFederation.name` is automatically derived from the extension ID (`hello-world` → `helloWorld`), and backend entry points use the full Python namespace (`superset_extensions.hello_world`).
|
||||
|
||||
**Key fields:**
|
||||
|
||||
- `frontend.contributions.views.sqllab.panels`: Registers your panel in SQL Lab
|
||||
@@ -123,9 +135,9 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
|
||||
## Step 4: Create Backend API
|
||||
|
||||
The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create an API endpoint.
|
||||
The CLI generated a basic `backend/src/superset_extensions/hello_world/entrypoint.py`. We'll create an API endpoint.
|
||||
|
||||
**Create `backend/src/hello_world/api.py`**
|
||||
**Create `backend/src/superset_extensions/hello_world/api.py`**
|
||||
|
||||
```python
|
||||
from flask import Response
|
||||
@@ -174,10 +186,10 @@ class HelloWorldAPI(RestApi):
|
||||
- Extends `RestApi` from `superset_core.api.types.rest_api`
|
||||
- Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
|
||||
- Returns responses using `self.response(status_code, result=data)`
|
||||
- The endpoint will be accessible at `/extensions/hello_world/message`
|
||||
- The endpoint will be accessible at `/extensions/hello-world/message`
|
||||
- OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically generate interactive API documentation at `/swagger/v1`, allowing developers to explore endpoints, understand schemas, and test the API directly from the browser
|
||||
|
||||
**Update `backend/src/hello_world/entrypoint.py`**
|
||||
**Update `backend/src/superset_extensions/hello_world/entrypoint.py`**
|
||||
|
||||
Replace the generated print statement with API registration:
|
||||
|
||||
@@ -201,7 +213,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hello_world",
|
||||
"name": "hello-world",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -330,7 +342,7 @@ const HelloWorldPanel: React.FC = () => {
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const csrfToken = await authentication.getCSRFToken();
|
||||
const response = await fetch('/extensions/hello_world/message', {
|
||||
const response = await fetch('/extensions/hello-world/message', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -403,7 +415,7 @@ import HelloWorldPanel from './HelloWorldPanel';
|
||||
|
||||
export const activate = (context: core.ExtensionContext) => {
|
||||
context.disposables.push(
|
||||
core.registerViewProvider('hello_world.main', () => <HelloWorldPanel />),
|
||||
core.registerViewProvider('hello-world.main', () => <HelloWorldPanel />),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -413,7 +425,7 @@ export const deactivate = () => {};
|
||||
**Key patterns:**
|
||||
|
||||
- `activate` function is called when the extension loads
|
||||
- `core.registerViewProvider` registers the component with ID `hello_world.main` (matching `extension.json`)
|
||||
- `core.registerViewProvider` registers the component with ID `hello-world.main` (matching `extension.json`)
|
||||
- `authentication.getCSRFToken()` retrieves the CSRF token for API calls
|
||||
- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
|
||||
- `context.disposables.push()` ensures proper cleanup
|
||||
@@ -444,7 +456,7 @@ This command automatically:
|
||||
- `manifest.json` - Build metadata and asset references
|
||||
- `frontend/dist/` - Built frontend assets (remoteEntry.js, chunks)
|
||||
- `backend/` - Python source files
|
||||
- Packages everything into `hello_world-0.1.0.supx` - a zip archive with the specific structure required by Superset
|
||||
- Packages everything into `hello-world-0.1.0.supx` - a zip archive with the specific structure required by Superset
|
||||
|
||||
## Step 8: Deploy to Superset
|
||||
|
||||
@@ -469,7 +481,7 @@ EXTENSIONS_PATH = "/path/to/extensions/folder"
|
||||
Copy your `.supx` file to the configured extensions path:
|
||||
|
||||
```bash
|
||||
cp hello_world-0.1.0.supx /path/to/extensions/folder/
|
||||
cp hello-world-0.1.0.supx /path/to/extensions/folder/
|
||||
```
|
||||
|
||||
**Restart Superset**
|
||||
@@ -500,7 +512,7 @@ Here's what happens when your extension loads:
|
||||
4. **Module Federation**: Webpack loads your extension code and resolves `@apache-superset/core` to `window.superset`
|
||||
5. **Activation**: `activate()` is called, registering your view provider
|
||||
6. **Rendering**: When the user opens your panel, React renders `<HelloWorldPanel />`
|
||||
7. **API call**: Component fetches data from `/extensions/hello_world/message`
|
||||
7. **API call**: Component fetches data from `/extensions/hello-world/message`
|
||||
8. **Backend response**: Your Flask API returns the hello world message
|
||||
9. **Display**: Component shows the message to the user
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ from pydantic import BaseModel, Field # noqa: I001
|
||||
class ModuleFederationConfig(BaseModel):
|
||||
"""Configuration for Webpack Module Federation."""
|
||||
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Module Federation container name "
|
||||
"(must be valid JavaScript identifier)",
|
||||
)
|
||||
exposes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Modules exposed by this extension",
|
||||
|
||||
@@ -38,7 +38,20 @@ from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from superset_extensions_cli.constants import MIN_NPM_VERSION
|
||||
from superset_extensions_cli.utils import read_json, read_toml
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.types import ExtensionNames
|
||||
from superset_extensions_cli.utils import (
|
||||
generate_extension_names,
|
||||
kebab_to_camel_case,
|
||||
kebab_to_snake_case,
|
||||
read_json,
|
||||
read_toml,
|
||||
to_kebab_case,
|
||||
to_snake_case,
|
||||
validate_extension_id,
|
||||
validate_npm_package_name,
|
||||
validate_python_package_name,
|
||||
)
|
||||
|
||||
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
|
||||
FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist")
|
||||
@@ -403,14 +416,127 @@ def dev(ctx: click.Context) -> None:
|
||||
click.secho("❌ No directories to watch. Exiting.", fg="red")
|
||||
|
||||
|
||||
def prompt_for_extension_name(
|
||||
display_name_opt: str | None, id_opt: str | None
|
||||
) -> ExtensionNames:
|
||||
"""
|
||||
Prompt for extension name with graceful validation and re-prompting.
|
||||
|
||||
Args:
|
||||
display_name_opt: Display name provided via CLI option (if any)
|
||||
id_opt: Extension ID provided via CLI option (if any)
|
||||
|
||||
Returns:
|
||||
ExtensionNames: Validated extension name variants
|
||||
"""
|
||||
|
||||
# Case 1: Both provided via CLI - validate they work together
|
||||
if display_name_opt and id_opt:
|
||||
try:
|
||||
# Generate all names from display name for consistency
|
||||
temp_names = generate_extension_names(display_name_opt)
|
||||
# Check if the provided ID matches what we'd generate
|
||||
if temp_names["id"] == id_opt:
|
||||
return temp_names
|
||||
else:
|
||||
# If IDs don't match, use the provided ID but validate it
|
||||
validate_extension_id(id_opt)
|
||||
validate_python_package_name(to_snake_case(id_opt))
|
||||
validate_npm_package_name(id_opt)
|
||||
# Create names with the provided ID (derive technical names from ID)
|
||||
return ExtensionNames(
|
||||
name=display_name_opt,
|
||||
id=id_opt,
|
||||
mf_name=kebab_to_camel_case(id_opt),
|
||||
backend_name=kebab_to_snake_case(id_opt),
|
||||
backend_package=f"superset_extensions.{kebab_to_snake_case(id_opt)}",
|
||||
backend_entry=f"superset_extensions.{kebab_to_snake_case(id_opt)}.entrypoint",
|
||||
)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
# Case 2: Only display name provided - suggest ID
|
||||
if display_name_opt and not id_opt:
|
||||
display_name = display_name_opt
|
||||
try:
|
||||
suggested_names = generate_extension_names(display_name)
|
||||
suggested_id = suggested_names["id"]
|
||||
except ExtensionNameError:
|
||||
suggested_id = to_kebab_case(display_name)
|
||||
|
||||
extension_id = click.prompt("Extension ID", default=suggested_id, type=str)
|
||||
|
||||
# Case 3: Only ID provided - ask for display name
|
||||
elif id_opt and not display_name_opt:
|
||||
extension_id = id_opt
|
||||
# Validate the provided ID first
|
||||
try:
|
||||
validate_extension_id(id_opt)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
# Suggest display name from kebab ID
|
||||
suggested_display = " ".join(word.capitalize() for word in id_opt.split("-"))
|
||||
display_name = click.prompt(
|
||||
"Extension name", default=suggested_display, type=str
|
||||
)
|
||||
|
||||
# Case 4: Neither provided - ask for both
|
||||
else:
|
||||
display_name = click.prompt("Extension name (e.g. Hello World)", type=str)
|
||||
try:
|
||||
suggested_names = generate_extension_names(display_name)
|
||||
suggested_id = suggested_names["id"]
|
||||
except ExtensionNameError:
|
||||
suggested_id = to_kebab_case(display_name)
|
||||
|
||||
extension_id = click.prompt("Extension ID", default=suggested_id, type=str)
|
||||
|
||||
# Final validation loop - try to use generate_extension_names for consistent results
|
||||
display_name_failed = False # Track if display name validation failed
|
||||
while True:
|
||||
try:
|
||||
# First try to generate from display name if possible and it hasn't failed before
|
||||
if display_name and not display_name_failed:
|
||||
temp_names = generate_extension_names(display_name)
|
||||
if temp_names["id"] == extension_id:
|
||||
# Perfect match - use generated names
|
||||
return temp_names
|
||||
|
||||
# If no match or display name failed, validate manually and construct
|
||||
validate_extension_id(extension_id)
|
||||
validate_python_package_name(to_snake_case(extension_id))
|
||||
validate_npm_package_name(extension_id)
|
||||
|
||||
return ExtensionNames(
|
||||
name=display_name,
|
||||
id=extension_id,
|
||||
mf_name=kebab_to_camel_case(extension_id),
|
||||
backend_name=kebab_to_snake_case(extension_id),
|
||||
backend_package=f"superset_extensions.{kebab_to_snake_case(extension_id)}",
|
||||
backend_entry=f"superset_extensions.{kebab_to_snake_case(extension_id)}.entrypoint",
|
||||
)
|
||||
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
# If the error came from generate_extension_names, stop trying it
|
||||
if "display_name" in str(e) or not display_name_failed:
|
||||
display_name_failed = True
|
||||
extension_id = click.prompt("Extension ID", type=str)
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.option(
|
||||
"--id",
|
||||
"id_opt",
|
||||
default=None,
|
||||
help="Extension ID (alphanumeric and underscores only)",
|
||||
help="Extension ID (kebab-case, e.g. hello-world)",
|
||||
)
|
||||
@click.option(
|
||||
"--name", "name_opt", default=None, help="Extension display name (e.g. Hello World)"
|
||||
)
|
||||
@click.option("--name", "name_opt", default=None, help="Extension display name")
|
||||
@click.option(
|
||||
"--version", "version_opt", default=None, help="Initial version (default: 0.1.0)"
|
||||
)
|
||||
@@ -431,18 +557,9 @@ def init(
|
||||
frontend_opt: bool | None,
|
||||
backend_opt: bool | None,
|
||||
) -> None:
|
||||
id_ = id_opt or click.prompt(
|
||||
"Extension ID (unique identifier, alphanumeric only)", type=str
|
||||
)
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", id_):
|
||||
click.secho(
|
||||
"❌ ID must be alphanumeric (letters, digits, underscore).", fg="red"
|
||||
)
|
||||
sys.exit(1)
|
||||
# Get extension names with graceful validation
|
||||
names = prompt_for_extension_name(name_opt, id_opt)
|
||||
|
||||
name = name_opt or click.prompt(
|
||||
"Extension name (human-readable display name)", type=str
|
||||
)
|
||||
version = version_opt or click.prompt("Initial version", default="0.1.0")
|
||||
license_ = license_opt or click.prompt("License", default="Apache-2.0")
|
||||
include_frontend = (
|
||||
@@ -456,7 +573,7 @@ def init(
|
||||
else click.confirm("Include backend?", default=True)
|
||||
)
|
||||
|
||||
target_dir = Path.cwd() / id_
|
||||
target_dir = Path.cwd() / names["id"]
|
||||
if target_dir.exists():
|
||||
click.secho(f"❌ Directory {target_dir} already exists.", fg="red")
|
||||
sys.exit(1)
|
||||
@@ -465,8 +582,7 @@ def init(
|
||||
templates_dir = Path(__file__).parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701
|
||||
ctx = {
|
||||
"id": id_,
|
||||
"name": name,
|
||||
**names, # Include all name variants
|
||||
"include_frontend": include_frontend,
|
||||
"include_backend": include_backend,
|
||||
"license": license_,
|
||||
@@ -502,29 +618,41 @@ def init(
|
||||
(frontend_src_dir / "index.tsx").write_text(index_tsx)
|
||||
click.secho("✅ Created frontend folder structure", fg="green")
|
||||
|
||||
# Initialize backend files
|
||||
# Initialize backend files with superset_extensions namespace
|
||||
if include_backend:
|
||||
backend_dir = target_dir / "backend"
|
||||
backend_dir.mkdir()
|
||||
backend_src_dir = backend_dir / "src"
|
||||
backend_src_dir.mkdir()
|
||||
backend_src_package_dir = backend_src_dir / id_
|
||||
backend_src_package_dir.mkdir()
|
||||
|
||||
# Create superset_extensions namespace directory
|
||||
namespace_dir = backend_src_dir / "superset_extensions"
|
||||
namespace_dir.mkdir()
|
||||
|
||||
# Create extension package directory
|
||||
extension_package_dir = namespace_dir / names["backend_name"]
|
||||
extension_package_dir.mkdir()
|
||||
|
||||
# backend files
|
||||
pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx)
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_toml)
|
||||
|
||||
# Namespace package __init__.py (empty for namespace)
|
||||
(namespace_dir / "__init__.py").write_text("")
|
||||
|
||||
# Extension package files
|
||||
init_py = env.get_template("backend/src/package/__init__.py.j2").render(ctx)
|
||||
(backend_src_package_dir / "__init__.py").write_text(init_py)
|
||||
(extension_package_dir / "__init__.py").write_text(init_py)
|
||||
entrypoint_py = env.get_template("backend/src/package/entrypoint.py.j2").render(
|
||||
ctx
|
||||
)
|
||||
(backend_src_package_dir / "entrypoint.py").write_text(entrypoint_py)
|
||||
(extension_package_dir / "entrypoint.py").write_text(entrypoint_py)
|
||||
|
||||
click.secho("✅ Created backend folder structure", fg="green")
|
||||
|
||||
click.secho(
|
||||
f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan"
|
||||
f"🎉 Extension {names['name']} (ID: {names['id']}) initialized at {target_dir}",
|
||||
fg="cyan",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
class ExtensionNameError(Exception):
|
||||
"""Raised when extension name validation fails."""
|
||||
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
[project]
|
||||
name = "{{ id }}"
|
||||
name = "{{ backend_package }}"
|
||||
version = "{{ version }}"
|
||||
license = "{{ license }}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
print("{{ name }} extension registered")
|
||||
print("{{ display_name }} extension registered")
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
"menus": {}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"name": "{{ mf_name }}",
|
||||
"exposes": ["./index"]
|
||||
}
|
||||
},
|
||||
{% endif -%}
|
||||
{% if include_backend -%}
|
||||
"backend": {
|
||||
"entryPoints": ["{{ id }}.entrypoint"],
|
||||
"files": ["backend/src/{{ id }}/**/*.py"]
|
||||
"entryPoints": ["{{ backend_entry }}"],
|
||||
"files": ["backend/src/superset_extensions/{{ backend_name }}/**/*.py"]
|
||||
},
|
||||
{% endif -%}
|
||||
"permissions": []
|
||||
|
||||
@@ -39,7 +39,7 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: "{{ id }}",
|
||||
name: "{{ mf_name }}",
|
||||
filename: "remoteEntry.[contenthash].js",
|
||||
exposes: {
|
||||
"./index": "./src/index.tsx",
|
||||
|
||||
40
superset-extensions-cli/src/superset_extensions_cli/types.py
Normal file
40
superset-extensions-cli/src/superset_extensions_cli/types.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class ExtensionNames(TypedDict):
|
||||
"""Type definition for extension name variants following platform conventions."""
|
||||
|
||||
# Extension name (e.g., "Hello World")
|
||||
name: str
|
||||
|
||||
# Extension ID - kebab-case primary identifier and npm package name (e.g., "hello-world")
|
||||
id: str
|
||||
|
||||
# Module Federation library - camelCase JS identifier (e.g., "helloWorld")
|
||||
mf_name: str
|
||||
|
||||
# Backend package name - snake_case (e.g., "hello_world")
|
||||
backend_name: str
|
||||
|
||||
# Full backend package (e.g., "superset_extensions.hello_world")
|
||||
backend_package: str
|
||||
|
||||
# Backend entry point (e.g., "superset_extensions.hello_world.entrypoint")
|
||||
backend_entry: str
|
||||
@@ -16,15 +16,75 @@
|
||||
# under the License.
|
||||
|
||||
import json # noqa: TID251
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.types import ExtensionNames
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
import tomli as tomllib
|
||||
|
||||
# Python reserved keywords to avoid in package names
|
||||
PYTHON_KEYWORDS = {
|
||||
"and",
|
||||
"as",
|
||||
"assert",
|
||||
"break",
|
||||
"class",
|
||||
"continue",
|
||||
"def",
|
||||
"del",
|
||||
"elif",
|
||||
"else",
|
||||
"except",
|
||||
"exec",
|
||||
"finally",
|
||||
"for",
|
||||
"from",
|
||||
"global",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"is",
|
||||
"lambda",
|
||||
"not",
|
||||
"or",
|
||||
"pass",
|
||||
"print",
|
||||
"raise",
|
||||
"return",
|
||||
"try",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
"False",
|
||||
"None",
|
||||
"True",
|
||||
}
|
||||
|
||||
# npm reserved names to avoid
|
||||
NPM_RESERVED = {
|
||||
"node_modules",
|
||||
"favicon.ico",
|
||||
"www",
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
"localhost",
|
||||
"package.json",
|
||||
"npm",
|
||||
"yarn",
|
||||
"bower_components",
|
||||
}
|
||||
|
||||
# Extension name pattern: lowercase, start with letter or number, alphanumeric + hyphens
|
||||
EXTENSION_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def read_toml(path: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
@@ -40,3 +100,205 @@ def read_json(path: Path) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def _normalize_for_identifiers(name: str) -> str:
|
||||
"""
|
||||
Normalize display name to clean lowercase words.
|
||||
|
||||
Args:
|
||||
name: Raw display name (e.g., "Hello World!")
|
||||
|
||||
Returns:
|
||||
Normalized string (e.g., "hello world")
|
||||
"""
|
||||
# Convert to lowercase
|
||||
normalized = name.lower().strip()
|
||||
|
||||
# Convert underscores and existing hyphens to spaces for consistent processing
|
||||
normalized = normalized.replace("_", " ").replace("-", " ")
|
||||
|
||||
# Remove any non-alphanumeric characters except spaces
|
||||
normalized = re.sub(r"[^a-z0-9\s]", "", normalized)
|
||||
|
||||
# Normalize whitespace (collapse multiple spaces, strip)
|
||||
normalized = " ".join(normalized.split())
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_to_kebab(normalized: str) -> str:
|
||||
"""Convert normalized string to kebab-case."""
|
||||
return normalized.replace(" ", "-")
|
||||
|
||||
|
||||
def _normalized_to_snake(normalized: str) -> str:
|
||||
"""Convert normalized string to snake_case."""
|
||||
return normalized.replace(" ", "_")
|
||||
|
||||
|
||||
def _normalized_to_camel(normalized: str) -> str:
|
||||
"""Convert normalized string to camelCase."""
|
||||
parts = normalized.split()
|
||||
if not parts:
|
||||
return ""
|
||||
# First part lowercase, subsequent parts capitalized
|
||||
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
||||
|
||||
|
||||
def kebab_to_camel_case(kebab_name: str) -> str:
|
||||
"""Convert kebab-case to camelCase (e.g., 'hello-world' -> 'helloWorld')."""
|
||||
parts = kebab_name.split("-")
|
||||
if not parts:
|
||||
return ""
|
||||
# First part lowercase, subsequent parts capitalized
|
||||
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
||||
|
||||
|
||||
def kebab_to_snake_case(kebab_name: str) -> str:
|
||||
"""Convert kebab-case to snake_case (e.g., 'hello-world' -> 'hello_world')."""
|
||||
return kebab_name.replace("-", "_")
|
||||
|
||||
|
||||
def name_to_kebab_case(name: str) -> str:
|
||||
"""Convert display name directly to kebab-case (e.g., 'Hello World' -> 'hello-world')."""
|
||||
normalized = _normalize_for_identifiers(name)
|
||||
return _normalized_to_kebab(normalized)
|
||||
|
||||
|
||||
# Legacy functions for backward compatibility
|
||||
def to_kebab_case(name: str) -> str:
|
||||
"""Convert display name to kebab-case. For new code, use name_to_kebab_case."""
|
||||
return name_to_kebab_case(name)
|
||||
|
||||
|
||||
def to_snake_case(kebab_name: str) -> str:
|
||||
"""Convert kebab-case to snake_case. For new code, use kebab_to_snake_case."""
|
||||
return kebab_to_snake_case(kebab_name)
|
||||
|
||||
|
||||
def validate_extension_id(extension_id: str) -> None:
|
||||
"""
|
||||
Validate extension ID format (kebab-case).
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If ID is invalid
|
||||
"""
|
||||
if not extension_id:
|
||||
raise ExtensionNameError("Extension ID cannot be empty")
|
||||
|
||||
# Check for leading/trailing hyphens first
|
||||
if extension_id.startswith("-"):
|
||||
raise ExtensionNameError("Extension ID cannot start with hyphens")
|
||||
|
||||
if extension_id.endswith("-"):
|
||||
raise ExtensionNameError("Extension ID cannot end with hyphens")
|
||||
|
||||
# Check for consecutive hyphens
|
||||
if "--" in extension_id:
|
||||
raise ExtensionNameError("Extension ID cannot have consecutive hyphens")
|
||||
|
||||
# Check overall pattern
|
||||
if not EXTENSION_NAME_PATTERN.match(extension_id):
|
||||
raise ExtensionNameError(
|
||||
"Use lowercase letters, numbers, and hyphens only (e.g. hello-world)"
|
||||
)
|
||||
|
||||
|
||||
def validate_extension_name(name: str) -> str:
|
||||
"""
|
||||
Validate and normalize extension name (human-readable).
|
||||
|
||||
Args:
|
||||
extension_name: Raw extension name input
|
||||
|
||||
Returns:
|
||||
Cleaned extension name
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If extension name is invalid
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
raise ExtensionNameError("Extension name cannot be empty")
|
||||
|
||||
# Normalize whitespace: strip and collapse multiple spaces
|
||||
normalized = " ".join(name.strip().split())
|
||||
|
||||
# Check for only whitespace/special chars after normalization
|
||||
if not any(c.isalnum() for c in normalized):
|
||||
raise ExtensionNameError(
|
||||
"Extension name must contain at least one letter or number"
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_python_package_name(name: str) -> None:
|
||||
"""
|
||||
Validate Python package name (snake_case format).
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If name is invalid
|
||||
"""
|
||||
# Check if it starts with a number (invalid for Python identifiers)
|
||||
if name[0].isdigit():
|
||||
raise ExtensionNameError(f"Package name '{name}' cannot start with a number")
|
||||
|
||||
# Check if the first part (before any underscore) is a Python keyword
|
||||
if (first_part := name.split("_")[0]) in PYTHON_KEYWORDS:
|
||||
raise ExtensionNameError(
|
||||
f"Package name cannot start with Python keyword '{first_part}'"
|
||||
)
|
||||
|
||||
# Check if it's a valid Python identifier
|
||||
if not name.replace("_", "a").isalnum():
|
||||
raise ExtensionNameError(f"'{name}' is not a valid Python package name")
|
||||
|
||||
|
||||
def validate_npm_package_name(name: str) -> None:
|
||||
"""
|
||||
Validate npm package name (kebab-case format).
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If name is invalid
|
||||
"""
|
||||
if name.lower() in NPM_RESERVED:
|
||||
raise ExtensionNameError(f"'{name}' is a reserved npm package name")
|
||||
|
||||
|
||||
def generate_extension_names(name: str) -> ExtensionNames:
|
||||
"""
|
||||
Generate all extension name variants from display name input.
|
||||
|
||||
Flow: Display Name -> Generate ID -> Derive Technical Names from ID
|
||||
Example: "Hello World" -> "hello-world" -> "helloWorld"/"hello_world" (from ID)
|
||||
|
||||
Returns:
|
||||
ExtensionNames: Dictionary with all name variants
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If any generated name is invalid
|
||||
"""
|
||||
# Validate and normalize the extension name
|
||||
name = validate_extension_name(name)
|
||||
|
||||
# Generate ID from display name
|
||||
kebab_name = name_to_kebab_case(name)
|
||||
|
||||
# Derive all technical names from the generated ID (not display name)
|
||||
snake_name = kebab_to_snake_case(kebab_name)
|
||||
camel_name = kebab_to_camel_case(kebab_name)
|
||||
|
||||
# Validate the generated names
|
||||
validate_extension_id(kebab_name)
|
||||
validate_python_package_name(snake_name)
|
||||
validate_npm_package_name(kebab_name)
|
||||
|
||||
return ExtensionNames(
|
||||
name=name,
|
||||
id=kebab_name,
|
||||
mf_name=camel_name,
|
||||
backend_name=snake_name,
|
||||
backend_package=f"superset_extensions.{snake_name}",
|
||||
backend_entry=f"superset_extensions.{snake_name}.entrypoint",
|
||||
)
|
||||
|
||||
@@ -58,25 +58,25 @@ def extension_params():
|
||||
@pytest.fixture
|
||||
def cli_input_both():
|
||||
"""CLI input for creating extension with both frontend and backend."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
return "Test Extension\n\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_frontend_only():
|
||||
"""CLI input for creating extension with frontend only."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\nn\n"
|
||||
return "Test Extension\n\n0.1.0\nApache-2.0\ny\nn\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_backend_only():
|
||||
"""CLI input for creating extension with backend only."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\ny\n"
|
||||
return "Test Extension\n\n0.1.0\nApache-2.0\nn\ny\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_neither():
|
||||
"""CLI input for creating extension with neither frontend nor backend."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\nn\n"
|
||||
return "Test Extension\n\n0.1.0\nApache-2.0\nn\nn\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -43,16 +43,16 @@ def test_init_creates_extension_with_both_frontend_and_backend(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
assert (
|
||||
"🎉 Extension Test Extension (ID: test_extension) initialized" in result.output
|
||||
"🎉 Extension Test Extension (ID: test-extension) initialized" in result.output
|
||||
)
|
||||
|
||||
# Verify directory structure
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
assert_directory_exists(extension_path, "main extension directory")
|
||||
|
||||
expected_structure = create_test_extension_structure(
|
||||
isolated_filesystem,
|
||||
"test_extension",
|
||||
"test-extension",
|
||||
include_frontend=True,
|
||||
include_backend=True,
|
||||
)
|
||||
@@ -73,7 +73,7 @@ def test_init_creates_extension_with_frontend_only(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should have frontend directory and package.json
|
||||
@@ -96,7 +96,7 @@ def test_init_creates_extension_with_backend_only(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should have backend directory and pyproject.toml
|
||||
@@ -119,7 +119,7 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should only have extension.json
|
||||
@@ -130,54 +130,52 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
|
||||
assert not (extension_path / "backend").exists()
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_any_display_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts any display name and generates proper ID."""
|
||||
cli_input = "My Awesome Extension!\n\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, f"Should accept display name: {result.output}"
|
||||
assert Path("my-awesome-extension").exists(), (
|
||||
"Directory for generated ID should be created"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_mixed_alphanumeric_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts mixed alphanumeric display names."""
|
||||
cli_input = "Tool 123\n\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, (
|
||||
f"Mixed alphanumeric display name should be valid: {result.output}"
|
||||
)
|
||||
assert Path("tool-123").exists(), "Directory for 'tool-123' should be created"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_name,expected_error",
|
||||
"display_name,expected_id",
|
||||
[
|
||||
("test-extension", "must be alphanumeric"),
|
||||
("test extension", "must be alphanumeric"),
|
||||
("test.extension", "must be alphanumeric"),
|
||||
("test@extension", "must be alphanumeric"),
|
||||
("", "must be alphanumeric"),
|
||||
("Test Extension", "test-extension"),
|
||||
("My Tool v2", "my-tool-v2"),
|
||||
("Dashboard Helper", "dashboard-helper"),
|
||||
("Chart Builder Pro", "chart-builder-pro"),
|
||||
],
|
||||
)
|
||||
def test_init_validates_extension_name(
|
||||
cli_runner, isolated_filesystem, invalid_name, expected_error
|
||||
):
|
||||
"""Test that init validates extension names according to regex pattern."""
|
||||
cli_input = f"{invalid_name}\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 1, (
|
||||
f"Expected command to fail for invalid name '{invalid_name}'"
|
||||
)
|
||||
assert expected_error in result.output
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_numeric_extension_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts numeric extension ids like '123'."""
|
||||
cli_input = "123\n123\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, f"Numeric id '123' should be valid: {result.output}"
|
||||
assert Path("123").exists(), "Directory for '123' should be created"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@pytest.mark.parametrize(
|
||||
"valid_id", ["test123", "TestExtension", "test_extension_123", "MyExt_1"]
|
||||
)
|
||||
def test_init_with_valid_alphanumeric_names(cli_runner, valid_id):
|
||||
"""Test that init accepts various valid alphanumeric names."""
|
||||
def test_init_with_various_display_names(cli_runner, display_name, expected_id):
|
||||
"""Test that init accepts various display names and generates proper IDs."""
|
||||
with cli_runner.isolated_filesystem():
|
||||
cli_input = f"{valid_id}\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
cli_input = f"{display_name}\n\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, (
|
||||
f"Valid name '{valid_id}' was rejected: {result.output}"
|
||||
f"Valid display name '{display_name}' was rejected: {result.output}"
|
||||
)
|
||||
assert Path(expected_id).exists(), (
|
||||
f"Directory for '{expected_id}' was not created"
|
||||
)
|
||||
assert Path(valid_id).exists(), f"Directory for '{valid_id}' was not created"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -186,7 +184,7 @@ def test_init_fails_when_directory_already_exists(
|
||||
):
|
||||
"""Test that init fails gracefully when target directory already exists."""
|
||||
# Create the directory first
|
||||
existing_dir = isolated_filesystem / "test_extension"
|
||||
existing_dir = isolated_filesystem / "test-extension"
|
||||
existing_dir.mkdir()
|
||||
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
@@ -203,14 +201,14 @@ def test_extension_json_content_is_correct(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
extension_json_path = extension_path / "extension.json"
|
||||
|
||||
# Verify the JSON structure and values
|
||||
assert_json_content(
|
||||
extension_json_path,
|
||||
{
|
||||
"id": "test_extension",
|
||||
"id": "test-extension",
|
||||
"name": "Test Extension",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
@@ -227,15 +225,20 @@ def test_extension_json_content_is_correct(
|
||||
assert "contributions" in frontend
|
||||
assert "moduleFederation" in frontend
|
||||
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
|
||||
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
|
||||
assert frontend["moduleFederation"] == {
|
||||
"exposes": ["./index"],
|
||||
"name": "testExtension",
|
||||
}
|
||||
|
||||
# Verify backend section exists and has correct structure
|
||||
assert "backend" in content
|
||||
backend = content["backend"]
|
||||
assert "entryPoints" in backend
|
||||
assert "files" in backend
|
||||
assert backend["entryPoints"] == ["test_extension.entrypoint"]
|
||||
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
|
||||
assert backend["entryPoints"] == ["superset_extensions.test_extension.entrypoint"]
|
||||
assert backend["files"] == [
|
||||
"backend/src/superset_extensions/test_extension/**/*.py"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -246,14 +249,14 @@ def test_frontend_package_json_content_is_correct(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
package_json_path = extension_path / "frontend" / "package.json"
|
||||
|
||||
# Verify the package.json structure and values
|
||||
assert_json_content(
|
||||
package_json_path,
|
||||
{
|
||||
"name": "test_extension",
|
||||
"name": "test-extension",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
},
|
||||
@@ -275,14 +278,14 @@ def test_backend_pyproject_toml_is_created(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
pyproject_path = extension_path / "backend" / "pyproject.toml"
|
||||
|
||||
assert_file_exists(pyproject_path, "backend pyproject.toml")
|
||||
|
||||
# Basic content verification (without parsing TOML for now)
|
||||
content = pyproject_path.read_text()
|
||||
assert "test_extension" in content
|
||||
assert "superset_extensions.test_extension" in content
|
||||
assert "0.1.0" in content
|
||||
assert "Apache-2.0" in content
|
||||
|
||||
@@ -300,7 +303,7 @@ def test_init_command_output_messages(cli_runner, isolated_filesystem, cli_input
|
||||
assert "Created .gitignore" in output
|
||||
assert "Created frontend folder structure" in output
|
||||
assert "Created backend folder structure" in output
|
||||
assert "Extension Test Extension (ID: test_extension) initialized" in output
|
||||
assert "Extension Test Extension (ID: test-extension) initialized" in output
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -309,7 +312,7 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-extension"
|
||||
gitignore_path = extension_path / ".gitignore"
|
||||
|
||||
assert_file_exists(gitignore_path, ".gitignore")
|
||||
@@ -329,18 +332,18 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
|
||||
@pytest.mark.cli
|
||||
def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
|
||||
"""Test init with custom version and license parameters."""
|
||||
cli_input = "my_extension\nMy Extension\n2.1.0\nMIT\ny\nn\n"
|
||||
cli_input = "My Extension\n\n2.1.0\nMIT\ny\nn\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "my_extension"
|
||||
extension_path = isolated_filesystem / "my-extension"
|
||||
extension_json_path = extension_path / "extension.json"
|
||||
|
||||
assert_json_content(
|
||||
extension_json_path,
|
||||
{
|
||||
"id": "my_extension",
|
||||
"id": "my-extension",
|
||||
"name": "My Extension",
|
||||
"version": "2.1.0",
|
||||
"license": "MIT",
|
||||
@@ -353,17 +356,17 @@ def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
|
||||
def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
|
||||
"""Integration test for the complete init workflow."""
|
||||
# Test the complete flow with realistic user input
|
||||
cli_input = "awesome_charts\nAwesome Charts\n1.0.0\nApache-2.0\ny\ny\n"
|
||||
cli_input = "Awesome Charts\n\n1.0.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify complete directory structure
|
||||
extension_path = isolated_filesystem / "awesome_charts"
|
||||
extension_path = isolated_filesystem / "awesome-charts"
|
||||
expected_structure = create_test_extension_structure(
|
||||
isolated_filesystem,
|
||||
"awesome_charts",
|
||||
"awesome-charts",
|
||||
include_frontend=True,
|
||||
include_backend=True,
|
||||
)
|
||||
@@ -374,16 +377,16 @@ def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
|
||||
|
||||
# Verify all generated files have correct content
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["id"] == "awesome_charts"
|
||||
assert extension_json["id"] == "awesome-charts"
|
||||
assert extension_json["name"] == "Awesome Charts"
|
||||
assert extension_json["version"] == "1.0.0"
|
||||
assert extension_json["license"] == "Apache-2.0"
|
||||
|
||||
package_json = load_json_file(extension_path / "frontend" / "package.json")
|
||||
assert package_json["name"] == "awesome_charts"
|
||||
assert package_json["name"] == "awesome-charts"
|
||||
|
||||
pyproject_content = (extension_path / "backend" / "pyproject.toml").read_text()
|
||||
assert "awesome_charts" in pyproject_content
|
||||
assert "superset_extensions.awesome_charts" in pyproject_content
|
||||
|
||||
|
||||
# Non-interactive mode tests
|
||||
@@ -395,7 +398,7 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"my_ext",
|
||||
"my-ext",
|
||||
"--name",
|
||||
"My Extension",
|
||||
"--version",
|
||||
@@ -408,15 +411,15 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
assert "🎉 Extension My Extension (ID: my_ext) initialized" in result.output
|
||||
assert "🎉 Extension My Extension (ID: my-ext) initialized" in result.output
|
||||
|
||||
extension_path = isolated_filesystem / "my_ext"
|
||||
extension_path = isolated_filesystem / "my-ext"
|
||||
assert_directory_exists(extension_path)
|
||||
assert_directory_exists(extension_path / "frontend")
|
||||
assert_directory_exists(extension_path / "backend")
|
||||
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["id"] == "my_ext"
|
||||
assert extension_json["id"] == "my-ext"
|
||||
assert extension_json["name"] == "My Extension"
|
||||
assert extension_json["version"] == "1.0.0"
|
||||
assert extension_json["license"] == "MIT"
|
||||
@@ -430,7 +433,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"frontend_ext",
|
||||
"frontend-ext",
|
||||
"--name",
|
||||
"Frontend Extension",
|
||||
"--version",
|
||||
@@ -444,7 +447,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "frontend_ext"
|
||||
extension_path = isolated_filesystem / "frontend-ext"
|
||||
assert_directory_exists(extension_path / "frontend")
|
||||
assert not (extension_path / "backend").exists()
|
||||
|
||||
@@ -457,7 +460,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"backend_ext",
|
||||
"backend-ext",
|
||||
"--name",
|
||||
"Backend Extension",
|
||||
"--version",
|
||||
@@ -471,7 +474,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "backend_ext"
|
||||
extension_path = isolated_filesystem / "backend-ext"
|
||||
assert not (extension_path / "frontend").exists()
|
||||
assert_directory_exists(extension_path / "backend")
|
||||
|
||||
@@ -485,7 +488,7 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"default_ext",
|
||||
"default-ext",
|
||||
"--name",
|
||||
"Default Extension",
|
||||
"--frontend",
|
||||
@@ -496,7 +499,7 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "default_ext"
|
||||
extension_path = isolated_filesystem / "default-ext"
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["version"] == "0.1.0"
|
||||
assert extension_json["license"] == "Apache-2.0"
|
||||
@@ -510,7 +513,7 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"invalid-id",
|
||||
"invalid_name",
|
||||
"--name",
|
||||
"Invalid Extension",
|
||||
"--frontend",
|
||||
@@ -519,4 +522,4 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "must be alphanumeric" in result.output
|
||||
assert "Use lowercase letters, numbers, and hyphens only" in result.output
|
||||
|
||||
400
superset-extensions-cli/tests/test_name_transformations.py
Normal file
400
superset-extensions-cli/tests/test_name_transformations.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pytest
|
||||
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.utils import (
|
||||
generate_extension_names,
|
||||
kebab_to_camel_case,
|
||||
kebab_to_snake_case,
|
||||
name_to_kebab_case,
|
||||
to_snake_case, # Keep this for backward compatibility testing only
|
||||
validate_extension_name,
|
||||
validate_extension_id,
|
||||
validate_npm_package_name,
|
||||
validate_python_package_name,
|
||||
)
|
||||
|
||||
|
||||
class TestNameTransformations:
|
||||
"""Test name transformation functions."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"display_name,expected",
|
||||
[
|
||||
("Hello World", "hello-world"),
|
||||
("Data Explorer", "data-explorer"),
|
||||
("My Extension", "my-extension"),
|
||||
("hello-world", "hello-world"), # Already normalized
|
||||
("Hello@World!", "helloworld"), # Special chars removed
|
||||
(
|
||||
"Data_Explorer",
|
||||
"data-explorer",
|
||||
), # Underscores become spaces then hyphens
|
||||
("My Extension", "my-extension"), # Multiple spaces normalized
|
||||
(" Hello World ", "hello-world"), # Trimmed
|
||||
("API v2 Client", "api-v2-client"), # Numbers preserved
|
||||
("Simple", "simple"), # Single word
|
||||
],
|
||||
)
|
||||
def test_name_to_kebab_case(self, display_name, expected):
|
||||
"""Test direct kebab case conversion from display names."""
|
||||
assert name_to_kebab_case(display_name) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kebab_name,expected",
|
||||
[
|
||||
("hello-world", "helloWorld"),
|
||||
("data-explorer", "dataExplorer"),
|
||||
("my-extension", "myExtension"),
|
||||
("api-v2-client", "apiV2Client"),
|
||||
("simple", "simple"), # Single word
|
||||
("chart-tool", "chartTool"),
|
||||
("dashboard-helper", "dashboardHelper"),
|
||||
],
|
||||
)
|
||||
def test_kebab_to_camel_case(self, kebab_name, expected):
|
||||
"""Test kebab-case to camelCase conversion."""
|
||||
assert kebab_to_camel_case(kebab_name) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kebab_name,expected",
|
||||
[
|
||||
("hello-world", "hello_world"),
|
||||
("data-explorer", "data_explorer"),
|
||||
("my-extension", "my_extension"),
|
||||
("api-v2-client", "api_v2_client"),
|
||||
("simple", "simple"), # Single word
|
||||
("chart-tool", "chart_tool"),
|
||||
("dashboard-helper", "dashboard_helper"),
|
||||
],
|
||||
)
|
||||
def test_kebab_to_snake_case(self, kebab_name, expected):
|
||||
"""Test kebab-case to snake_case conversion."""
|
||||
assert kebab_to_snake_case(kebab_name) == expected
|
||||
|
||||
# Backward compatibility test for remaining legacy function
|
||||
@pytest.mark.parametrize(
|
||||
"input_name,expected",
|
||||
[
|
||||
("hello-world", "hello_world"),
|
||||
("data-explorer", "data_explorer"),
|
||||
("my-extension-name", "my_extension_name"),
|
||||
],
|
||||
)
|
||||
def test_to_snake_case_legacy(self, input_name, expected):
|
||||
"""Test legacy kebab-to-snake conversion function."""
|
||||
assert to_snake_case(input_name) == expected
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test validation functions."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_display",
|
||||
[
|
||||
"Hello World",
|
||||
"Data Explorer",
|
||||
"My Extension",
|
||||
"Simple",
|
||||
" Extra Spaces ", # Gets normalized
|
||||
],
|
||||
)
|
||||
def test_validate_extension_name_valid(self, valid_display):
|
||||
"""Test valid display names."""
|
||||
result = validate_extension_name(valid_display)
|
||||
assert result # Should return normalized name
|
||||
assert " " not in result # No double spaces
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_display,error_match",
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(" ", "cannot be empty"),
|
||||
("@#$%", "must contain at least one letter or number"),
|
||||
],
|
||||
)
|
||||
def test_validate_extension_name_invalid(self, invalid_display, error_match):
|
||||
"""Test invalid extension names."""
|
||||
with pytest.raises(ExtensionNameError, match=error_match):
|
||||
validate_extension_name(invalid_display)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_id",
|
||||
[
|
||||
"hello-world",
|
||||
"data-explorer",
|
||||
"myext",
|
||||
"chart123",
|
||||
"my-tool-v2",
|
||||
"a", # Single character
|
||||
"extension-with-many-parts",
|
||||
],
|
||||
)
|
||||
def test_validate_extension_id_valid(self, valid_id):
|
||||
"""Test valid extension IDs."""
|
||||
# Should not raise exceptions
|
||||
validate_extension_id(valid_id)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_id,error_match",
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
("Hello-World", "Use lowercase"),
|
||||
("-hello", "cannot start with hyphens"),
|
||||
("hello-", "cannot end with hyphens"),
|
||||
("hello--world", "consecutive hyphens"),
|
||||
],
|
||||
)
|
||||
def test_validate_extension_id_invalid(self, invalid_id, error_match):
|
||||
"""Test invalid extension IDs."""
|
||||
with pytest.raises(ExtensionNameError, match=error_match):
|
||||
validate_extension_id(invalid_id)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_package",
|
||||
[
|
||||
"hello_world",
|
||||
"data_explorer",
|
||||
"myext",
|
||||
"test123",
|
||||
"package_with_many_parts",
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_valid(self, valid_package):
|
||||
"""Test valid Python package names."""
|
||||
# Should not raise exceptions
|
||||
validate_python_package_name(valid_package)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keyword",
|
||||
[
|
||||
"class",
|
||||
"import",
|
||||
"def",
|
||||
"return",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"while",
|
||||
"try",
|
||||
"except",
|
||||
"finally",
|
||||
"with",
|
||||
"as",
|
||||
"lambda",
|
||||
"yield",
|
||||
"False",
|
||||
"None",
|
||||
"True",
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_keywords(self, keyword):
|
||||
"""Test that Python reserved keywords are rejected."""
|
||||
with pytest.raises(
|
||||
ExtensionNameError, match="Package name cannot start with Python keyword"
|
||||
):
|
||||
validate_python_package_name(keyword)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_package",
|
||||
[
|
||||
"hello-world", # Hyphens not allowed in Python identifiers
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_invalid(self, invalid_package):
|
||||
"""Test invalid Python package names."""
|
||||
with pytest.raises(ExtensionNameError, match="not a valid Python package"):
|
||||
validate_python_package_name(invalid_package)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_npm",
|
||||
[
|
||||
"hello-world",
|
||||
"data-explorer",
|
||||
"myext",
|
||||
"package-with-many-parts",
|
||||
],
|
||||
)
|
||||
def test_validate_npm_package_name_valid(self, valid_npm):
|
||||
"""Test valid npm package names."""
|
||||
# Should not raise exceptions
|
||||
validate_npm_package_name(valid_npm)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reserved_name",
|
||||
["node_modules", "npm", "yarn", "package.json", "localhost", "favicon.ico"],
|
||||
)
|
||||
def test_validate_npm_package_name_reserved(self, reserved_name):
|
||||
"""Test that npm reserved names are rejected."""
|
||||
with pytest.raises(ExtensionNameError, match="reserved npm package name"):
|
||||
validate_npm_package_name(reserved_name)
|
||||
|
||||
|
||||
class TestNameGeneration:
|
||||
"""Test complete name generation."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"display_name,expected_kebab,expected_snake,expected_camel",
|
||||
[
|
||||
("Hello World", "hello-world", "hello_world", "helloWorld"),
|
||||
("Data Explorer", "data-explorer", "data_explorer", "dataExplorer"),
|
||||
("My Extension v2", "my-extension-v2", "my_extension_v2", "myExtensionV2"),
|
||||
("Chart Tool", "chart-tool", "chart_tool", "chartTool"),
|
||||
("Simple", "simple", "simple", "simple"),
|
||||
("API v2 Client", "api-v2-client", "api_v2_client", "apiV2Client"),
|
||||
(
|
||||
"Dashboard Helper",
|
||||
"dashboard-helper",
|
||||
"dashboard_helper",
|
||||
"dashboardHelper",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_generate_extension_names_complete_flow(
|
||||
self, display_name, expected_kebab, expected_snake, expected_camel
|
||||
):
|
||||
"""Test complete name generation flow from display name to all variants."""
|
||||
names = generate_extension_names(display_name)
|
||||
|
||||
# Test all transformations from single source
|
||||
assert names["name"] == display_name
|
||||
assert names["id"] == expected_kebab # Extension ID (kebab-case)
|
||||
assert names["mf_name"] == expected_camel # Module Federation (camelCase)
|
||||
assert names["backend_name"] == expected_snake # Python package (snake_case)
|
||||
assert names["backend_package"] == f"superset_extensions.{expected_snake}"
|
||||
assert (
|
||||
names["backend_entry"] == f"superset_extensions.{expected_snake}.entrypoint"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_display",
|
||||
[
|
||||
"Class Helper", # Would create 'class_helper' - reserved keyword
|
||||
"Import Tool", # Would create 'import_tool' - reserved keyword
|
||||
"@#$%", # All special chars - becomes empty
|
||||
"123 Tool", # Starts with number after kebab conversion
|
||||
],
|
||||
)
|
||||
def test_generate_extension_names_invalid(self, invalid_display):
|
||||
"""Test invalid name generation scenarios."""
|
||||
with pytest.raises(ExtensionNameError):
|
||||
generate_extension_names(invalid_display)
|
||||
|
||||
def test_generate_extension_names_unicode(self):
|
||||
"""Test handling of unicode characters."""
|
||||
names = generate_extension_names("Café Extension")
|
||||
assert "é" not in names["id"]
|
||||
assert names["id"] == "caf-extension"
|
||||
assert names["name"] == "Café Extension" # Original preserved
|
||||
|
||||
def test_generate_extension_names_special_chars(self):
|
||||
"""Test name generation with special characters."""
|
||||
names = generate_extension_names("My@Extension!")
|
||||
|
||||
assert names["name"] == "My@Extension!"
|
||||
assert names["id"] == "myextension"
|
||||
assert names["backend_name"] == "myextension"
|
||||
|
||||
def test_generate_extension_names_case_preservation(self):
|
||||
"""Test that display name case is preserved."""
|
||||
names = generate_extension_names("CamelCase Extension")
|
||||
assert names["name"] == "CamelCase Extension"
|
||||
assert names["id"] == "camelcase-extension"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and boundary conditions."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"edge_case",
|
||||
[
|
||||
"", # Empty string
|
||||
" ", # Only spaces
|
||||
"---", # Only hyphens
|
||||
"___", # Only underscores
|
||||
],
|
||||
)
|
||||
def test_empty_or_invalid_inputs(self, edge_case):
|
||||
"""Test inputs that become empty or invalid after processing."""
|
||||
with pytest.raises(ExtensionNameError):
|
||||
generate_extension_names(edge_case)
|
||||
|
||||
def test_minimal_valid_input(self):
|
||||
"""Test minimal valid input."""
|
||||
names = generate_extension_names("A Extension")
|
||||
assert names["id"] == "a-extension"
|
||||
assert names["backend_name"] == "a_extension"
|
||||
|
||||
def test_numbers_handling(self):
|
||||
"""Test handling of numbers in names."""
|
||||
names = generate_extension_names("Tool 123 v2")
|
||||
assert names["id"] == "tool-123-v2"
|
||||
assert names["backend_name"] == "tool_123_v2"
|
||||
|
||||
def test_id_based_name_generation(self):
|
||||
"""Test that technical names are derived from ID, not display name."""
|
||||
# Simulate manual ExtensionNames construction with custom ID
|
||||
display_name = "My Awesome Chart Builder Pro"
|
||||
extension_id = "chart-builder" # Much shorter than display name
|
||||
|
||||
# Create names using ID-based generation (new behavior)
|
||||
from superset_extensions_cli.types import ExtensionNames
|
||||
|
||||
names = ExtensionNames(
|
||||
name=display_name,
|
||||
id=extension_id,
|
||||
mf_name=kebab_to_camel_case(extension_id), # From ID: "chartBuilder"
|
||||
backend_name=kebab_to_snake_case(extension_id), # From ID: "chart_builder"
|
||||
backend_package=f"superset_extensions.{kebab_to_snake_case(extension_id)}",
|
||||
backend_entry=f"superset_extensions.{kebab_to_snake_case(extension_id)}.entrypoint",
|
||||
)
|
||||
|
||||
# Verify technical names come from ID, not display name
|
||||
assert names["name"] == "My Awesome Chart Builder Pro" # Display name preserved
|
||||
assert names["id"] == "chart-builder" # Extension ID
|
||||
assert (
|
||||
names["mf_name"] == "chartBuilder"
|
||||
) # From ID, not "myAwesomeChartBuilderPro"
|
||||
assert (
|
||||
names["backend_name"] == "chart_builder"
|
||||
) # From ID, not "my_awesome_chart_builder_pro"
|
||||
assert names["backend_package"] == "superset_extensions.chart_builder"
|
||||
assert names["backend_entry"] == "superset_extensions.chart_builder.entrypoint"
|
||||
|
||||
def test_generate_names_uses_id_based_technical_names(self):
|
||||
"""Test that generate_extension_names uses ID-based generation for technical names."""
|
||||
display_name = "Hello World"
|
||||
|
||||
# Generated names should use ID-based technical name generation
|
||||
names = generate_extension_names(display_name)
|
||||
|
||||
# Verify the ID was generated from display name
|
||||
assert names["id"] == "hello-world"
|
||||
|
||||
# Verify technical names were generated from the ID, not original display name
|
||||
assert names["mf_name"] == kebab_to_camel_case("hello-world") # "helloWorld"
|
||||
assert names["backend_name"] == kebab_to_snake_case(
|
||||
"hello-world"
|
||||
) # "hello_world"
|
||||
|
||||
# For this simple case, the results are the same as before, but the path is different:
|
||||
# Old path: Display Name -> camelCase directly
|
||||
# New path: Display Name -> ID -> camelCase from ID
|
||||
assert names["mf_name"] == "helloWorld"
|
||||
assert names["backend_name"] == "hello_world"
|
||||
@@ -42,8 +42,12 @@ def jinja_env(templates_dir):
|
||||
def template_context():
|
||||
"""Default template context for testing."""
|
||||
return {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"id": "test-extension",
|
||||
"mf_name": "testExtension",
|
||||
"backend_name": "test_extension",
|
||||
"backend_package": "superset_extensions.test_extension",
|
||||
"backend_entry": "superset_extensions.test_extension.entrypoint",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"include_frontend": True,
|
||||
@@ -64,7 +68,7 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
# Verify basic fields
|
||||
assert parsed["id"] == "test_extension"
|
||||
assert parsed["id"] == "test-extension"
|
||||
assert parsed["name"] == "Test Extension"
|
||||
assert parsed["version"] == "0.1.0"
|
||||
assert parsed["license"] == "Apache-2.0"
|
||||
@@ -76,13 +80,18 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
|
||||
assert "contributions" in frontend
|
||||
assert "moduleFederation" in frontend
|
||||
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
|
||||
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
|
||||
assert frontend["moduleFederation"] == {
|
||||
"exposes": ["./index"],
|
||||
"name": "testExtension",
|
||||
}
|
||||
|
||||
# Verify backend section exists
|
||||
assert "backend" in parsed
|
||||
backend = parsed["backend"]
|
||||
assert backend["entryPoints"] == ["test_extension.entrypoint"]
|
||||
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
|
||||
assert backend["entryPoints"] == ["superset_extensions.test_extension.entrypoint"]
|
||||
assert backend["files"] == [
|
||||
"backend/src/superset_extensions/test_extension/**/*.py"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -127,7 +136,7 @@ def test_frontend_package_json_template_renders_correctly(jinja_env, template_co
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
# Verify basic package info
|
||||
assert parsed["name"] == "test_extension"
|
||||
assert parsed["name"] == "test-extension"
|
||||
assert parsed["version"] == "0.1.0"
|
||||
assert parsed["license"] == "Apache-2.0"
|
||||
assert parsed["private"] is True
|
||||
@@ -169,19 +178,34 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
|
||||
# Template Rendering with Different Parameters Tests
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"id_,name",
|
||||
"extension_id,name,backend_name",
|
||||
[
|
||||
("simple_extension", "Simple Extension"),
|
||||
("MyExtension123", "My Extension 123"),
|
||||
("complex_extension_name_123", "Complex Extension Name 123"),
|
||||
("ext", "Ext"),
|
||||
("simple-extension", "Simple Extension", "simple_extension"),
|
||||
("my-extension-123", "My Extension 123", "my_extension_123"),
|
||||
(
|
||||
"complex-extension-name-123",
|
||||
"Complex Extension Name 123",
|
||||
"complex_extension_name_123",
|
||||
),
|
||||
("ext", "Ext", "ext"),
|
||||
],
|
||||
)
|
||||
def test_template_rendering_with_different_ids(jinja_env, id_, name):
|
||||
def test_template_rendering_with_different_ids(
|
||||
jinja_env, extension_id, name, backend_name
|
||||
):
|
||||
"""Test templates render correctly with various extension ids/names."""
|
||||
# Generate camelCase name for webpack from extension ID (new ID-based approach)
|
||||
from superset_extensions_cli.utils import kebab_to_camel_case
|
||||
|
||||
mf_name = kebab_to_camel_case(extension_id)
|
||||
|
||||
context = {
|
||||
"id": id_,
|
||||
"id": extension_id,
|
||||
"name": name,
|
||||
"mf_name": mf_name,
|
||||
"backend_name": backend_name,
|
||||
"backend_package": f"superset_extensions.{backend_name}",
|
||||
"backend_entry": f"superset_extensions.{backend_name}.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"include_frontend": True,
|
||||
@@ -193,23 +217,27 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
|
||||
rendered = template.render(context)
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
assert parsed["id"] == id_
|
||||
assert parsed["id"] == extension_id
|
||||
assert parsed["name"] == name
|
||||
assert parsed["backend"]["entryPoints"] == [f"{id_}.entrypoint"]
|
||||
assert parsed["backend"]["files"] == [f"backend/src/{id_}/**/*.py"]
|
||||
assert parsed["backend"]["entryPoints"] == [
|
||||
f"superset_extensions.{backend_name}.entrypoint"
|
||||
]
|
||||
assert parsed["backend"]["files"] == [
|
||||
f"backend/src/superset_extensions/{backend_name}/**/*.py"
|
||||
]
|
||||
|
||||
# Test package.json template
|
||||
template = jinja_env.get_template("frontend/package.json.j2")
|
||||
rendered = template.render(context)
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
assert parsed["name"] == id_
|
||||
assert parsed["name"] == extension_id
|
||||
|
||||
# Test pyproject.toml template
|
||||
template = jinja_env.get_template("backend/pyproject.toml.j2")
|
||||
rendered = template.render(context)
|
||||
|
||||
assert id_ in rendered
|
||||
assert f"superset_extensions.{backend_name}" in rendered
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -219,6 +247,7 @@ def test_template_rendering_with_different_versions(jinja_env, version):
|
||||
context = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"mf_name": "testExtension",
|
||||
"version": version,
|
||||
"license": "Apache-2.0",
|
||||
"include_frontend": True,
|
||||
@@ -248,6 +277,10 @@ def test_template_rendering_with_different_licenses(jinja_env, license_type):
|
||||
context = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"mf_name": "testExtension",
|
||||
"backend_name": "test_ext",
|
||||
"backend_package": "superset_extensions.test_ext",
|
||||
"backend_entry": "superset_extensions.test_ext.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": license_type,
|
||||
"include_frontend": True,
|
||||
@@ -314,6 +347,10 @@ def test_template_context_edge_cases(jinja_env):
|
||||
minimal_context = {
|
||||
"id": "minimal",
|
||||
"name": "Minimal",
|
||||
"mf_name": "minimal",
|
||||
"backend_name": "minimal",
|
||||
"backend_package": "superset_extensions.minimal",
|
||||
"backend_entry": "superset_extensions.minimal.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"include_frontend": False,
|
||||
|
||||
@@ -160,7 +160,9 @@ class ExtensionsManager {
|
||||
// Initialize Webpack module federation
|
||||
// @ts-expect-error
|
||||
await __webpack_init_sharing__('default');
|
||||
const container = (window as any)[id];
|
||||
// Use moduleFederationName (camelCase) for webpack container access, fallback to id for compatibility
|
||||
const containerName = (extension as any).moduleFederationName || id;
|
||||
const container = (window as any)[containerName];
|
||||
|
||||
// @ts-expect-error
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
@@ -80,6 +80,34 @@ class InMemoryFinder(importlib.abc.MetaPathFinder):
|
||||
|
||||
self.modules[mod_name] = (content, is_package, full_path)
|
||||
|
||||
# Create namespace packages for all parent modules
|
||||
# This ensures 'superset_extensions' namespace package exists
|
||||
namespace_packages: set[str] = set()
|
||||
for mod_name in list(self.modules.keys()):
|
||||
parts = mod_name.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
namespace_name = ".".join(parts[:i])
|
||||
if namespace_name not in self.modules:
|
||||
namespace_packages.add(namespace_name)
|
||||
|
||||
# Add namespace packages
|
||||
for ns_name in namespace_packages:
|
||||
# Create a virtual __init__.py path for the namespace package
|
||||
if is_virtual_path:
|
||||
ns_path = f"{source_base_path}/backend/src/"
|
||||
f"{ns_name.replace('.', '/')}/__init__.py"
|
||||
else:
|
||||
ns_path = str(
|
||||
Path(source_base_path)
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ ns_name.replace(".", "/")
|
||||
/ "__init__.py"
|
||||
)
|
||||
|
||||
# Namespace packages have empty content
|
||||
self.modules[ns_name] = (b"", True, ns_path)
|
||||
|
||||
def _get_module_name(self, file_path: str) -> Tuple[str, bool]:
|
||||
parts = list(Path(file_path).parts)
|
||||
is_package = parts[-1] == "__init__.py"
|
||||
@@ -217,6 +245,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
||||
{
|
||||
"remoteEntry": remote_entry_url,
|
||||
"exposedModules": module_federation.exposes,
|
||||
"moduleFederationName": module_federation.name,
|
||||
"contributions": frontend.contributions.model_dump(),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user